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.
- package/LICENSE +21 -0
- package/README.md +663 -0
- package/bin/actual-mcp-server.js +3 -0
- package/dist/generated/actual-client/types.js +5 -0
- package/dist/package.json +88 -0
- package/dist/src/actualConnection.js +157 -0
- package/dist/src/actualToolsManager.js +211 -0
- package/dist/src/auth/budget-acl.js +143 -0
- package/dist/src/auth/setup.js +58 -0
- package/dist/src/config.js +41 -0
- package/dist/src/index.js +313 -0
- package/dist/src/lib/ActualConnectionPool.js +343 -0
- package/dist/src/lib/ActualMCPConnection.js +125 -0
- package/dist/src/lib/actual-adapter.js +1228 -0
- package/dist/src/lib/actual-schema.js +222 -0
- package/dist/src/lib/budget-registry.js +64 -0
- package/dist/src/lib/constants.js +121 -0
- package/dist/src/lib/errors.js +19 -0
- package/dist/src/lib/loggerFactory.js +72 -0
- package/dist/src/lib/node-polyfills.js +20 -0
- package/dist/src/lib/query-validator.js +221 -0
- package/dist/src/lib/retry.js +26 -0
- package/dist/src/lib/schemas/common.js +203 -0
- package/dist/src/lib/toolFactory.js +109 -0
- package/dist/src/logger.js +127 -0
- package/dist/src/observability.js +58 -0
- package/dist/src/prompts/showLargeTransactions.js +6 -0
- package/dist/src/resources/accountsSummary.js +13 -0
- package/dist/src/server/httpServer.js +540 -0
- package/dist/src/server/httpServer_testing.js +401 -0
- package/dist/src/server/stdioServer.js +52 -0
- package/dist/src/server/streamable-http.js +148 -0
- package/dist/src/tests/actualToolsTests.js +70 -0
- package/dist/src/tests/observability.smoke.test.js +18 -0
- package/dist/src/tests/testMcpClient.js +170 -0
- package/dist/src/tests_adapter_runner.js +86 -0
- package/dist/src/tools/accounts_close.js +16 -0
- package/dist/src/tools/accounts_create.js +27 -0
- package/dist/src/tools/accounts_delete.js +16 -0
- package/dist/src/tools/accounts_get_balance.js +40 -0
- package/dist/src/tools/accounts_list.js +16 -0
- package/dist/src/tools/accounts_reopen.js +16 -0
- package/dist/src/tools/accounts_update.js +52 -0
- package/dist/src/tools/bank_sync.js +22 -0
- package/dist/src/tools/budget_updates_batch.js +77 -0
- package/dist/src/tools/budgets_getMonth.js +14 -0
- package/dist/src/tools/budgets_getMonths.js +14 -0
- package/dist/src/tools/budgets_get_all.js +13 -0
- package/dist/src/tools/budgets_holdForNextMonth.js +19 -0
- package/dist/src/tools/budgets_list_available.js +20 -0
- package/dist/src/tools/budgets_resetHold.js +16 -0
- package/dist/src/tools/budgets_setAmount.js +26 -0
- package/dist/src/tools/budgets_setCarryover.js +18 -0
- package/dist/src/tools/budgets_switch.js +27 -0
- package/dist/src/tools/budgets_transfer.js +64 -0
- package/dist/src/tools/categories_create.js +65 -0
- package/dist/src/tools/categories_delete.js +16 -0
- package/dist/src/tools/categories_get.js +14 -0
- package/dist/src/tools/categories_update.js +22 -0
- package/dist/src/tools/category_groups_create.js +18 -0
- package/dist/src/tools/category_groups_delete.js +26 -0
- package/dist/src/tools/category_groups_get.js +13 -0
- package/dist/src/tools/category_groups_update.js +21 -0
- package/dist/src/tools/get_id_by_name.js +36 -0
- package/dist/src/tools/index.js +63 -0
- package/dist/src/tools/payee_rules_get.js +27 -0
- package/dist/src/tools/payees_create.js +25 -0
- package/dist/src/tools/payees_delete.js +16 -0
- package/dist/src/tools/payees_get.js +14 -0
- package/dist/src/tools/payees_merge.js +17 -0
- package/dist/src/tools/payees_update.js +59 -0
- package/dist/src/tools/query_run.js +78 -0
- package/dist/src/tools/rules_create.js +129 -0
- package/dist/src/tools/rules_create_or_update.js +191 -0
- package/dist/src/tools/rules_delete.js +26 -0
- package/dist/src/tools/rules_get.js +13 -0
- package/dist/src/tools/rules_update.js +120 -0
- package/dist/src/tools/schedules_create.js +54 -0
- package/dist/src/tools/schedules_delete.js +41 -0
- package/dist/src/tools/schedules_get.js +13 -0
- package/dist/src/tools/schedules_update.js +40 -0
- package/dist/src/tools/server_get_version.js +22 -0
- package/dist/src/tools/server_info.js +86 -0
- package/dist/src/tools/session_close.js +100 -0
- package/dist/src/tools/session_list.js +24 -0
- package/dist/src/tools/transactions_create.js +50 -0
- package/dist/src/tools/transactions_delete.js +20 -0
- package/dist/src/tools/transactions_filter.js +73 -0
- package/dist/src/tools/transactions_get.js +23 -0
- package/dist/src/tools/transactions_import.js +21 -0
- package/dist/src/tools/transactions_search_by_amount.js +126 -0
- package/dist/src/tools/transactions_search_by_category.js +137 -0
- package/dist/src/tools/transactions_search_by_month.js +142 -0
- package/dist/src/tools/transactions_search_by_payee.js +142 -0
- package/dist/src/tools/transactions_summary_by_category.js +80 -0
- package/dist/src/tools/transactions_summary_by_payee.js +72 -0
- package/dist/src/tools/transactions_uncategorized.js +66 -0
- package/dist/src/tools/transactions_update.js +34 -0
- package/dist/src/tools/transactions_update_batch.js +60 -0
- package/dist/src/utils.js +63 -0
- 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
|
+
}
|