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,221 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SQL Query Validator
|
|
3
|
+
*
|
|
4
|
+
* Validates SQL queries against the Actual Budget schema before execution
|
|
5
|
+
* to prevent server crashes from invalid table/field references.
|
|
6
|
+
*/
|
|
7
|
+
import { getTableFields, getTableNames, isValidTable, isValidField, isValidJoinPath, } from './actual-schema.js';
|
|
8
|
+
/**
|
|
9
|
+
* Extract table name from SQL query
|
|
10
|
+
* Handles: FROM table, FROM table1, table2, JOIN table
|
|
11
|
+
*/
|
|
12
|
+
function extractTableNames(sql) {
|
|
13
|
+
const tables = new Set();
|
|
14
|
+
const normalized = sql.toUpperCase();
|
|
15
|
+
// Extract FROM clause tables - handle queries without WHERE/ORDER/LIMIT
|
|
16
|
+
const fromMatch = normalized.match(/FROM\s+(\w+)/i);
|
|
17
|
+
if (fromMatch) {
|
|
18
|
+
tables.add(fromMatch[1].toLowerCase());
|
|
19
|
+
}
|
|
20
|
+
// Extract JOIN clause tables
|
|
21
|
+
const joinMatches = sql.matchAll(/JOIN\s+(\w+)/gi);
|
|
22
|
+
for (const match of joinMatches) {
|
|
23
|
+
tables.add(match[1].toLowerCase());
|
|
24
|
+
}
|
|
25
|
+
return Array.from(tables);
|
|
26
|
+
}
|
|
27
|
+
/**
|
|
28
|
+
* Extract field references from SELECT clause
|
|
29
|
+
* Handles: *, field, table.field, field AS alias, payee.name
|
|
30
|
+
*/
|
|
31
|
+
function extractSelectFields(sql) {
|
|
32
|
+
const fields = [];
|
|
33
|
+
// Handle SELECT *
|
|
34
|
+
if (/SELECT\s+\*/i.test(sql)) {
|
|
35
|
+
return [{ field: '*' }];
|
|
36
|
+
}
|
|
37
|
+
// Extract SELECT clause
|
|
38
|
+
const selectMatch = sql.match(/SELECT\s+(.*?)\s+FROM/is);
|
|
39
|
+
if (!selectMatch)
|
|
40
|
+
return fields;
|
|
41
|
+
const selectClause = selectMatch[1];
|
|
42
|
+
// Split by commas (but not inside functions)
|
|
43
|
+
const fieldParts = selectClause.split(/,(?![^()]*\))/);
|
|
44
|
+
for (let part of fieldParts) {
|
|
45
|
+
part = part.trim();
|
|
46
|
+
// Remove AS alias
|
|
47
|
+
part = part.replace(/\s+AS\s+.+$/i, '');
|
|
48
|
+
// Check for table.field or field
|
|
49
|
+
const dotMatch = part.match(/^(\w+)\.(\w+)$/);
|
|
50
|
+
if (dotMatch) {
|
|
51
|
+
fields.push({ table: dotMatch[1], field: dotMatch[2] });
|
|
52
|
+
}
|
|
53
|
+
else if (/^\w+$/.test(part)) {
|
|
54
|
+
// Simple field name
|
|
55
|
+
fields.push({ field: part });
|
|
56
|
+
}
|
|
57
|
+
// Ignore functions like COUNT(*), SUM(amount), etc.
|
|
58
|
+
}
|
|
59
|
+
return fields;
|
|
60
|
+
}
|
|
61
|
+
/**
|
|
62
|
+
* Extract field references from WHERE clause
|
|
63
|
+
*/
|
|
64
|
+
function extractWhereFields(sql) {
|
|
65
|
+
const fields = [];
|
|
66
|
+
const whereMatch = sql.match(/WHERE\s+(.*?)(?:GROUP|ORDER|LIMIT|;|$)/is);
|
|
67
|
+
if (!whereMatch)
|
|
68
|
+
return fields;
|
|
69
|
+
const whereClause = whereMatch[1];
|
|
70
|
+
// Find field references (table.field or field)
|
|
71
|
+
const fieldMatches = whereClause.matchAll(/(\w+)\.(\w+)|(?:^|\s)(\w+)\s*[=<>!]/g);
|
|
72
|
+
for (const match of fieldMatches) {
|
|
73
|
+
if (match[1] && match[2]) {
|
|
74
|
+
// table.field
|
|
75
|
+
fields.push({ table: match[1], field: match[2] });
|
|
76
|
+
}
|
|
77
|
+
else if (match[3]) {
|
|
78
|
+
// simple field
|
|
79
|
+
fields.push({ field: match[3] });
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
return fields;
|
|
83
|
+
}
|
|
84
|
+
/**
|
|
85
|
+
* Validate a SQL query against the Actual Budget schema
|
|
86
|
+
*/
|
|
87
|
+
export function validateQuery(sql) {
|
|
88
|
+
const errors = [];
|
|
89
|
+
try {
|
|
90
|
+
// Normalize SQL
|
|
91
|
+
sql = sql.trim();
|
|
92
|
+
if (!sql) {
|
|
93
|
+
return { valid: false, errors: [{ type: 'invalid_field', message: 'Empty query' }] };
|
|
94
|
+
}
|
|
95
|
+
// Extract tables
|
|
96
|
+
const tables = extractTableNames(sql);
|
|
97
|
+
if (tables.length === 0) {
|
|
98
|
+
return { valid: false, errors: [{ type: 'invalid_table', message: 'No table found in query' }] };
|
|
99
|
+
}
|
|
100
|
+
// Validate tables exist
|
|
101
|
+
for (const table of tables) {
|
|
102
|
+
if (!isValidTable(table)) {
|
|
103
|
+
errors.push({
|
|
104
|
+
type: 'invalid_table',
|
|
105
|
+
message: `Table "${table}" does not exist`,
|
|
106
|
+
table,
|
|
107
|
+
suggestions: getTableNames(),
|
|
108
|
+
});
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
// If table is invalid, stop here
|
|
112
|
+
if (errors.length > 0) {
|
|
113
|
+
return { valid: false, errors };
|
|
114
|
+
}
|
|
115
|
+
const primaryTable = tables[0]; // First table is primary
|
|
116
|
+
// Extract and validate SELECT fields
|
|
117
|
+
const selectFields = extractSelectFields(sql);
|
|
118
|
+
for (const { field, table } of selectFields) {
|
|
119
|
+
if (field === '*')
|
|
120
|
+
continue; // SELECT * is always valid
|
|
121
|
+
// Check for join paths (e.g., payee.name)
|
|
122
|
+
const fullPath = table ? `${table}.${field}` : field;
|
|
123
|
+
if (table && field) {
|
|
124
|
+
// If it's a dot notation, check if it's a valid join path
|
|
125
|
+
if (isValidJoinPath(fullPath)) {
|
|
126
|
+
continue; // Valid join path
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
// Check if field exists in primary table or specified table
|
|
130
|
+
const targetTable = table || primaryTable;
|
|
131
|
+
if (!isValidField(targetTable, field)) {
|
|
132
|
+
const availableFields = getTableFields(targetTable);
|
|
133
|
+
// If table doesn't exist, suggest valid tables instead of empty field list
|
|
134
|
+
if (availableFields === null) {
|
|
135
|
+
errors.push({
|
|
136
|
+
type: 'invalid_table',
|
|
137
|
+
message: `Table "${targetTable}" does not exist (referenced in "${table ? table + '.' + field : field}")`,
|
|
138
|
+
table: targetTable,
|
|
139
|
+
field,
|
|
140
|
+
suggestions: getTableNames(),
|
|
141
|
+
});
|
|
142
|
+
}
|
|
143
|
+
else {
|
|
144
|
+
errors.push({
|
|
145
|
+
type: 'invalid_field',
|
|
146
|
+
message: `Field "${field}" does not exist in table "${targetTable}"`,
|
|
147
|
+
table: targetTable,
|
|
148
|
+
field,
|
|
149
|
+
suggestions: availableFields,
|
|
150
|
+
});
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
// Extract and validate WHERE fields
|
|
155
|
+
const whereFields = extractWhereFields(sql);
|
|
156
|
+
for (const { field, table } of whereFields) {
|
|
157
|
+
const fullPath = table ? `${table}.${field}` : field;
|
|
158
|
+
// Check for join paths
|
|
159
|
+
if (table && field && isValidJoinPath(fullPath)) {
|
|
160
|
+
continue;
|
|
161
|
+
}
|
|
162
|
+
// Check if field exists
|
|
163
|
+
const targetTable = table || primaryTable;
|
|
164
|
+
if (!isValidField(targetTable, field)) {
|
|
165
|
+
const availableFields = getTableFields(targetTable);
|
|
166
|
+
// If table doesn't exist, suggest valid tables instead of empty field list
|
|
167
|
+
if (availableFields === null) {
|
|
168
|
+
errors.push({
|
|
169
|
+
type: 'invalid_table',
|
|
170
|
+
message: `Table "${targetTable}" does not exist (referenced in "${table ? table + '.' + field : field}")`,
|
|
171
|
+
table: targetTable,
|
|
172
|
+
field,
|
|
173
|
+
suggestions: getTableNames(),
|
|
174
|
+
});
|
|
175
|
+
}
|
|
176
|
+
else {
|
|
177
|
+
errors.push({
|
|
178
|
+
type: 'invalid_field',
|
|
179
|
+
message: `Field "${field}" does not exist in table "${targetTable}"`,
|
|
180
|
+
table: targetTable,
|
|
181
|
+
field,
|
|
182
|
+
suggestions: availableFields,
|
|
183
|
+
});
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
return {
|
|
188
|
+
valid: errors.length === 0,
|
|
189
|
+
errors,
|
|
190
|
+
};
|
|
191
|
+
}
|
|
192
|
+
catch (error) {
|
|
193
|
+
return {
|
|
194
|
+
valid: false,
|
|
195
|
+
errors: [{
|
|
196
|
+
type: 'invalid_field',
|
|
197
|
+
message: `Query parsing error: ${error instanceof Error ? error.message : String(error)}`,
|
|
198
|
+
}],
|
|
199
|
+
};
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
/**
|
|
203
|
+
* Format validation errors into a user-friendly message
|
|
204
|
+
*/
|
|
205
|
+
export function formatValidationErrors(result) {
|
|
206
|
+
if (result.valid)
|
|
207
|
+
return '';
|
|
208
|
+
const messages = [];
|
|
209
|
+
for (const error of result.errors) {
|
|
210
|
+
messages.push(`❌ ${error.message}`);
|
|
211
|
+
if (error.suggestions && error.suggestions.length > 0) {
|
|
212
|
+
if (error.type === 'invalid_table') {
|
|
213
|
+
messages.push(` Available tables: ${error.suggestions.join(', ')}`);
|
|
214
|
+
}
|
|
215
|
+
else if (error.type === 'invalid_field') {
|
|
216
|
+
messages.push(` Available fields: ${error.suggestions.join(', ')}`);
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
return messages.join('\n');
|
|
221
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { DEFAULT_RETRY_ATTEMPTS, DEFAULT_RETRY_BACKOFF_MS, MAX_RETRY_DELAY_MS } from './constants.js';
|
|
2
|
+
import { ModuleLoggers } from './loggerFactory.js';
|
|
3
|
+
const log = ModuleLoggers.RETRY;
|
|
4
|
+
export async function retry(fn, opts) {
|
|
5
|
+
const retries = opts?.retries ?? DEFAULT_RETRY_ATTEMPTS;
|
|
6
|
+
const backoffMs = opts?.backoffMs ?? DEFAULT_RETRY_BACKOFF_MS;
|
|
7
|
+
let attempt = 0;
|
|
8
|
+
while (true) {
|
|
9
|
+
try {
|
|
10
|
+
// Ensure the promise from fn() is properly awaited and any rejection is caught
|
|
11
|
+
const result = await Promise.resolve().then(() => fn());
|
|
12
|
+
return result;
|
|
13
|
+
}
|
|
14
|
+
catch (err) {
|
|
15
|
+
attempt++;
|
|
16
|
+
if (attempt > retries) {
|
|
17
|
+
log.error(`All retry attempts exhausted after ${retries} tries`, err);
|
|
18
|
+
throw err;
|
|
19
|
+
}
|
|
20
|
+
const delay = Math.min(backoffMs * Math.pow(2, attempt - 1), MAX_RETRY_DELAY_MS);
|
|
21
|
+
log.debug(`Retry attempt ${attempt}/${retries} after ${delay}ms`, { error: err.message });
|
|
22
|
+
await new Promise(r => setTimeout(r, delay));
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
export default retry;
|
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared Zod Validation Schemas
|
|
3
|
+
*
|
|
4
|
+
* Common validation schemas used across MCP tools for Actual Budget.
|
|
5
|
+
* These schemas provide consistent validation, better error messages,
|
|
6
|
+
* and reduce duplication across the 43 tool definitions.
|
|
7
|
+
*/
|
|
8
|
+
import { z } from 'zod';
|
|
9
|
+
import { MAX_NAME_LENGTH, MAX_NOTES_LENGTH, DATE_PATTERN, MONTH_PATTERN, UUID_PATTERN } from '../constants.js';
|
|
10
|
+
// ============================================================================
|
|
11
|
+
// ID SCHEMAS
|
|
12
|
+
// ============================================================================
|
|
13
|
+
/**
|
|
14
|
+
* Account UUID validation
|
|
15
|
+
* Used for: account operations, transactions, transfers
|
|
16
|
+
*/
|
|
17
|
+
export const accountIdSchema = z
|
|
18
|
+
.string()
|
|
19
|
+
.regex(UUID_PATTERN, 'Invalid account ID format (expected UUID)')
|
|
20
|
+
.describe('Account UUID');
|
|
21
|
+
/**
|
|
22
|
+
* Transaction UUID validation
|
|
23
|
+
* Used for: transaction updates, deletions
|
|
24
|
+
*/
|
|
25
|
+
export const transactionIdSchema = z
|
|
26
|
+
.string()
|
|
27
|
+
.regex(UUID_PATTERN, 'Invalid transaction ID format (expected UUID)')
|
|
28
|
+
.describe('Transaction UUID');
|
|
29
|
+
/**
|
|
30
|
+
* Category UUID validation
|
|
31
|
+
* Used for: budget operations, transactions, category management
|
|
32
|
+
*/
|
|
33
|
+
export const categoryIdSchema = z
|
|
34
|
+
.string()
|
|
35
|
+
.regex(UUID_PATTERN, 'Invalid category ID format (expected UUID)')
|
|
36
|
+
.describe('Category UUID');
|
|
37
|
+
/**
|
|
38
|
+
* Category group UUID validation
|
|
39
|
+
* Used for: category group management
|
|
40
|
+
*/
|
|
41
|
+
export const categoryGroupIdSchema = z
|
|
42
|
+
.string()
|
|
43
|
+
.regex(UUID_PATTERN, 'Invalid category group ID format (expected UUID)')
|
|
44
|
+
.describe('Category group UUID');
|
|
45
|
+
/**
|
|
46
|
+
* Payee UUID validation
|
|
47
|
+
* Used for: transactions, payee management, rules
|
|
48
|
+
*/
|
|
49
|
+
export const payeeIdSchema = z
|
|
50
|
+
.string()
|
|
51
|
+
.regex(UUID_PATTERN, 'Invalid payee ID format (expected UUID)')
|
|
52
|
+
.describe('Payee UUID');
|
|
53
|
+
/**
|
|
54
|
+
* Rule UUID validation
|
|
55
|
+
* Used for: rule management operations
|
|
56
|
+
*/
|
|
57
|
+
export const ruleIdSchema = z
|
|
58
|
+
.string()
|
|
59
|
+
.regex(UUID_PATTERN, 'Invalid rule ID format (expected UUID)')
|
|
60
|
+
.describe('Rule UUID');
|
|
61
|
+
// ============================================================================
|
|
62
|
+
// DATE SCHEMAS
|
|
63
|
+
// ============================================================================
|
|
64
|
+
/**
|
|
65
|
+
* Date in YYYY-MM-DD format
|
|
66
|
+
* Used for: transactions, account balance queries, date range filters
|
|
67
|
+
* Example: "2025-11-24"
|
|
68
|
+
*/
|
|
69
|
+
export const dateSchema = z
|
|
70
|
+
.string()
|
|
71
|
+
.regex(DATE_PATTERN, 'Invalid date format (expected YYYY-MM-DD)')
|
|
72
|
+
.describe('Date in YYYY-MM-DD format');
|
|
73
|
+
/**
|
|
74
|
+
* Month in YYYY-MM format
|
|
75
|
+
* Used for: budget months, monthly reports
|
|
76
|
+
* Example: "2025-11"
|
|
77
|
+
*/
|
|
78
|
+
export const monthYearSchema = z
|
|
79
|
+
.string()
|
|
80
|
+
.regex(MONTH_PATTERN, 'Invalid month format (expected YYYY-MM)')
|
|
81
|
+
.describe('Month in YYYY-MM format');
|
|
82
|
+
// ============================================================================
|
|
83
|
+
// AMOUNT SCHEMAS
|
|
84
|
+
// ============================================================================
|
|
85
|
+
/**
|
|
86
|
+
* Amount in cents (integer)
|
|
87
|
+
* Negative for expenses, positive for income
|
|
88
|
+
* Example: -12.34 USD = -1234 cents
|
|
89
|
+
*/
|
|
90
|
+
export const amountCentsSchema = z
|
|
91
|
+
.number()
|
|
92
|
+
.int('Amount must be an integer (cents)')
|
|
93
|
+
.describe('Amount in cents (negative for expenses, positive for income)');
|
|
94
|
+
/**
|
|
95
|
+
* Optional amount in cents
|
|
96
|
+
* Used for: account balance initialization, optional transaction amounts
|
|
97
|
+
*/
|
|
98
|
+
export const optionalAmountCentsSchema = amountCentsSchema
|
|
99
|
+
.optional()
|
|
100
|
+
.describe('Optional amount in cents');
|
|
101
|
+
// ============================================================================
|
|
102
|
+
// TEXT FIELD SCHEMAS
|
|
103
|
+
// ============================================================================
|
|
104
|
+
/**
|
|
105
|
+
* Name field (1-255 characters)
|
|
106
|
+
* Used for: accounts, categories, payees, category groups
|
|
107
|
+
*/
|
|
108
|
+
export const nameSchema = z
|
|
109
|
+
.string()
|
|
110
|
+
.min(1, 'Name cannot be empty')
|
|
111
|
+
.max(MAX_NAME_LENGTH, `Name cannot exceed ${MAX_NAME_LENGTH} characters`)
|
|
112
|
+
.describe('Name (1-255 characters)');
|
|
113
|
+
/**
|
|
114
|
+
* Notes/description field (max 1000 characters)
|
|
115
|
+
* Used for: transactions, categories, accounts, rules
|
|
116
|
+
*/
|
|
117
|
+
export const notesSchema = z
|
|
118
|
+
.string()
|
|
119
|
+
.max(MAX_NOTES_LENGTH, `Notes cannot exceed ${MAX_NOTES_LENGTH} characters`)
|
|
120
|
+
.optional()
|
|
121
|
+
.describe('Optional notes (max 1000 characters)');
|
|
122
|
+
// ============================================================================
|
|
123
|
+
// STATUS FLAGS
|
|
124
|
+
// ============================================================================
|
|
125
|
+
/**
|
|
126
|
+
* Transaction cleared flag
|
|
127
|
+
* Indicates whether a transaction has cleared the bank
|
|
128
|
+
*/
|
|
129
|
+
export const clearedSchema = z
|
|
130
|
+
.boolean()
|
|
131
|
+
.optional()
|
|
132
|
+
.describe('Whether the transaction has cleared');
|
|
133
|
+
/**
|
|
134
|
+
* Transaction reconciled flag
|
|
135
|
+
* Indicates whether a transaction has been reconciled
|
|
136
|
+
*/
|
|
137
|
+
export const reconciledSchema = z
|
|
138
|
+
.boolean()
|
|
139
|
+
.optional()
|
|
140
|
+
.describe('Whether the transaction has been reconciled');
|
|
141
|
+
/**
|
|
142
|
+
* Account closed flag
|
|
143
|
+
* Indicates whether an account is closed
|
|
144
|
+
*/
|
|
145
|
+
export const closedSchema = z
|
|
146
|
+
.boolean()
|
|
147
|
+
.optional()
|
|
148
|
+
.describe('Whether the account is closed');
|
|
149
|
+
/**
|
|
150
|
+
* Account off-budget flag
|
|
151
|
+
* Indicates whether an account is excluded from budget calculations
|
|
152
|
+
*/
|
|
153
|
+
export const offBudgetSchema = z
|
|
154
|
+
.boolean()
|
|
155
|
+
.optional()
|
|
156
|
+
.describe('Whether the account is off-budget');
|
|
157
|
+
// ============================================================================
|
|
158
|
+
// COMPOSITE SCHEMAS
|
|
159
|
+
// ============================================================================
|
|
160
|
+
/**
|
|
161
|
+
* Common schemas object for easy import
|
|
162
|
+
* Usage: import { CommonSchemas } from '../lib/schemas/common.js';
|
|
163
|
+
*/
|
|
164
|
+
export const CommonSchemas = {
|
|
165
|
+
// IDs
|
|
166
|
+
accountId: accountIdSchema,
|
|
167
|
+
transactionId: transactionIdSchema,
|
|
168
|
+
categoryId: categoryIdSchema,
|
|
169
|
+
categoryGroupId: categoryGroupIdSchema,
|
|
170
|
+
payeeId: payeeIdSchema,
|
|
171
|
+
ruleId: ruleIdSchema,
|
|
172
|
+
// Dates
|
|
173
|
+
date: dateSchema,
|
|
174
|
+
monthYear: monthYearSchema,
|
|
175
|
+
// Amounts
|
|
176
|
+
amountCents: amountCentsSchema,
|
|
177
|
+
optionalAmountCents: optionalAmountCentsSchema,
|
|
178
|
+
// Text fields
|
|
179
|
+
name: nameSchema,
|
|
180
|
+
notes: notesSchema,
|
|
181
|
+
// Status flags
|
|
182
|
+
cleared: clearedSchema,
|
|
183
|
+
reconciled: reconciledSchema,
|
|
184
|
+
closed: closedSchema,
|
|
185
|
+
offBudget: offBudgetSchema,
|
|
186
|
+
};
|
|
187
|
+
// ============================================================================
|
|
188
|
+
// EXAMPLES
|
|
189
|
+
// ============================================================================
|
|
190
|
+
/**
|
|
191
|
+
* Example usage in tool definitions:
|
|
192
|
+
*
|
|
193
|
+
* ```typescript
|
|
194
|
+
* import { z } from 'zod';
|
|
195
|
+
* import { CommonSchemas } from '../lib/schemas/common.js';
|
|
196
|
+
*
|
|
197
|
+
* const InputSchema = z.object({
|
|
198
|
+
* accountId: CommonSchemas.accountId,
|
|
199
|
+
* name: CommonSchemas.name,
|
|
200
|
+
* balance: CommonSchemas.optionalAmountCents,
|
|
201
|
+
* });
|
|
202
|
+
* ```
|
|
203
|
+
*/
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tool Factory
|
|
3
|
+
*
|
|
4
|
+
* Factory function for creating MCP tool definitions with consistent structure,
|
|
5
|
+
* error handling, logging, and observability. This eliminates code duplication
|
|
6
|
+
* across the 43 tool files and ensures consistent behavior.
|
|
7
|
+
*
|
|
8
|
+
* Benefits:
|
|
9
|
+
* - Reduces boilerplate from ~25 LOC to ~10 LOC per tool
|
|
10
|
+
* - Automatic error handling and logging
|
|
11
|
+
* - Consistent observability integration
|
|
12
|
+
* - Type-safe tool configuration
|
|
13
|
+
* - Easier to test and maintain
|
|
14
|
+
*/
|
|
15
|
+
import { createModuleLogger } from './loggerFactory.js';
|
|
16
|
+
import observability from '../observability.js';
|
|
17
|
+
const log = createModuleLogger('TOOLS');
|
|
18
|
+
/**
|
|
19
|
+
* Create a tool definition with automatic error handling and logging
|
|
20
|
+
*
|
|
21
|
+
* @param config - Tool configuration
|
|
22
|
+
* @returns MCP tool definition ready for registration
|
|
23
|
+
*
|
|
24
|
+
* @example
|
|
25
|
+
* ```typescript
|
|
26
|
+
* import { z } from 'zod';
|
|
27
|
+
* import { createTool } from '../lib/toolFactory.js';
|
|
28
|
+
* import { CommonSchemas } from '../lib/schemas/common.js';
|
|
29
|
+
* import adapter from '../lib/actual-adapter.js';
|
|
30
|
+
*
|
|
31
|
+
* export default createTool({
|
|
32
|
+
* name: 'actual_accounts_create',
|
|
33
|
+
* description: 'Create a new account in Actual Budget',
|
|
34
|
+
* schema: z.object({
|
|
35
|
+
* id: z.string().optional(),
|
|
36
|
+
* name: CommonSchemas.name,
|
|
37
|
+
* balance: CommonSchemas.optionalAmountCents,
|
|
38
|
+
* }),
|
|
39
|
+
* handler: async (input) => {
|
|
40
|
+
* const result = await adapter.createAccount(input, input.balance);
|
|
41
|
+
* return { id: result };
|
|
42
|
+
* },
|
|
43
|
+
* });
|
|
44
|
+
* ```
|
|
45
|
+
*/
|
|
46
|
+
export function createTool(config) {
|
|
47
|
+
const { name, description, schema, handler, examples } = config;
|
|
48
|
+
return {
|
|
49
|
+
name,
|
|
50
|
+
description: examples
|
|
51
|
+
? `${description}\n\nExamples:\n${examples.map((ex, i) => `${i + 1}. ${ex.description}\n Input: ${JSON.stringify(ex.input, null, 2)}`).join('\n')}`
|
|
52
|
+
: description,
|
|
53
|
+
inputSchema: schema,
|
|
54
|
+
call: async (args, meta) => {
|
|
55
|
+
const startTime = Date.now();
|
|
56
|
+
try {
|
|
57
|
+
// Validate input against schema
|
|
58
|
+
log.debug(`Validating input for ${name}`, { args });
|
|
59
|
+
const input = schema.parse(args || {});
|
|
60
|
+
// Execute handler
|
|
61
|
+
log.debug(`Executing ${name}`, { input });
|
|
62
|
+
const result = await handler(input, meta);
|
|
63
|
+
// Log success
|
|
64
|
+
const duration = Date.now() - startTime;
|
|
65
|
+
log.debug(`${name} completed in ${duration}ms`, { result });
|
|
66
|
+
// Track observability
|
|
67
|
+
await observability.incrementToolCall(name);
|
|
68
|
+
return { result };
|
|
69
|
+
}
|
|
70
|
+
catch (error) {
|
|
71
|
+
// Log error with details
|
|
72
|
+
const duration = Date.now() - startTime;
|
|
73
|
+
log.error(`${name} failed after ${duration}ms`, error, { args });
|
|
74
|
+
// Track observability (still increment even on failure)
|
|
75
|
+
await observability.incrementToolCall(name);
|
|
76
|
+
// Re-throw for MCP error handling
|
|
77
|
+
throw error;
|
|
78
|
+
}
|
|
79
|
+
},
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
/**
|
|
83
|
+
* Batch create multiple tools with shared configuration
|
|
84
|
+
* Useful for creating related tools (CRUD operations) with common patterns
|
|
85
|
+
*
|
|
86
|
+
* @param baseConfig - Shared configuration for all tools
|
|
87
|
+
* @param tools - Array of tool-specific overrides
|
|
88
|
+
* @returns Array of tool definitions
|
|
89
|
+
*
|
|
90
|
+
* @example
|
|
91
|
+
* ```typescript
|
|
92
|
+
* export const accountTools = createTools(
|
|
93
|
+
* { namePrefix: 'actual_accounts_' },
|
|
94
|
+
* [
|
|
95
|
+
* { name: 'create', schema: createSchema, handler: createHandler },
|
|
96
|
+
* { name: 'update', schema: updateSchema, handler: updateHandler },
|
|
97
|
+
* { name: 'delete', schema: deleteSchema, handler: deleteHandler },
|
|
98
|
+
* ]
|
|
99
|
+
* );
|
|
100
|
+
* ```
|
|
101
|
+
*/
|
|
102
|
+
export function createTools(baseConfig, tools) {
|
|
103
|
+
const { namePrefix = '', ...sharedConfig } = baseConfig;
|
|
104
|
+
return tools.map((toolConfig) => createTool({
|
|
105
|
+
...sharedConfig,
|
|
106
|
+
...toolConfig,
|
|
107
|
+
name: namePrefix + toolConfig.name,
|
|
108
|
+
}));
|
|
109
|
+
}
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
import winston from 'winston';
|
|
2
|
+
import DailyRotateFile from 'winston-daily-rotate-file';
|
|
3
|
+
import path from 'path';
|
|
4
|
+
import { fileURLToPath } from 'url';
|
|
5
|
+
const __filename = fileURLToPath(import.meta.url);
|
|
6
|
+
const __dirname = path.dirname(__filename);
|
|
7
|
+
// env-configurable
|
|
8
|
+
const STORE_LOGS = process.env.MCP_BRIDGE_STORE_LOGS === 'true';
|
|
9
|
+
const LOG_DIR = process.env.MCP_BRIDGE_LOG_DIR
|
|
10
|
+
? path.isAbsolute(process.env.MCP_BRIDGE_LOG_DIR)
|
|
11
|
+
? process.env.MCP_BRIDGE_LOG_DIR
|
|
12
|
+
: path.join(process.cwd(), process.env.MCP_BRIDGE_LOG_DIR)
|
|
13
|
+
: path.join(__dirname, '..', 'app', 'logs');
|
|
14
|
+
const DATE_PATTERN = process.env.MCP_BRIDGE_ROTATE_DATEPATTERN || 'YYYY-MM-DD';
|
|
15
|
+
const MAX_SIZE = process.env.MCP_BRIDGE_MAX_LOG_SIZE || '20m';
|
|
16
|
+
const MAX_FILES = process.env.MCP_BRIDGE_MAX_FILES || '14d';
|
|
17
|
+
const DEFAULT_LEVEL = process.env.MCP_BRIDGE_LOG_LEVEL || 'debug';
|
|
18
|
+
function safeStringify(obj, maxLen = 2000) {
|
|
19
|
+
try {
|
|
20
|
+
const seen = new WeakSet();
|
|
21
|
+
const s = JSON.stringify(obj, (_key, value) => {
|
|
22
|
+
if (typeof value === 'object' && value !== null) {
|
|
23
|
+
if (seen.has(value))
|
|
24
|
+
return '[Circular]';
|
|
25
|
+
seen.add(value);
|
|
26
|
+
}
|
|
27
|
+
return value;
|
|
28
|
+
}, 2);
|
|
29
|
+
if (s.length > maxLen)
|
|
30
|
+
return s.slice(0, maxLen) + '...';
|
|
31
|
+
return s;
|
|
32
|
+
}
|
|
33
|
+
catch {
|
|
34
|
+
try {
|
|
35
|
+
return String(obj).slice(0, maxLen) + '...';
|
|
36
|
+
}
|
|
37
|
+
catch {
|
|
38
|
+
return '[Unstringifiable]';
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
const transports = [];
|
|
43
|
+
// file transports when enabled (capture debug+)
|
|
44
|
+
if (STORE_LOGS) {
|
|
45
|
+
const createDailyRotateTransport = (level) => new DailyRotateFile({
|
|
46
|
+
level,
|
|
47
|
+
dirname: LOG_DIR,
|
|
48
|
+
filename: `${level}-%DATE%.log`,
|
|
49
|
+
datePattern: DATE_PATTERN,
|
|
50
|
+
zippedArchive: true,
|
|
51
|
+
maxSize: MAX_SIZE,
|
|
52
|
+
maxFiles: MAX_FILES,
|
|
53
|
+
format: winston.format.combine(winston.format.timestamp(), winston.format.printf(({ timestamp, level, message }) => `${timestamp} ${level}: ${message}`)),
|
|
54
|
+
});
|
|
55
|
+
// debug transport collects debug+ messages into debug-%DATE%.log
|
|
56
|
+
transports.push(createDailyRotateTransport('debug'));
|
|
57
|
+
// errors into error-%DATE%.log
|
|
58
|
+
transports.push(createDailyRotateTransport('error'));
|
|
59
|
+
}
|
|
60
|
+
// single console transport for terminal output (use same level)
|
|
61
|
+
// In stdio mode all output must go to stderr — writing to stdout corrupts JSON-RPC framing.
|
|
62
|
+
// MCP_STDIO_MODE is set by src/index.ts before this module is first imported.
|
|
63
|
+
transports.push(new winston.transports.Console({
|
|
64
|
+
level: DEFAULT_LEVEL,
|
|
65
|
+
...(process.env.MCP_STDIO_MODE === 'true'
|
|
66
|
+
? { stderrLevels: ['error', 'warn', 'info', 'verbose', 'debug', 'silly', 'http'] }
|
|
67
|
+
: {}),
|
|
68
|
+
format: winston.format.combine(winston.format.timestamp({ format: 'YYYY-MM-DD HH:mm:ss.SSS' }), winston.format.colorize(), winston.format.printf(({ timestamp, level, message }) => `${timestamp} ${level}: ${message}`)),
|
|
69
|
+
}));
|
|
70
|
+
const logger = winston.createLogger({
|
|
71
|
+
level: DEFAULT_LEVEL,
|
|
72
|
+
transports,
|
|
73
|
+
exitOnError: false,
|
|
74
|
+
});
|
|
75
|
+
export function logTransportWithDirection(direction, clientIp, req, data) {
|
|
76
|
+
const meta = {
|
|
77
|
+
direction,
|
|
78
|
+
clientIp,
|
|
79
|
+
method: req.method,
|
|
80
|
+
url: req.originalUrl,
|
|
81
|
+
headers: req.headers,
|
|
82
|
+
payload: safeStringify(data, 2000),
|
|
83
|
+
};
|
|
84
|
+
logger.debug(safeStringify(meta, 4000));
|
|
85
|
+
}
|
|
86
|
+
// --- Wire debug module into winston and route console.* to winston only ---
|
|
87
|
+
// NOTE: avoid writing to both original console and winston to prevent duplicates.
|
|
88
|
+
(function wireConsoleAndDebug() {
|
|
89
|
+
const writeToWinston = (level, args) => {
|
|
90
|
+
try {
|
|
91
|
+
const text = args.map((a) => (typeof a === 'string' ? a : safeStringify(a, 2000))).join(' ');
|
|
92
|
+
// call winston at the requested level
|
|
93
|
+
// @ts-ignore dynamic level
|
|
94
|
+
logger[level](text);
|
|
95
|
+
}
|
|
96
|
+
catch {
|
|
97
|
+
// ignore
|
|
98
|
+
}
|
|
99
|
+
};
|
|
100
|
+
// Replace global console methods to feed winston (do NOT call original console.*)
|
|
101
|
+
console.log = (...args) => writeToWinston('info', args);
|
|
102
|
+
console.info = (...args) => writeToWinston('info', args);
|
|
103
|
+
console.warn = (...args) => writeToWinston('warn', args);
|
|
104
|
+
console.error = (...args) => writeToWinston('error', args);
|
|
105
|
+
console.debug = (...args) => writeToWinston('debug', args);
|
|
106
|
+
// Patch debug module (express, undici, debug(...) callers) to forward to winston.debug
|
|
107
|
+
try {
|
|
108
|
+
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
|
109
|
+
const debugModule = require('debug');
|
|
110
|
+
if (debugModule) {
|
|
111
|
+
// replace debug.log so debug(...) prints go into winston
|
|
112
|
+
debugModule.log = (...args) => {
|
|
113
|
+
try {
|
|
114
|
+
const s = args.map((a) => (typeof a === 'string' ? a : safeStringify(a, 1000))).join(' ');
|
|
115
|
+
logger.debug(s);
|
|
116
|
+
}
|
|
117
|
+
catch {
|
|
118
|
+
// ignore
|
|
119
|
+
}
|
|
120
|
+
};
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
catch {
|
|
124
|
+
// debug module not available or failed to patch — ignore
|
|
125
|
+
}
|
|
126
|
+
})();
|
|
127
|
+
export default logger;
|