actual-mcp-server 0.5.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.
Files changed (101) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +663 -0
  3. package/bin/actual-mcp-server.js +3 -0
  4. package/dist/generated/actual-client/types.js +5 -0
  5. package/dist/package.json +88 -0
  6. package/dist/src/actualConnection.js +157 -0
  7. package/dist/src/actualToolsManager.js +211 -0
  8. package/dist/src/auth/budget-acl.js +143 -0
  9. package/dist/src/auth/setup.js +58 -0
  10. package/dist/src/config.js +41 -0
  11. package/dist/src/index.js +313 -0
  12. package/dist/src/lib/ActualConnectionPool.js +343 -0
  13. package/dist/src/lib/ActualMCPConnection.js +125 -0
  14. package/dist/src/lib/actual-adapter.js +1228 -0
  15. package/dist/src/lib/actual-schema.js +222 -0
  16. package/dist/src/lib/budget-registry.js +64 -0
  17. package/dist/src/lib/constants.js +121 -0
  18. package/dist/src/lib/errors.js +19 -0
  19. package/dist/src/lib/loggerFactory.js +72 -0
  20. package/dist/src/lib/node-polyfills.js +20 -0
  21. package/dist/src/lib/query-validator.js +221 -0
  22. package/dist/src/lib/retry.js +26 -0
  23. package/dist/src/lib/schemas/common.js +203 -0
  24. package/dist/src/lib/toolFactory.js +109 -0
  25. package/dist/src/logger.js +127 -0
  26. package/dist/src/observability.js +58 -0
  27. package/dist/src/prompts/showLargeTransactions.js +6 -0
  28. package/dist/src/resources/accountsSummary.js +13 -0
  29. package/dist/src/server/httpServer.js +540 -0
  30. package/dist/src/server/httpServer_testing.js +401 -0
  31. package/dist/src/server/stdioServer.js +52 -0
  32. package/dist/src/server/streamable-http.js +148 -0
  33. package/dist/src/tests/actualToolsTests.js +70 -0
  34. package/dist/src/tests/observability.smoke.test.js +18 -0
  35. package/dist/src/tests/testMcpClient.js +170 -0
  36. package/dist/src/tests_adapter_runner.js +86 -0
  37. package/dist/src/tools/accounts_close.js +16 -0
  38. package/dist/src/tools/accounts_create.js +27 -0
  39. package/dist/src/tools/accounts_delete.js +16 -0
  40. package/dist/src/tools/accounts_get_balance.js +40 -0
  41. package/dist/src/tools/accounts_list.js +16 -0
  42. package/dist/src/tools/accounts_reopen.js +16 -0
  43. package/dist/src/tools/accounts_update.js +52 -0
  44. package/dist/src/tools/bank_sync.js +22 -0
  45. package/dist/src/tools/budget_updates_batch.js +77 -0
  46. package/dist/src/tools/budgets_getMonth.js +14 -0
  47. package/dist/src/tools/budgets_getMonths.js +14 -0
  48. package/dist/src/tools/budgets_get_all.js +13 -0
  49. package/dist/src/tools/budgets_holdForNextMonth.js +19 -0
  50. package/dist/src/tools/budgets_list_available.js +20 -0
  51. package/dist/src/tools/budgets_resetHold.js +16 -0
  52. package/dist/src/tools/budgets_setAmount.js +26 -0
  53. package/dist/src/tools/budgets_setCarryover.js +18 -0
  54. package/dist/src/tools/budgets_switch.js +27 -0
  55. package/dist/src/tools/budgets_transfer.js +64 -0
  56. package/dist/src/tools/categories_create.js +65 -0
  57. package/dist/src/tools/categories_delete.js +16 -0
  58. package/dist/src/tools/categories_get.js +14 -0
  59. package/dist/src/tools/categories_update.js +22 -0
  60. package/dist/src/tools/category_groups_create.js +18 -0
  61. package/dist/src/tools/category_groups_delete.js +26 -0
  62. package/dist/src/tools/category_groups_get.js +13 -0
  63. package/dist/src/tools/category_groups_update.js +21 -0
  64. package/dist/src/tools/get_id_by_name.js +36 -0
  65. package/dist/src/tools/index.js +63 -0
  66. package/dist/src/tools/payee_rules_get.js +27 -0
  67. package/dist/src/tools/payees_create.js +25 -0
  68. package/dist/src/tools/payees_delete.js +16 -0
  69. package/dist/src/tools/payees_get.js +14 -0
  70. package/dist/src/tools/payees_merge.js +17 -0
  71. package/dist/src/tools/payees_update.js +59 -0
  72. package/dist/src/tools/query_run.js +78 -0
  73. package/dist/src/tools/rules_create.js +129 -0
  74. package/dist/src/tools/rules_create_or_update.js +191 -0
  75. package/dist/src/tools/rules_delete.js +26 -0
  76. package/dist/src/tools/rules_get.js +13 -0
  77. package/dist/src/tools/rules_update.js +120 -0
  78. package/dist/src/tools/schedules_create.js +54 -0
  79. package/dist/src/tools/schedules_delete.js +41 -0
  80. package/dist/src/tools/schedules_get.js +13 -0
  81. package/dist/src/tools/schedules_update.js +40 -0
  82. package/dist/src/tools/server_get_version.js +22 -0
  83. package/dist/src/tools/server_info.js +86 -0
  84. package/dist/src/tools/session_close.js +100 -0
  85. package/dist/src/tools/session_list.js +24 -0
  86. package/dist/src/tools/transactions_create.js +50 -0
  87. package/dist/src/tools/transactions_delete.js +20 -0
  88. package/dist/src/tools/transactions_filter.js +73 -0
  89. package/dist/src/tools/transactions_get.js +23 -0
  90. package/dist/src/tools/transactions_import.js +21 -0
  91. package/dist/src/tools/transactions_search_by_amount.js +126 -0
  92. package/dist/src/tools/transactions_search_by_category.js +137 -0
  93. package/dist/src/tools/transactions_search_by_month.js +142 -0
  94. package/dist/src/tools/transactions_search_by_payee.js +142 -0
  95. package/dist/src/tools/transactions_summary_by_category.js +80 -0
  96. package/dist/src/tools/transactions_summary_by_payee.js +72 -0
  97. package/dist/src/tools/transactions_uncategorized.js +66 -0
  98. package/dist/src/tools/transactions_update.js +34 -0
  99. package/dist/src/tools/transactions_update_batch.js +60 -0
  100. package/dist/src/utils.js +63 -0
  101. package/package.json +88 -0
@@ -0,0 +1,88 @@
1
+ {
2
+ "name": "actual-mcp-server",
3
+ "displayName": "Actual MCP Server",
4
+ "version": "0.5.0",
5
+ "description": "MCP server with 62 tools for AI-driven financial management with Actual Budget — HTTP and stdio transports, LibreChat, Claude Desktop, Cursor, VS Code, Gemini CLI",
6
+ "main": "dist/src/index.js",
7
+ "bin": {
8
+ "actual-mcp-server": "./bin/actual-mcp-server.js"
9
+ },
10
+ "files": [
11
+ "bin/",
12
+ "dist/",
13
+ "README.md",
14
+ "LICENSE"
15
+ ],
16
+ "type": "module",
17
+ "scripts": {
18
+ "build": "tsc",
19
+ "dev": "npm run build && node scripts/register-tsconfig-paths.js -- --debug",
20
+ "start": "node dist/src/index.js",
21
+ "verify-tools": "npm run build && node scripts/verify-tools.js",
22
+ "check:coverage": "node scripts/list-actual-api-methods.mjs",
23
+ "direct-sync": "node scripts/direct-sync/bank-sync-direct.mjs",
24
+ "test:unit-js": "node tests/unit/transactions_create.test.js && node tests/unit/generated_tools.smoke.test.js && node tests/unit/schema_validation.test.js && node tests/unit/auth-acl.test.js && node tests/unit/bug76.test.js && node tests/unit/budgets_setAmount.test.js && node tests/unit/transactions_uncategorized.test.js",
25
+ "test:adapter": "npm run build && node dist/src/tests_adapter_runner.js",
26
+ "test:e2e": "npx playwright test",
27
+ "test:e2e:docker": "./tests/e2e/run-docker-e2e.sh",
28
+ "test:e2e:docker:smoke": "./tests/e2e/run-docker-e2e.sh smoke",
29
+ "test:e2e:docker:full": "./tests/e2e/run-docker-e2e.sh full",
30
+ "test:all": "npm run test:adapter && npm run test:unit-js && npm run test:e2e:docker:smoke",
31
+ "test:mcp-client": "npm run build && node scripts/register-tsconfig-paths.js -- --http --test-mcp-client",
32
+ "test:integration": "MCP_TEST_LEVEL=sanity node tests/manual/index.js",
33
+ "test:integration:sanity": "MCP_TEST_LEVEL=sanity node tests/manual/index.js",
34
+ "test:integration:smoke": "MCP_TEST_LEVEL=smoke node tests/manual/index.js",
35
+ "test:integration:normal": "MCP_TEST_LEVEL=normal node tests/manual/index.js",
36
+ "test:integration:extended": "MCP_TEST_LEVEL=extended node tests/manual/index.js",
37
+ "test:integration:full": "MCP_TEST_LEVEL=full node tests/manual/index.js",
38
+ "test:integration:cleanup": "MCP_TEST_LEVEL=cleanup node tests/manual/index.js",
39
+ "deploy:full": "bash scripts/deploy-and-test.sh full",
40
+ "deploy:smoke": "bash scripts/deploy-and-test.sh smoke",
41
+ "docs:sync": "node scripts/version-bump.js sync",
42
+ "version:current": "cat VERSION",
43
+ "version:dev": "node scripts/version-dev.js",
44
+ "version:bump": "node scripts/version-bump.js",
45
+ "version:check": "node scripts/version-check.js",
46
+ "release:major": "npm run version:bump -- major",
47
+ "release:minor": "npm run version:bump -- minor",
48
+ "release:patch": "npm run version:bump -- patch"
49
+ },
50
+ "dependencies": {
51
+ "@actual-app/api": "^26.4.0",
52
+ "@modelcontextprotocol/sdk": "^1.29.0",
53
+ "dotenv": "^17.4.1",
54
+ "express": "^5.2.1",
55
+ "mcp-auth": "^0.2.0",
56
+ "winston": "^3.18.3",
57
+ "winston-daily-rotate-file": "^5.0.0",
58
+ "zod": "^4.0.0"
59
+ },
60
+ "overrides": {
61
+ "ajv": "8.18.0",
62
+ "qs": "6.14.2"
63
+ },
64
+ "comments": {
65
+ "security-overrides": "ajv>=8.18.0 (CVE alert #21), qs>=6.14.2 (alert #17)"
66
+ },
67
+ "devDependencies": {
68
+ "@playwright/test": "^1.59.1",
69
+ "@types/express": "^5.0.3",
70
+ "@types/node": "^25.6.0",
71
+ "tsconfig-paths": "^4.2.0",
72
+ "typescript": "^6.0.2"
73
+ },
74
+ "license": "MIT",
75
+ "keywords": [
76
+ "mcp",
77
+ "model-context-protocol",
78
+ "actual-budget",
79
+ "actual-finance",
80
+ "librechat",
81
+ "claude-desktop",
82
+ "cursor",
83
+ "budget",
84
+ "finance",
85
+ "ai"
86
+ ],
87
+ "author": "agigante80"
88
+ }
@@ -0,0 +1,157 @@
1
+ import fs from 'fs';
2
+ import path from 'path';
3
+ import os from 'os';
4
+ import api from '@actual-app/api';
5
+ import logger from './logger.js';
6
+ import config from './config.js';
7
+ import { connectionPool } from './lib/ActualConnectionPool.js';
8
+ import { createModuleLogger } from './lib/loggerFactory.js';
9
+ const log = createModuleLogger('CONNECTION');
10
+ const DEFAULT_DATA_DIR = path.resolve(os.homedir() || '.', '.actual');
11
+ let initialized = false;
12
+ let initializing = false;
13
+ let initializationError = null;
14
+ // Feature flag to enable connection pooling - can be disabled via environment variable
15
+ let useConnectionPool = process.env.USE_CONNECTION_POOL !== 'false';
16
+ export async function connectToActual() {
17
+ if (initialized)
18
+ return;
19
+ if (initializing) {
20
+ while (initializing)
21
+ await new Promise(r => setTimeout(r, 100));
22
+ if (initializationError)
23
+ throw initializationError;
24
+ return;
25
+ }
26
+ initializing = true;
27
+ try {
28
+ const SERVER_URL = config.ACTUAL_SERVER_URL;
29
+ const PASSWORD = config.ACTUAL_PASSWORD;
30
+ const BUDGET_SYNC_ID = config.ACTUAL_BUDGET_SYNC_ID;
31
+ const BUDGET_PASSWORD = process.env.ACTUAL_BUDGET_PASSWORD; // optional for E2E encrypted budgets
32
+ const TEST_ACTUAL_CONNECTION = process.argv.includes('--test-actual-connection');
33
+ // Use configured MCP_BRIDGE_DATA_DIR (fallback to DEFAULT_DATA_DIR) for all runs
34
+ const DATA_DIR = config.MCP_BRIDGE_DATA_DIR || DEFAULT_DATA_DIR;
35
+ new URL(SERVER_URL);
36
+ if (!fs.existsSync(DATA_DIR))
37
+ fs.mkdirSync(DATA_DIR, { recursive: true });
38
+ log.info(`Initializing Actual API with dataDir=${DATA_DIR}`);
39
+ await api.init({
40
+ dataDir: DATA_DIR,
41
+ serverURL: SERVER_URL,
42
+ password: PASSWORD,
43
+ });
44
+ log.info(`Downloading budget with sync ID: ${BUDGET_SYNC_ID}`);
45
+ // According to official docs, downloadBudget accepts optional second parameter for E2E encryption
46
+ if (BUDGET_PASSWORD) {
47
+ const apiWithOptions = api;
48
+ await apiWithOptions.downloadBudget(BUDGET_SYNC_ID, { password: BUDGET_PASSWORD });
49
+ }
50
+ else {
51
+ await api.downloadBudget(BUDGET_SYNC_ID);
52
+ }
53
+ if (TEST_ACTUAL_CONNECTION) {
54
+ logger.info('Test flag detected (--test-actual-connection) — closing Actual session.');
55
+ // Prefer the documented shutdown method only
56
+ try {
57
+ const maybeApi = api;
58
+ if (typeof maybeApi.shutdown === 'function') {
59
+ await maybeApi.shutdown();
60
+ }
61
+ else {
62
+ logger.warn('No shutdown method found on Actual API; leaving session as-is.');
63
+ }
64
+ }
65
+ catch (closeErr) {
66
+ logger.error('Error while shutting down Actual session during test run:', closeErr);
67
+ }
68
+ // allow small grace period for any IO to finish before cleanup/exit
69
+ await new Promise((res) => setTimeout(res, 500));
70
+ // no temp data dir cleanup — use persistent MCP_BRIDGE_DATA_DIR as configured
71
+ logger.info('Exiting process after test connection.');
72
+ // exit explicitly for test mode
73
+ process.exit(0);
74
+ }
75
+ initialized = true;
76
+ logger.info('✅ Connected to Actual Finance and downloaded budget');
77
+ }
78
+ catch (err) {
79
+ initializationError = err instanceof Error ? err : new Error(String(err));
80
+ logger.error('❌ Failed to connect to Actual Finance:', initializationError);
81
+ throw initializationError;
82
+ }
83
+ finally {
84
+ initializing = false;
85
+ }
86
+ }
87
+ export async function shutdownActual() {
88
+ if (useConnectionPool) {
89
+ await connectionPool.shutdownAll();
90
+ initialized = false;
91
+ return;
92
+ }
93
+ try {
94
+ const maybeApi = api;
95
+ if (typeof maybeApi.shutdown === 'function') {
96
+ await maybeApi.shutdown();
97
+ }
98
+ initialized = false;
99
+ logger.info('Actual API shutdown complete.');
100
+ }
101
+ catch (err) {
102
+ logger.error('Error during Actual API shutdown:', err);
103
+ }
104
+ }
105
+ /**
106
+ * Initialize connection for a specific MCP session
107
+ * Uses connection pooling to give each session its own Actual Budget connection
108
+ */
109
+ export async function connectToActualForSession(sessionId) {
110
+ if (!useConnectionPool) {
111
+ // Fallback to shared connection
112
+ return connectToActual();
113
+ }
114
+ try {
115
+ // Ensure connection pool initialization is complete before accepting connections
116
+ await connectionPool.waitForInitialization();
117
+ await connectionPool.getConnection(sessionId);
118
+ logger.info(`Actual API connection ready for session: ${sessionId}`);
119
+ }
120
+ catch (err) {
121
+ logger.error(`Failed to connect to Actual for session ${sessionId}:`, err);
122
+ throw err;
123
+ }
124
+ }
125
+ /**
126
+ * Shutdown connection for a specific MCP session
127
+ */
128
+ export async function shutdownActualForSession(sessionId) {
129
+ if (!useConnectionPool) {
130
+ return;
131
+ }
132
+ try {
133
+ await connectionPool.shutdownConnection(sessionId);
134
+ logger.info(`Actual API connection shutdown for session: ${sessionId}`);
135
+ }
136
+ catch (err) {
137
+ logger.error(`Error shutting down Actual for session ${sessionId}:`, err);
138
+ }
139
+ }
140
+ export function getConnectionState() {
141
+ // When using connection pooling, consider server initialized if pool is ready
142
+ // (even with 0 active connections, pool being initialized means server is ready to accept sessions)
143
+ const isPoolInitialized = useConnectionPool && connectionPool.isInitialized();
144
+ const effectivelyInitialized = useConnectionPool ? isPoolInitialized : initialized;
145
+ return {
146
+ initialized: effectivelyInitialized,
147
+ initializationError,
148
+ connectionPool: useConnectionPool ? connectionPool.getStats() : null,
149
+ idleTimeoutMinutes: useConnectionPool ? connectionPool.getIdleTimeoutMinutes() : null,
150
+ };
151
+ }
152
+ export function canAcceptNewSession() {
153
+ if (!useConnectionPool) {
154
+ return true; // Shared connection mode - always accept
155
+ }
156
+ return connectionPool.canAcceptNewSession();
157
+ }
@@ -0,0 +1,211 @@
1
+ import logger from './logger.js';
2
+ import { z } from 'zod';
3
+ // ✅ List of tools already implemented in this class.
4
+ // Adding the tool name here is considered fully implemented.
5
+ const IMPLEMENTED_TOOLS = [
6
+ 'actual_accounts_close',
7
+ 'actual_accounts_create',
8
+ 'actual_accounts_delete',
9
+ 'actual_accounts_get_balance',
10
+ 'actual_accounts_list',
11
+ 'actual_accounts_reopen',
12
+ 'actual_accounts_update',
13
+ 'actual_bank_sync',
14
+ 'actual_budgets_get_all',
15
+ 'actual_budgets_list_available',
16
+ 'actual_budgets_switch',
17
+ 'actual_budgets_getMonth',
18
+ 'actual_budgets_getMonths',
19
+ 'actual_budgets_holdForNextMonth',
20
+ 'actual_budgets_resetHold',
21
+ 'actual_budgets_setAmount',
22
+ 'actual_budgets_setCarryover',
23
+ 'actual_budgets_transfer',
24
+ 'actual_budget_updates_batch',
25
+ 'actual_categories_create',
26
+ 'actual_categories_delete',
27
+ 'actual_categories_get',
28
+ 'actual_categories_update',
29
+ 'actual_category_groups_create',
30
+ 'actual_category_groups_delete',
31
+ 'actual_category_groups_get',
32
+ 'actual_category_groups_update',
33
+ 'actual_payees_create',
34
+ 'actual_payees_delete',
35
+ 'actual_payees_get',
36
+ 'actual_payees_merge',
37
+ 'actual_payees_update',
38
+ 'actual_payee_rules_get',
39
+ 'actual_query_run',
40
+ 'actual_rules_create',
41
+ 'actual_rules_create_or_update',
42
+ 'actual_rules_delete',
43
+ 'actual_rules_get',
44
+ 'actual_rules_update',
45
+ 'actual_schedules_create',
46
+ 'actual_schedules_delete',
47
+ 'actual_schedules_get',
48
+ 'actual_schedules_update',
49
+ 'actual_transactions_create',
50
+ 'actual_transactions_delete',
51
+ 'actual_transactions_filter',
52
+ 'actual_transactions_get',
53
+ 'actual_transactions_import',
54
+ 'actual_transactions_search_by_amount',
55
+ 'actual_transactions_search_by_category',
56
+ 'actual_transactions_search_by_month',
57
+ 'actual_transactions_search_by_payee',
58
+ 'actual_transactions_summary_by_category',
59
+ 'actual_transactions_summary_by_payee',
60
+ 'actual_transactions_uncategorized',
61
+ 'actual_transactions_update',
62
+ 'actual_transactions_update_batch',
63
+ 'actual_server_info',
64
+ 'actual_session_close',
65
+ 'actual_session_list',
66
+ 'actual_get_id_by_name',
67
+ 'actual_server_get_version',
68
+ ];
69
+ // 🔑 Mapping of Actual API function names → your MCP tool names
70
+ // This allows us to compare what exists in the API vs. what it has been wrapped.
71
+ const API_TOOL_MAP = {
72
+ getAccounts: 'actual_accounts_list',
73
+ createAccount: 'actual_accounts_create',
74
+ updateAccount: 'actual_accounts_update',
75
+ deleteAccount: 'actual_accounts_delete',
76
+ getAccountBalance: 'actual_accounts_get_balance',
77
+ getTransactions: 'actual_transactions_get',
78
+ addTransactions: 'actual_transactions_create',
79
+ importTransactions: 'actual_transactions_import',
80
+ updateTransaction: 'actual_transactions_update',
81
+ deleteTransaction: 'actual_transactions_delete',
82
+ getBudgetMonths: 'actual_budgets_getMonths',
83
+ getBudgetMonth: 'actual_budgets_getMonth',
84
+ setBudgetAmount: 'actual_budgets_setAmount',
85
+ setBudgetCarryover: 'actual_budgets_setCarryover',
86
+ getCategories: 'actual_categories_get',
87
+ createCategory: 'actual_categories_create',
88
+ updateCategory: 'actual_categories_update',
89
+ deleteCategory: 'actual_categories_delete',
90
+ getPayees: 'actual_payees_get',
91
+ createPayee: 'actual_payees_create',
92
+ updatePayee: 'actual_payees_update',
93
+ deletePayee: 'actual_payees_delete',
94
+ mergePayees: 'actual_payees_merge',
95
+ getPayeeRules: 'actual_payee_rules_get',
96
+ batchBudgetUpdates: 'actual_budget_updates_batch',
97
+ holdBudgetForNextMonth: 'actual_budgets_holdForNextMonth',
98
+ resetBudgetHold: 'actual_budgets_resetHold',
99
+ getRules: 'actual_rules_get',
100
+ createRule: 'actual_rules_create',
101
+ updateRule: 'actual_rules_update',
102
+ deleteRule: 'actual_rules_delete',
103
+ closeAccount: 'actual_accounts_close',
104
+ reopenAccount: 'actual_accounts_reopen',
105
+ getCategoryGroups: 'actual_category_groups_get',
106
+ createCategoryGroup: 'actual_category_groups_create',
107
+ updateCategoryGroup: 'actual_category_groups_update',
108
+ deleteCategoryGroup: 'actual_category_groups_delete',
109
+ getIDByName: 'actual_get_id_by_name',
110
+ getServerVersion: 'actual_server_get_version',
111
+ };
112
+ // Define Account schema for validation (simplified example)
113
+ const AccountSchema = z.object({
114
+ id: z.string().optional(),
115
+ name: z.string(),
116
+ type: z.string(), // Consider enum if you have fixed valid types
117
+ offbudget: z.boolean().optional().default(false),
118
+ closed: z.boolean().optional().default(false),
119
+ });
120
+ // Input schema for get_accounts - no args
121
+ const GetAccountsInputSchema = z.object({});
122
+ // Input schema for get_account_balance
123
+ const GetAccountBalanceInputSchema = z.object({
124
+ id: z.string().optional().describe('Account ID to get balance for (optional; defaults to first account)'),
125
+ cutoff: z.string().optional().describe('Optional ISO date string cutoff'),
126
+ });
127
+ class ActualToolsManager {
128
+ tools = new Map();
129
+ constructor() { }
130
+ async initialize() {
131
+ // Dynamically import all tool modules from src/tools/index.ts
132
+ const toolModules = (await import('./tools/index.js'));
133
+ let count = 0;
134
+ for (const [key, tool] of Object.entries(toolModules)) {
135
+ const t = tool;
136
+ if (t && t.name) {
137
+ this.tools.set(t.name, tool);
138
+ count++;
139
+ }
140
+ }
141
+ logger.info(`🔗 Loaded ${count} tool modules from src/tools`);
142
+ }
143
+ getToolNames() {
144
+ return Array.from(this.tools.keys());
145
+ }
146
+ getTool(name) {
147
+ return this.tools.get(name);
148
+ }
149
+ async callTool(name, args) {
150
+ const tool = this.getTool(name);
151
+ if (!tool)
152
+ throw new Error(`Tool not found: ${name}`);
153
+ try {
154
+ const result = await tool.call(args);
155
+ // Force-serialize/deserialize to ensure result is JSON-safe (no circular refs,
156
+ // Buffers, class instances, etc). Convert undefined -> null so callers always
157
+ // receive a valid JSON value.
158
+ const safe = result === undefined ? null : JSON.parse(JSON.stringify(result));
159
+ logger.info(`[TOOL RESULT] ${name}: ${JSON.stringify(safe)}`);
160
+ return safe;
161
+ }
162
+ catch (err) {
163
+ // Format Zod validation errors more clearly
164
+ if (err && typeof err === 'object' && 'issues' in err) {
165
+ const zodError = err;
166
+ const issues = zodError.issues.map(issue => {
167
+ const path = issue.path.join('.');
168
+ return `${path}: ${issue.message}`;
169
+ }).join(', ');
170
+ const formattedMsg = `Validation error: ${issues}`;
171
+ logger.error(`[TOOL ERROR] ${name}: ${formattedMsg}`);
172
+ throw new Error(formattedMsg);
173
+ }
174
+ const e = err;
175
+ const msg = e && typeof e.message === 'string' ? e.message : String(err);
176
+ logger.error(`[TOOL ERROR] ${name}: ${msg}`);
177
+ throw err;
178
+ }
179
+ }
180
+ /**
181
+ * Get coverage statistics comparing implemented tools with available API methods
182
+ */
183
+ getCoverageStats() {
184
+ const apiMethods = Object.keys(API_TOOL_MAP);
185
+ const mappedTools = Object.values(API_TOOL_MAP);
186
+ const implemented = IMPLEMENTED_TOOLS;
187
+ const missing = mappedTools.filter(tool => !implemented.includes(tool));
188
+ const coverage = (implemented.length / mappedTools.length) * 100;
189
+ return {
190
+ totalApiMethods: apiMethods.length,
191
+ totalMappedTools: mappedTools.length,
192
+ implementedTools: implemented.length,
193
+ missingTools: missing.length,
194
+ coveragePercent: Math.round(coverage * 100) / 100,
195
+ missingToolsList: missing,
196
+ };
197
+ }
198
+ /**
199
+ * Get the API method name for a given tool name
200
+ */
201
+ getApiMethodForTool(toolName) {
202
+ return Object.entries(API_TOOL_MAP).find(([_, tool]) => tool === toolName)?.[0];
203
+ }
204
+ /**
205
+ * Get the tool name for a given API method
206
+ */
207
+ getToolForApiMethod(apiMethod) {
208
+ return API_TOOL_MAP[apiMethod];
209
+ }
210
+ }
211
+ export default new ActualToolsManager();
@@ -0,0 +1,143 @@
1
+ // src/auth/budget-acl.ts
2
+ //
3
+ // Per-user budget ACL enforcement (CF-5: OIDC multi-user auth).
4
+ //
5
+ // When AUTH_PROVIDER=oidc and AUTH_BUDGET_ACL is set, this module restricts
6
+ // which Actual Budget sync-IDs each authenticated user may access.
7
+ //
8
+ // ACL map format (AUTH_BUDGET_ACL env var, JSON):
9
+ // { "<principal>": ["<syncId1>", "<syncId2>"] }
10
+ //
11
+ // Principal keys:
12
+ // "alice@example.com" — matched against token email claim
13
+ // "some-sub-uuid" — matched against token sub claim
14
+ // "group:admin" — matched against token groups/roles array
15
+ //
16
+ // Value ["*"] grants access to all budgets (admin shorthand).
17
+ //
18
+ // When AUTH_BUDGET_ACL is unset, all authenticated users are allowed ("*").
19
+ //
20
+ // Usage in httpServer.ts:
21
+ // app.use(httpPath, budgetAclMiddleware); // after bearerAuth()
22
+ //
23
+ // Tools/adapters can read (req as any).allowedBudgets: string[] | undefined
24
+ // to filter results by permitted sync IDs (forward-looking multi-budget support).
25
+ import config from '../config.js';
26
+ import logger from '../logger.js';
27
+ // ---------------------------------------------------------------------------
28
+ // ACL map (parsed once from env, cached)
29
+ // ---------------------------------------------------------------------------
30
+ let _aclMap = null;
31
+ function getAclMap() {
32
+ if (_aclMap !== null)
33
+ return _aclMap;
34
+ if (!config.AUTH_BUDGET_ACL) {
35
+ _aclMap = {};
36
+ return _aclMap;
37
+ }
38
+ try {
39
+ const parsed = JSON.parse(config.AUTH_BUDGET_ACL);
40
+ if (typeof parsed !== 'object' || Array.isArray(parsed)) {
41
+ throw new TypeError('AUTH_BUDGET_ACL must be a JSON object');
42
+ }
43
+ _aclMap = parsed;
44
+ logger.info(`[ACL] Budget ACL loaded: ${Object.keys(_aclMap).length} principal(s)`);
45
+ }
46
+ catch (err) {
47
+ logger.error('[ACL] Invalid AUTH_BUDGET_ACL JSON — treating as empty (no budget restrictions):', err);
48
+ _aclMap = {};
49
+ }
50
+ return _aclMap;
51
+ }
52
+ // ---------------------------------------------------------------------------
53
+ // Public helpers
54
+ // ---------------------------------------------------------------------------
55
+ /**
56
+ * Returns the list of allowed budget sync-IDs for the currently authenticated
57
+ * request principal.
58
+ *
59
+ * - Returns `['*']` when ACL is empty / unset (no restrictions).
60
+ * - Returns `['*']` when any matched principal has wildcard access.
61
+ * - Returns `[]` when the user is authenticated but not in the ACL at all.
62
+ */
63
+ export function getAllowedBudgets(req) {
64
+ const acl = getAclMap();
65
+ // No ACL configured → allow everything
66
+ if (Object.keys(acl).length === 0)
67
+ return ['*'];
68
+ const auth = req.auth;
69
+ if (!auth)
70
+ return [];
71
+ const claims = (auth.claims ?? {});
72
+ const sub = auth.subject;
73
+ const email = claims['email'];
74
+ const rawGroups = claims['groups'] ?? claims['roles'] ?? [];
75
+ const groups = Array.isArray(rawGroups) ? rawGroups : [];
76
+ // Build the list of principal identities for this request
77
+ const principals = [];
78
+ if (sub)
79
+ principals.push(sub);
80
+ if (email)
81
+ principals.push(email);
82
+ for (const g of groups)
83
+ principals.push(`group:${g}`);
84
+ // Check for wildcard admin access first
85
+ for (const p of principals) {
86
+ if (acl[p]?.includes('*'))
87
+ return ['*'];
88
+ }
89
+ // Collect all permitted sync-IDs
90
+ const permitted = new Set();
91
+ for (const p of principals) {
92
+ for (const id of acl[p] ?? [])
93
+ permitted.add(id);
94
+ }
95
+ return [...permitted];
96
+ }
97
+ /**
98
+ * Returns true if the authenticated user can access the given budget sync-ID.
99
+ */
100
+ export function canAccessBudget(req, budgetSyncId) {
101
+ const allowed = getAllowedBudgets(req);
102
+ return allowed.includes('*') || allowed.includes(budgetSyncId);
103
+ }
104
+ // ---------------------------------------------------------------------------
105
+ // Express middleware
106
+ // ---------------------------------------------------------------------------
107
+ /**
108
+ * Express middleware that enforces the budget ACL when OIDC auth is active.
109
+ *
110
+ * - Falls through immediately when AUTH_PROVIDER !== 'oidc' or AUTH_BUDGET_ACL
111
+ * is unset (no-op — keeps backward compatibility).
112
+ * - Attaches `req.allowedBudgets` for downstream use (multi-budget tools).
113
+ * - Returns HTTP 403 if the authenticated user has no permitted budgets.
114
+ *
115
+ * Must be mounted AFTER mcp-auth's `bearerAuth()` middleware so `req.auth`
116
+ * is already populated.
117
+ */
118
+ export function budgetAclMiddleware(req, res, next) {
119
+ // No-op unless OIDC + ACL configured
120
+ if (config.AUTH_PROVIDER !== 'oidc') {
121
+ next();
122
+ return;
123
+ }
124
+ const allowed = getAllowedBudgets(req);
125
+ if (allowed.includes('*') || allowed.length > 0) {
126
+ // Expose allowed budgets for use in tools
127
+ req.allowedBudgets = allowed;
128
+ next();
129
+ return;
130
+ }
131
+ const auth = req.auth;
132
+ const identity = auth?.subject ?? 'unknown';
133
+ logger.warn(`[ACL] Access denied for '${identity}': no permitted budgets in ACL`);
134
+ res.status(403).json({ error: 'Forbidden: no budget access configured for this user' });
135
+ }
136
+ /** Reset cached state (test helper). */
137
+ export function _resetAclForTests() {
138
+ _aclMap = null;
139
+ }
140
+ /** Directly set the ACL map (test helper — bypasses config/env). */
141
+ export function _setAclForTests(aclMap) {
142
+ _aclMap = aclMap;
143
+ }
@@ -0,0 +1,58 @@
1
+ // src/auth/setup.ts
2
+ //
3
+ // Factory for the mcp-auth MCPAuth instance (CF-5: OIDC multi-user auth).
4
+ // Returns null when AUTH_PROVIDER !== 'oidc' — existing static Bearer token
5
+ // auth (MCP_SSE_AUTHORIZATION) is then used unchanged.
6
+ //
7
+ // Uses the mcp-auth "discovery config" approach: OIDC metadata is fetched
8
+ // on-demand on the first incoming request, so no top-level async needed here.
9
+ import { MCPAuth } from 'mcp-auth';
10
+ import config from '../config.js';
11
+ import logger from '../logger.js';
12
+ let _instance = null;
13
+ /**
14
+ * Returns an MCPAuth instance configured for this server's OIDC settings,
15
+ * or null if AUTH_PROVIDER is not 'oidc'.
16
+ *
17
+ * The instance is a singleton — safe to call multiple times.
18
+ *
19
+ * @throws If AUTH_PROVIDER=oidc but OIDC_ISSUER or OIDC_RESOURCE are unset.
20
+ */
21
+ export function createMcpAuth() {
22
+ if (config.AUTH_PROVIDER !== 'oidc') {
23
+ return null;
24
+ }
25
+ if (!config.OIDC_ISSUER) {
26
+ throw new Error('[OIDC] AUTH_PROVIDER=oidc requires OIDC_ISSUER to be set. ' +
27
+ 'Example: OIDC_ISSUER=https://auth.example.com/realms/myrealm');
28
+ }
29
+ if (!config.OIDC_RESOURCE) {
30
+ throw new Error('[OIDC] AUTH_PROVIDER=oidc requires OIDC_RESOURCE to be set. ' +
31
+ 'Example: OIDC_RESOURCE=https://actual-mcp.example.com');
32
+ }
33
+ if (_instance)
34
+ return _instance;
35
+ const scopesSupported = config.OIDC_SCOPES
36
+ ? config.OIDC_SCOPES.split(',').map((s) => s.trim()).filter(Boolean)
37
+ : [];
38
+ logger.info(`[OIDC] Configuring mcp-auth — issuer: ${config.OIDC_ISSUER}`);
39
+ logger.info(`[OIDC] Resource identifier: ${config.OIDC_RESOURCE}`);
40
+ logger.info(`[OIDC] Scopes required: ${scopesSupported.length ? scopesSupported.join(', ') : '(none)'}`);
41
+ _instance = new MCPAuth({
42
+ protectedResources: [
43
+ {
44
+ metadata: {
45
+ resource: config.OIDC_RESOURCE,
46
+ // Discovery config: mcp-auth fetches OIDC metadata lazily on first request.
47
+ authorizationServers: [{ issuer: config.OIDC_ISSUER, type: 'oidc' }],
48
+ scopesSupported,
49
+ },
50
+ },
51
+ ],
52
+ });
53
+ return _instance;
54
+ }
55
+ /** Reset the singleton (test helper). */
56
+ export function _resetMcpAuthForTests() {
57
+ _instance = null;
58
+ }