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,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;