actual-mcp-server 0.6.44 → 0.6.48
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/README.md +1 -1
- package/dist/package.json +2 -2
- package/dist/src/actualToolsManager.js +4 -7
- package/dist/src/lib/loggerFactory.js +9 -4
- package/dist/src/lib/schemas/common.js +1 -1
- package/dist/src/lib/zod-error-format.js +150 -0
- package/dist/src/logger.js +237 -9
- package/dist/src/server/httpServer.js +5 -3
- package/dist/src/tools/accounts_update.js +11 -18
- package/dist/src/tools/categories_create.js +2 -23
- package/dist/src/tools/payees_update.js +11 -18
- package/dist/src/tools/rules_create.js +52 -60
- package/dist/src/tools/rules_create_or_update.js +68 -76
- package/package.json +2 -2
package/README.md
CHANGED
package/dist/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "actual-mcp-server",
|
|
3
3
|
"displayName": "Actual MCP Server",
|
|
4
|
-
"version": "0.6.
|
|
4
|
+
"version": "0.6.48",
|
|
5
5
|
"engines": {
|
|
6
6
|
"node": ">=20.0.0",
|
|
7
7
|
"npm": ">=10.0.0"
|
|
@@ -30,7 +30,7 @@
|
|
|
30
30
|
"verify-tools": "npm run build && node scripts/verify-tools.js",
|
|
31
31
|
"check:coverage": "node scripts/list-actual-api-methods.mjs",
|
|
32
32
|
"direct-sync": "node scripts/direct-sync/bank-sync-direct.mjs",
|
|
33
|
-
"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/config_https_validation.test.js && node tests/unit/config_insecure_upstream.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/budgets_transfer.test.js && node tests/unit/transactions_uncategorized.test.js && node tests/unit/httpServer_session_init.test.js && node tests/unit/httpServer_session_not_found.test.js && node tests/unit/manual_mcp_client_retry.test.js && node tests/unit/manual_mcp_client_session.test.js && node tests/unit/manual_mcp_client_circuit.test.js && node tests/unit/manual_runner_killswitch.test.js && node tests/unit/adapter_auth_rate_limit.test.js && node tests/unit/adapter_session_reuse.test.js && node tests/unit/retry_classifier.test.js && node tests/unit/adapter_module_surface.test.js && node tests/unit/adapter_nonidempotent_no_retry.test.js && node tests/unit/pool_liveness.test.js && node tests/unit/pool_shutdown_all.test.js && node tests/unit/query_where_operators.test.js && node tests/unit/query_run_validation.test.js && node tests/unit/adapter_with_write_session.test.js && node tests/unit/category_groups_delete.test.js && node tests/unit/rules_delete.test.js && node tests/unit/schedules_delete.test.js && node tests/unit/payees_delete.test.js && node tests/unit/rules_create_or_update.test.js && node tests/unit/unhandled-rejection.test.js && node tests/unit/rejection-allowlist-purity.test.js && node tests/unit/httpServer_bearer_auth.test.js && node tests/unit/httpServer_oidc_audience.test.js && node tests/unit/httpServer_oidc_auth_verification.test.js && node tests/unit/httpServer_body_limit.test.js && node tests/unit/adapter_write_pool_cooperation.test.js && node tests/unit/budget_acl_enforcement.test.js && node tests/unit/budget_preference_store.test.js && node tests/unit/budget_preference_restore.test.js && node tests/unit/tags_list.test.js && node tests/unit/tags_create.test.js && node tests/unit/tags_update.test.js && node tests/unit/tags_delete.test.js && node tests/unit/notes_get.test.js && node tests/unit/notes_update.test.js && node tests/unit/payees_common_list.test.js && node tests/unit/transactions_update_guard.test.js && node tests/unit/entities_search.test.js && node tests/unit/tool_count_sync.test.js && node tests/unit/check_coverage.test.js",
|
|
33
|
+
"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/config_https_validation.test.js && node tests/unit/config_insecure_upstream.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/budgets_transfer.test.js && node tests/unit/transactions_uncategorized.test.js && node tests/unit/httpServer_session_init.test.js && node tests/unit/httpServer_session_not_found.test.js && node tests/unit/manual_mcp_client_retry.test.js && node tests/unit/manual_mcp_client_session.test.js && node tests/unit/manual_mcp_client_circuit.test.js && node tests/unit/manual_runner_killswitch.test.js && node tests/unit/adapter_auth_rate_limit.test.js && node tests/unit/adapter_session_reuse.test.js && node tests/unit/retry_classifier.test.js && node tests/unit/adapter_module_surface.test.js && node tests/unit/adapter_nonidempotent_no_retry.test.js && node tests/unit/pool_liveness.test.js && node tests/unit/pool_shutdown_all.test.js && node tests/unit/query_where_operators.test.js && node tests/unit/query_run_validation.test.js && node tests/unit/adapter_with_write_session.test.js && node tests/unit/category_groups_delete.test.js && node tests/unit/rules_delete.test.js && node tests/unit/schedules_delete.test.js && node tests/unit/payees_delete.test.js && node tests/unit/rules_create_or_update.test.js && node tests/unit/unhandled-rejection.test.js && node tests/unit/rejection-allowlist-purity.test.js && node tests/unit/httpServer_bearer_auth.test.js && node tests/unit/httpServer_oidc_audience.test.js && node tests/unit/httpServer_oidc_auth_verification.test.js && node tests/unit/httpServer_body_limit.test.js && node tests/unit/adapter_write_pool_cooperation.test.js && node tests/unit/budget_acl_enforcement.test.js && node tests/unit/budget_preference_store.test.js && node tests/unit/budget_preference_restore.test.js && node tests/unit/tags_list.test.js && node tests/unit/tags_create.test.js && node tests/unit/tags_update.test.js && node tests/unit/tags_delete.test.js && node tests/unit/notes_get.test.js && node tests/unit/notes_update.test.js && node tests/unit/payees_common_list.test.js && node tests/unit/transactions_update_guard.test.js && node tests/unit/entities_search.test.js && node tests/unit/zod_error_format.test.js && node tests/unit/tool_count_sync.test.js && node tests/unit/stdio_transport.test.js && node tests/unit/logger_structured.test.js && node tests/unit/logger_redaction.test.js && node tests/unit/logger_correlation.test.js && node tests/unit/check_coverage.test.js",
|
|
34
34
|
"test:adapter": "npm run build && node dist/src/tests_adapter_runner.js",
|
|
35
35
|
"test:e2e": "npx playwright test",
|
|
36
36
|
"test:e2e:docker": "./tests/e2e/run-docker-e2e.sh",
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import logger from './logger.js';
|
|
2
2
|
import { z } from 'zod';
|
|
3
|
+
import { formatZodError } from './lib/zod-error-format.js';
|
|
3
4
|
// ✅ List of tools already implemented in this class.
|
|
4
5
|
// Adding the tool name here is considered fully implemented.
|
|
5
6
|
const IMPLEMENTED_TOOLS = [
|
|
@@ -169,14 +170,10 @@ class ActualToolsManager {
|
|
|
169
170
|
return safe;
|
|
170
171
|
}
|
|
171
172
|
catch (err) {
|
|
172
|
-
// Format Zod validation errors
|
|
173
|
+
// Format Zod validation errors into one actionable, consistent message (#206).
|
|
174
|
+
// Duck-type on `.issues` so any structurally-compatible ZodError is handled.
|
|
173
175
|
if (err && typeof err === 'object' && 'issues' in err) {
|
|
174
|
-
const
|
|
175
|
-
const issues = zodError.issues.map(issue => {
|
|
176
|
-
const path = issue.path.join('.');
|
|
177
|
-
return `${path}: ${issue.message}`;
|
|
178
|
-
}).join(', ');
|
|
179
|
-
const formattedMsg = `Validation error: ${issues}`;
|
|
176
|
+
const formattedMsg = formatZodError(err, tool.inputSchema);
|
|
180
177
|
logger.error(`[TOOL ERROR] ${name}: ${formattedMsg}`);
|
|
181
178
|
throw new Error(formattedMsg);
|
|
182
179
|
}
|
|
@@ -26,15 +26,19 @@ import logger from '../logger.js';
|
|
|
26
26
|
*/
|
|
27
27
|
export function createModuleLogger(moduleName) {
|
|
28
28
|
const prefix = `[${moduleName}]`;
|
|
29
|
+
// `module` is attached as a structured field (promoted to a top-level field by the
|
|
30
|
+
// logger format, #219) while the `[MODULE]` prefix is kept in the message for the
|
|
31
|
+
// pretty/dev output. `module` is spread LAST so it stays authoritative and a caller's
|
|
32
|
+
// metadata cannot accidentally overwrite it.
|
|
29
33
|
return {
|
|
30
34
|
info: (message, meta) => {
|
|
31
|
-
logger.info(`${prefix} ${message}`, meta);
|
|
35
|
+
logger.info(`${prefix} ${message}`, { ...meta, module: moduleName });
|
|
32
36
|
},
|
|
33
37
|
debug: (message, meta) => {
|
|
34
|
-
logger.debug(`${prefix} ${message}`, meta);
|
|
38
|
+
logger.debug(`${prefix} ${message}`, { ...meta, module: moduleName });
|
|
35
39
|
},
|
|
36
40
|
warn: (message, meta) => {
|
|
37
|
-
logger.warn(`${prefix} ${message}`, meta);
|
|
41
|
+
logger.warn(`${prefix} ${message}`, { ...meta, module: moduleName });
|
|
38
42
|
},
|
|
39
43
|
error: (message, error, meta) => {
|
|
40
44
|
if (error) {
|
|
@@ -42,10 +46,11 @@ export function createModuleLogger(moduleName) {
|
|
|
42
46
|
error: error.message,
|
|
43
47
|
stack: error.stack,
|
|
44
48
|
...meta,
|
|
49
|
+
module: moduleName,
|
|
45
50
|
});
|
|
46
51
|
}
|
|
47
52
|
else {
|
|
48
|
-
logger.error(`${prefix} ${message}`, meta);
|
|
53
|
+
logger.error(`${prefix} ${message}`, { ...meta, module: moduleName });
|
|
49
54
|
}
|
|
50
55
|
},
|
|
51
56
|
};
|
|
@@ -41,7 +41,7 @@ export const categoryIdSchema = z
|
|
|
41
41
|
export const categoryGroupIdSchema = z
|
|
42
42
|
.string()
|
|
43
43
|
.regex(UUID_PATTERN, 'Invalid category group ID format (expected UUID)')
|
|
44
|
-
.describe('Category group UUID');
|
|
44
|
+
.describe('Category group UUID; list with actual_category_groups_get');
|
|
45
45
|
/**
|
|
46
46
|
* Payee UUID validation
|
|
47
47
|
* Used for: transactions, payee management, rules
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
const MAX_KEY_LEN = 64;
|
|
2
|
+
/** Strip control characters and cap length so a hostile key cannot flood or inject. */
|
|
3
|
+
function sanitizeKey(key) {
|
|
4
|
+
let k = typeof key === 'string' ? key : String(key);
|
|
5
|
+
// eslint-disable-next-line no-control-regex
|
|
6
|
+
k = k.replace(/[\u0000-\u001f\u007f]/g, '');
|
|
7
|
+
if (k.length > MAX_KEY_LEN)
|
|
8
|
+
k = `${k.slice(0, MAX_KEY_LEN)}...`;
|
|
9
|
+
return k;
|
|
10
|
+
}
|
|
11
|
+
// ---- leaf-schema `.describe()` hint resolution (best-effort, never throws) ----
|
|
12
|
+
function unwrap(schema) {
|
|
13
|
+
// Strip only true single-child wrappers (optional / nullable / default / readonly /
|
|
14
|
+
// catch / brand), which in Zod v4 all expose their child at `_def.innerType`. We do
|
|
15
|
+
// NOT use `.unwrap()`: ZodArray also has `.unwrap()` (returning its element), which
|
|
16
|
+
// would descend past the array before the caller resolves the element by path index.
|
|
17
|
+
let s = schema;
|
|
18
|
+
let guard = 0;
|
|
19
|
+
while (s && guard++ < 12) {
|
|
20
|
+
const inner = s?._def?.innerType;
|
|
21
|
+
if (inner && inner !== s) {
|
|
22
|
+
s = inner;
|
|
23
|
+
continue;
|
|
24
|
+
}
|
|
25
|
+
break;
|
|
26
|
+
}
|
|
27
|
+
return s;
|
|
28
|
+
}
|
|
29
|
+
function shapeOf(schema) {
|
|
30
|
+
try {
|
|
31
|
+
const direct = schema?.shape;
|
|
32
|
+
if (direct)
|
|
33
|
+
return typeof direct === 'function' ? direct() : direct;
|
|
34
|
+
const def = schema?._def?.shape;
|
|
35
|
+
if (def)
|
|
36
|
+
return typeof def === 'function' ? def() : def;
|
|
37
|
+
}
|
|
38
|
+
catch { /* ignore */ }
|
|
39
|
+
return undefined;
|
|
40
|
+
}
|
|
41
|
+
function elementOf(schema) {
|
|
42
|
+
return schema?.element ?? schema?._def?.element ?? schema?._def?.type;
|
|
43
|
+
}
|
|
44
|
+
function leafAt(schema, path) {
|
|
45
|
+
let cur = schema;
|
|
46
|
+
for (const seg of path) {
|
|
47
|
+
cur = unwrap(cur);
|
|
48
|
+
if (typeof seg === 'number' || /^\d+$/.test(String(seg))) {
|
|
49
|
+
cur = elementOf(cur);
|
|
50
|
+
}
|
|
51
|
+
else {
|
|
52
|
+
const shape = shapeOf(cur);
|
|
53
|
+
cur = shape ? shape[String(seg)] : undefined;
|
|
54
|
+
}
|
|
55
|
+
if (!cur)
|
|
56
|
+
return undefined;
|
|
57
|
+
}
|
|
58
|
+
return unwrap(cur);
|
|
59
|
+
}
|
|
60
|
+
function hintAt(schema, path) {
|
|
61
|
+
if (!schema)
|
|
62
|
+
return undefined;
|
|
63
|
+
try {
|
|
64
|
+
const leaf = leafAt(schema, path);
|
|
65
|
+
const d = leaf?.description;
|
|
66
|
+
return typeof d === 'string' && d.length > 0 ? d : undefined;
|
|
67
|
+
}
|
|
68
|
+
catch {
|
|
69
|
+
return undefined;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
// ---- per-issue rendering ----
|
|
73
|
+
function unitFor(origin, count) {
|
|
74
|
+
const plural = count === 1 ? '' : 's';
|
|
75
|
+
if (origin === 'string')
|
|
76
|
+
return ` character${plural}`;
|
|
77
|
+
if (origin === 'array' || origin === 'set')
|
|
78
|
+
return ` item${plural}`;
|
|
79
|
+
return '';
|
|
80
|
+
}
|
|
81
|
+
function describeIssue(issue, schema) {
|
|
82
|
+
const path = Array.isArray(issue?.path) ? issue.path : [];
|
|
83
|
+
const pathStr = path.join('.');
|
|
84
|
+
const hint = hintAt(schema, path);
|
|
85
|
+
const withHint = (s) => (hint ? `${s} (${hint})` : s);
|
|
86
|
+
// Pass an already-actionable message through, prefixed with the field path when there is
|
|
87
|
+
// one (an empty path, e.g. a root-level issue, must not produce a leading ": ").
|
|
88
|
+
const withPath = (m) => (pathStr ? `${pathStr}: ${m}` : m);
|
|
89
|
+
const msg = typeof issue?.message === 'string' ? issue.message : 'invalid input';
|
|
90
|
+
switch (issue?.code) {
|
|
91
|
+
case 'invalid_type': {
|
|
92
|
+
// Zod v4's DEFAULT message looks like "Invalid input: expected X, received Y"
|
|
93
|
+
// ("received undefined" means the field was missing). We reformat only when we can
|
|
94
|
+
// read that "received <word>" token: that confirms it is the default message, not a
|
|
95
|
+
// custom one that merely happens to start with the same words. Anything else (a custom
|
|
96
|
+
// .int()/.refine() message) passes through unchanged. NOTE: this prefix is English and
|
|
97
|
+
// Zod-version-specific; the exact-string unit tests act as a canary if Zod changes it.
|
|
98
|
+
const received = msg.startsWith('Invalid input: expected') ? /received (\w+)/.exec(msg)?.[1] : undefined;
|
|
99
|
+
if (received === 'undefined')
|
|
100
|
+
return withHint(`${pathStr} is required`);
|
|
101
|
+
if (received)
|
|
102
|
+
return withHint(`${pathStr}: expected ${issue.expected}, received ${received}`);
|
|
103
|
+
return withPath(msg);
|
|
104
|
+
}
|
|
105
|
+
case 'invalid_value':
|
|
106
|
+
// enum / literal: list the allowed options
|
|
107
|
+
return Array.isArray(issue.values) ? withPath(`allowed values: ${issue.values.join(', ')}`) : withPath(msg);
|
|
108
|
+
case 'invalid_format':
|
|
109
|
+
// regex / custom string formats already carry an actionable message
|
|
110
|
+
return withPath(msg);
|
|
111
|
+
case 'too_big': {
|
|
112
|
+
if (!msg.startsWith('Too big:'))
|
|
113
|
+
return withPath(msg); // custom message
|
|
114
|
+
const cmp = issue.inclusive === false ? 'less than' : 'at most';
|
|
115
|
+
return withPath(`must be ${cmp} ${issue.maximum}${unitFor(issue.origin, issue.maximum)}`);
|
|
116
|
+
}
|
|
117
|
+
case 'too_small': {
|
|
118
|
+
if (!msg.startsWith('Too small:'))
|
|
119
|
+
return withPath(msg); // custom message
|
|
120
|
+
const cmp = issue.inclusive === false ? 'more than' : 'at least';
|
|
121
|
+
return withPath(`must be ${cmp} ${issue.minimum}${unitFor(issue.origin, issue.minimum)}`);
|
|
122
|
+
}
|
|
123
|
+
case 'unrecognized_keys': {
|
|
124
|
+
// path is empty for this issue: do NOT emit a leading "<path>: " (the old bug).
|
|
125
|
+
const keys = (Array.isArray(issue.keys) ? issue.keys : [])
|
|
126
|
+
.map(sanitizeKey)
|
|
127
|
+
.filter((k) => k.length > 0)
|
|
128
|
+
.join(', ');
|
|
129
|
+
// keys can be empty if every offending key sanitised away (all control chars).
|
|
130
|
+
return keys ? `unexpected field(s): ${keys}` : 'unexpected field(s) present';
|
|
131
|
+
}
|
|
132
|
+
default:
|
|
133
|
+
return withPath(msg);
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
/**
|
|
137
|
+
* Format a ZodError into one actionable `Validation error: ...` string.
|
|
138
|
+
*
|
|
139
|
+
* @param error the ZodError (duck-typed on `.issues`, so a structurally-compatible
|
|
140
|
+
* error from any Zod build is accepted).
|
|
141
|
+
* @param schema optional tool input schema, used to resolve `.describe()` hints for
|
|
142
|
+
* the failing fields. Omit it and messages still format, just without hints.
|
|
143
|
+
*/
|
|
144
|
+
export function formatZodError(error, schema) {
|
|
145
|
+
const issues = Array.isArray(error?.issues) ? error.issues : [];
|
|
146
|
+
if (issues.length === 0)
|
|
147
|
+
return 'Validation error: invalid input';
|
|
148
|
+
const parts = issues.map((issue) => describeIssue(issue, schema));
|
|
149
|
+
return `Validation error: ${parts.join(', ')}`;
|
|
150
|
+
}
|
package/dist/src/logger.js
CHANGED
|
@@ -2,6 +2,10 @@ import winston from 'winston';
|
|
|
2
2
|
import DailyRotateFile from 'winston-daily-rotate-file';
|
|
3
3
|
import path from 'path';
|
|
4
4
|
import { fileURLToPath } from 'url';
|
|
5
|
+
import { randomUUID } from 'crypto';
|
|
6
|
+
// requestContext only imports async_hooks, so this is safe to import from the early-loaded
|
|
7
|
+
// logger module (no circular import).
|
|
8
|
+
import { requestContext } from './lib/requestContext.js';
|
|
5
9
|
const __filename = fileURLToPath(import.meta.url);
|
|
6
10
|
const __dirname = path.dirname(__filename);
|
|
7
11
|
// env-configurable
|
|
@@ -14,7 +18,6 @@ const LOG_DIR = process.env.MCP_BRIDGE_LOG_DIR
|
|
|
14
18
|
const DATE_PATTERN = process.env.MCP_BRIDGE_ROTATE_DATEPATTERN || 'YYYY-MM-DD';
|
|
15
19
|
const MAX_SIZE = process.env.MCP_BRIDGE_MAX_LOG_SIZE || '20m';
|
|
16
20
|
const MAX_FILES = process.env.MCP_BRIDGE_MAX_FILES || '14d';
|
|
17
|
-
const DEFAULT_LEVEL = process.env.MCP_BRIDGE_LOG_LEVEL || 'debug';
|
|
18
21
|
function safeStringify(obj, maxLen = 2000) {
|
|
19
22
|
try {
|
|
20
23
|
const seen = new WeakSet();
|
|
@@ -39,6 +42,228 @@ function safeStringify(obj, maxLen = 2000) {
|
|
|
39
42
|
}
|
|
40
43
|
}
|
|
41
44
|
}
|
|
45
|
+
/**
|
|
46
|
+
* Resolve the output format, level, and service name from the environment.
|
|
47
|
+
*
|
|
48
|
+
* Format precedence is deterministic: an explicit `LOG_FORMAT` ("json" | "pretty")
|
|
49
|
+
* wins; otherwise `NODE_ENV==='production'` selects JSON; otherwise pretty. The
|
|
50
|
+
* non-production default is therefore the human-readable text format, so existing
|
|
51
|
+
* log-text-scraping tests keep passing and JSON is an explicit opt-in or prod-only.
|
|
52
|
+
*/
|
|
53
|
+
export function resolveLogConfig(env = process.env) {
|
|
54
|
+
const fmt = String(env.LOG_FORMAT || '').toLowerCase();
|
|
55
|
+
const isProd = env.NODE_ENV === 'production';
|
|
56
|
+
const useJson = fmt === 'json' ? true : fmt === 'pretty' ? false : isProd;
|
|
57
|
+
const level = env.MCP_BRIDGE_LOG_LEVEL || (isProd ? 'info' : 'debug');
|
|
58
|
+
const service = env.MCP_SERVICE_NAME || 'actual-mcp-server';
|
|
59
|
+
return { useJson, level, service };
|
|
60
|
+
}
|
|
61
|
+
const { useJson: USE_JSON, level: DEFAULT_LEVEL, service: SERVICE } = resolveLogConfig(process.env);
|
|
62
|
+
// Fields that stay at the top level of a record; everything else is user metadata.
|
|
63
|
+
// `sessionId`/`requestId` are reserved correlation fields (#221), so they render top-level
|
|
64
|
+
// rather than nested under `context`.
|
|
65
|
+
const STANDARD_KEYS = new Set([
|
|
66
|
+
'level', 'message', 'timestamp', 'service', 'module', 'stack', 'ms', 'sessionId', 'requestId',
|
|
67
|
+
]);
|
|
68
|
+
// Promote a `service` field onto every record.
|
|
69
|
+
const addService = winston.format((info) => {
|
|
70
|
+
info.service = SERVICE;
|
|
71
|
+
return info;
|
|
72
|
+
});
|
|
73
|
+
/**
|
|
74
|
+
* Resolve a request correlation id: reuse a non-empty inbound `X-Correlation-ID`, else
|
|
75
|
+
* generate one. Exported so the HTTP layer and tests share the exact decision (#221).
|
|
76
|
+
*
|
|
77
|
+
* The inbound value is CLIENT-CONTROLLED and is stamped on every log line for the request,
|
|
78
|
+
* so it is sanitised first: control characters are stripped (prevents log-line forging in
|
|
79
|
+
* non-JSON consumers) and the length is capped (prevents per-line bloat from a hostile
|
|
80
|
+
* header). If nothing usable remains, a UUID is generated.
|
|
81
|
+
*/
|
|
82
|
+
const MAX_REQUEST_ID_LEN = 128;
|
|
83
|
+
export function resolveRequestId(inbound) {
|
|
84
|
+
let id = typeof inbound === 'string' ? inbound.trim() : '';
|
|
85
|
+
if (id) {
|
|
86
|
+
// eslint-disable-next-line no-control-regex
|
|
87
|
+
id = id.replace(/[\u0000-\u001f\u007f]/g, '').slice(0, MAX_REQUEST_ID_LEN);
|
|
88
|
+
}
|
|
89
|
+
return id.length > 0 ? id : randomUUID();
|
|
90
|
+
}
|
|
91
|
+
// Stamp the active request's correlation ids onto every record (#221). Reads the
|
|
92
|
+
// AsyncLocalStorage store; the context-derived id is authoritative, so it OVERWRITES any
|
|
93
|
+
// caller-supplied `sessionId` metadata. When there is no active store (startup, CLI, stdio
|
|
94
|
+
// without a request wrapper) it is a no-op and never throws. Must run after splat() (which
|
|
95
|
+
// reverts top-level field additions made before it), so it is placed alongside addService.
|
|
96
|
+
const addCorrelation = winston.format((info) => {
|
|
97
|
+
const store = requestContext.getStore();
|
|
98
|
+
if (store?.sessionId)
|
|
99
|
+
info.sessionId = store.sessionId;
|
|
100
|
+
if (store?.requestId)
|
|
101
|
+
info.requestId = store.requestId;
|
|
102
|
+
return info;
|
|
103
|
+
});
|
|
104
|
+
// Collect non-standard fields into a single `context` object so JSON records have a
|
|
105
|
+
// stable shape ({ timestamp, level, service, module, message, stack?, context? }).
|
|
106
|
+
// winston hands the SAME info object to every transport's format, so this must be
|
|
107
|
+
// idempotent: a record passing through more than one transport (e.g. STORE_LOGS=true
|
|
108
|
+
// gives two file transports plus the console) must not re-nest `context` into
|
|
109
|
+
// `context.context`. Excluding the `context` key from the move makes a second pass a
|
|
110
|
+
// no-op, and any already-present context is merged rather than wrapped.
|
|
111
|
+
const nestContext = winston.format((info) => {
|
|
112
|
+
const ctx = {};
|
|
113
|
+
for (const key of Object.keys(info)) {
|
|
114
|
+
if (!STANDARD_KEYS.has(key) && key !== 'context') {
|
|
115
|
+
ctx[key] = info[key];
|
|
116
|
+
delete info[key];
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
if (Object.keys(ctx).length > 0) {
|
|
120
|
+
const existing = info.context;
|
|
121
|
+
info.context =
|
|
122
|
+
existing && typeof existing === 'object' ? { ...existing, ...ctx } : ctx;
|
|
123
|
+
}
|
|
124
|
+
return info;
|
|
125
|
+
});
|
|
126
|
+
// ---------------------------------------------------------------------------
|
|
127
|
+
// Secret / PII redaction (#220)
|
|
128
|
+
//
|
|
129
|
+
// Runs inside buildLogFormat (per-transport), specifically AFTER winston.format.splat():
|
|
130
|
+
// splat() rebuilds the record from the original args and reverts top-level field mutations
|
|
131
|
+
// made before it (nested objects survive only because they are shared refs), so redaction
|
|
132
|
+
// placed before splat would silently fail to mask a top-level secret like `note`. Running
|
|
133
|
+
// it per-transport in the SAME pipeline that serializes (before nestContext, which moves
|
|
134
|
+
// fields under `context`) is the placement that actually reaches the output. It is
|
|
135
|
+
// idempotent (masking and value-scrub both go to a constant), so the STORE_LOGS
|
|
136
|
+
// multi-transport fan-out is safe. The live `note`/header tests pin this ordering: moving
|
|
137
|
+
// redactSecrets before splat makes them fail.
|
|
138
|
+
//
|
|
139
|
+
// Two mechanisms: a key-name denylist for structured fields at any depth, and a finite
|
|
140
|
+
// scrub of the actual configured secret VALUES (which catches a secret carried as a value
|
|
141
|
+
// under a benign key, or inside a stringified message).
|
|
142
|
+
// ---------------------------------------------------------------------------
|
|
143
|
+
const REDACTED = '[REDACTED]';
|
|
144
|
+
const SENSITIVE_KEYS = new Set([
|
|
145
|
+
'authorization', 'proxy-authorization', 'token', 'password', 'encryptionpassword',
|
|
146
|
+
'cookie', 'set-cookie', 'secret', 'apikey', 'api_key', 'x-api-key',
|
|
147
|
+
'access_token', 'refresh_token', 'client_secret',
|
|
148
|
+
]);
|
|
149
|
+
/** A metadata key is sensitive if its lowercased name is denylisted or ends in a secret suffix. */
|
|
150
|
+
export function isSensitiveKey(key) {
|
|
151
|
+
const low = key.toLowerCase();
|
|
152
|
+
if (SENSITIVE_KEYS.has(low))
|
|
153
|
+
return true;
|
|
154
|
+
return (low.endsWith('password') ||
|
|
155
|
+
low.endsWith('secret') ||
|
|
156
|
+
low.endsWith('token') ||
|
|
157
|
+
low.includes('authorization') ||
|
|
158
|
+
low.includes('cookie'));
|
|
159
|
+
}
|
|
160
|
+
/** The actual configured secret values, so a secret echoed as a VALUE is also masked. */
|
|
161
|
+
export function collectSecretValues(env = process.env) {
|
|
162
|
+
const out = [];
|
|
163
|
+
for (const [k, v] of Object.entries(env)) {
|
|
164
|
+
// length guard avoids masking short/common values that could collide with benign log
|
|
165
|
+
// text (a short secret is still masked by KEY name when under a sensitive field; only
|
|
166
|
+
// the free-text VALUE scrub is skipped for it).
|
|
167
|
+
if (typeof v === 'string' && v.length >= 8 && /(PASSWORD|SECRET|AUTHORIZATION|_TOKEN)$/.test(k)) {
|
|
168
|
+
out.push(v);
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
return out;
|
|
172
|
+
}
|
|
173
|
+
function scrubSecretValues(s, secrets) {
|
|
174
|
+
let out = s;
|
|
175
|
+
for (const sec of secrets) {
|
|
176
|
+
if (sec && out.includes(sec))
|
|
177
|
+
out = out.split(sec).join(REDACTED);
|
|
178
|
+
}
|
|
179
|
+
return out;
|
|
180
|
+
}
|
|
181
|
+
const MAX_REDACT_DEPTH = 8;
|
|
182
|
+
const MAX_REDACT_NODES = 2000;
|
|
183
|
+
/**
|
|
184
|
+
* Return a redacted version of a value. NESTED objects/arrays are COPIED (copy-on-write),
|
|
185
|
+
* never mutated, so redacting a log record cannot corrupt a live caller object (e.g.
|
|
186
|
+
* `req.headers` passed by reference). Depth/node caps and a cycle guard bound pathological
|
|
187
|
+
* or cyclic structures.
|
|
188
|
+
*/
|
|
189
|
+
function redactValue(val, secrets, depth, seen, counter) {
|
|
190
|
+
if (val && typeof val === 'object') {
|
|
191
|
+
if (depth > MAX_REDACT_DEPTH || counter.n > MAX_REDACT_NODES)
|
|
192
|
+
return '[Truncated]';
|
|
193
|
+
if (seen.has(val))
|
|
194
|
+
return '[Circular]';
|
|
195
|
+
seen.add(val);
|
|
196
|
+
if (Array.isArray(val))
|
|
197
|
+
return val.map((v) => redactValue(v, secrets, depth + 1, seen, counter));
|
|
198
|
+
const copy = {};
|
|
199
|
+
for (const key of Object.keys(val)) {
|
|
200
|
+
counter.n++;
|
|
201
|
+
copy[key] = isSensitiveKey(key)
|
|
202
|
+
? REDACTED
|
|
203
|
+
: redactValue(val[key], secrets, depth + 1, seen, counter);
|
|
204
|
+
}
|
|
205
|
+
return copy;
|
|
206
|
+
}
|
|
207
|
+
if (typeof val === 'string' && secrets.length > 0)
|
|
208
|
+
return scrubSecretValues(val, secrets);
|
|
209
|
+
return val;
|
|
210
|
+
}
|
|
211
|
+
/**
|
|
212
|
+
* Mask sensitive keys and scrub known-secret values. The TOP-LEVEL record is mutated in
|
|
213
|
+
* place (winston hands the transport its own copy, and the winston Symbol keys must be
|
|
214
|
+
* preserved), but every NESTED object is replaced with a redacted copy so a shared live
|
|
215
|
+
* object (a request's headers) is never altered. Masking to a constant is idempotent.
|
|
216
|
+
*/
|
|
217
|
+
export function redactRecord(record, secrets = []) {
|
|
218
|
+
if (!record || typeof record !== 'object')
|
|
219
|
+
return record;
|
|
220
|
+
const seen = new WeakSet();
|
|
221
|
+
const counter = { n: 0 };
|
|
222
|
+
const rec = record;
|
|
223
|
+
for (const key of Object.keys(rec)) {
|
|
224
|
+
rec[key] = isSensitiveKey(key) ? REDACTED : redactValue(rec[key], secrets, 1, seen, counter);
|
|
225
|
+
}
|
|
226
|
+
return record;
|
|
227
|
+
}
|
|
228
|
+
const SECRET_VALUES = collectSecretValues(process.env);
|
|
229
|
+
const redactSecrets = winston.format((info) => redactRecord(info, SECRET_VALUES));
|
|
230
|
+
/**
|
|
231
|
+
* Build the winston format. Reuses winston's own building blocks
|
|
232
|
+
* (`format.errors` + `format.splat` + `format.json`) rather than a hand-rolled
|
|
233
|
+
* serializer, so the metadata that loggerFactory already attaches flows through.
|
|
234
|
+
* The previous printf referenced only `{ timestamp, level, message }`, which is
|
|
235
|
+
* why metadata (including error stacks) was silently dropped.
|
|
236
|
+
*/
|
|
237
|
+
export function buildLogFormat(useJson, colorize = !!process.stdout.isTTY) {
|
|
238
|
+
if (useJson) {
|
|
239
|
+
return winston.format.combine(winston.format.timestamp(), // ISO 8601 UTC
|
|
240
|
+
winston.format.errors({ stack: true }), winston.format.splat(),
|
|
241
|
+
// #220: redact AFTER splat. splat() rebuilds the record from the original args and
|
|
242
|
+
// reverts top-level field mutations made before it (nested objects survive as shared
|
|
243
|
+
// refs), so redaction must run after splat to actually mask top-level secrets.
|
|
244
|
+
redactSecrets(), addCorrelation(), // #221: stamp sessionId/requestId (after splat, like addService)
|
|
245
|
+
addService(), nestContext(), winston.format.json());
|
|
246
|
+
}
|
|
247
|
+
// Colorize only for an interactive terminal, so piped output and log files do not get
|
|
248
|
+
// ANSI escape codes (also keeps the format deterministic and unit-testable).
|
|
249
|
+
return winston.format.combine(winston.format.timestamp({ format: 'YYYY-MM-DD HH:mm:ss.SSS' }), winston.format.errors({ stack: true }), winston.format.splat(), redactSecrets(), // #220: after splat (see note in the json branch)
|
|
250
|
+
addCorrelation(), // #221: stamp sessionId/requestId
|
|
251
|
+
...(colorize ? [winston.format.colorize()] : []), winston.format.printf((info) => {
|
|
252
|
+
const rec = info;
|
|
253
|
+
let line = `${rec.timestamp} ${rec.level}: ${rec.message}`;
|
|
254
|
+
if (rec.stack)
|
|
255
|
+
line += `\n${rec.stack}`;
|
|
256
|
+
// Append any remaining metadata for dev readability (it is no longer dropped).
|
|
257
|
+
const extra = {};
|
|
258
|
+
for (const key of Object.keys(rec)) {
|
|
259
|
+
if (!STANDARD_KEYS.has(key))
|
|
260
|
+
extra[key] = rec[key];
|
|
261
|
+
}
|
|
262
|
+
if (Object.keys(extra).length > 0)
|
|
263
|
+
line += ` ${safeStringify(extra, 1000)}`;
|
|
264
|
+
return line;
|
|
265
|
+
}));
|
|
266
|
+
}
|
|
42
267
|
const transports = [];
|
|
43
268
|
// file transports when enabled (capture debug+)
|
|
44
269
|
if (STORE_LOGS) {
|
|
@@ -50,22 +275,23 @@ if (STORE_LOGS) {
|
|
|
50
275
|
zippedArchive: true,
|
|
51
276
|
maxSize: MAX_SIZE,
|
|
52
277
|
maxFiles: MAX_FILES,
|
|
53
|
-
|
|
278
|
+
// Files must never get ANSI colour, regardless of whether stdout is a TTY.
|
|
279
|
+
format: buildLogFormat(USE_JSON, false),
|
|
54
280
|
});
|
|
55
281
|
// debug transport collects debug+ messages into debug-%DATE%.log
|
|
56
282
|
transports.push(createDailyRotateTransport('debug'));
|
|
57
283
|
// errors into error-%DATE%.log
|
|
58
284
|
transports.push(createDailyRotateTransport('error'));
|
|
59
285
|
}
|
|
60
|
-
// single console transport for terminal output (use same level)
|
|
61
|
-
// In stdio mode all output must go to stderr
|
|
286
|
+
// single console transport for terminal output (use same level).
|
|
287
|
+
// In stdio mode all output must go to stderr. Writing to stdout corrupts JSON-RPC framing.
|
|
62
288
|
// MCP_STDIO_MODE is set by src/index.ts before this module is first imported.
|
|
63
289
|
transports.push(new winston.transports.Console({
|
|
64
290
|
level: DEFAULT_LEVEL,
|
|
65
291
|
...(process.env.MCP_STDIO_MODE === 'true'
|
|
66
292
|
? { stderrLevels: ['error', 'warn', 'info', 'verbose', 'debug', 'silly', 'http'] }
|
|
67
293
|
: {}),
|
|
68
|
-
format:
|
|
294
|
+
format: buildLogFormat(USE_JSON),
|
|
69
295
|
}));
|
|
70
296
|
const logger = winston.createLogger({
|
|
71
297
|
level: DEFAULT_LEVEL,
|
|
@@ -73,15 +299,17 @@ const logger = winston.createLogger({
|
|
|
73
299
|
exitOnError: false,
|
|
74
300
|
});
|
|
75
301
|
export function logTransportWithDirection(direction, clientIp, req, data) {
|
|
76
|
-
|
|
302
|
+
// Log STRUCTURED meta (not a pre-stringified blob) so the redaction format (#220) can
|
|
303
|
+
// mask sensitive header keys (Authorization, Cookie) and known-secret values in the
|
|
304
|
+
// payload before anything is written.
|
|
305
|
+
logger.debug('transport message', {
|
|
77
306
|
direction,
|
|
78
307
|
clientIp,
|
|
79
308
|
method: req.method,
|
|
80
309
|
url: req.originalUrl,
|
|
81
310
|
headers: req.headers,
|
|
82
311
|
payload: safeStringify(data, 2000),
|
|
83
|
-
};
|
|
84
|
-
logger.debug(safeStringify(meta, 4000));
|
|
312
|
+
});
|
|
85
313
|
}
|
|
86
314
|
// --- Wire debug module into winston and route console.* to winston only ---
|
|
87
315
|
// NOTE: avoid writing to both original console and winston to prevent duplicates.
|
|
@@ -121,7 +349,7 @@ export function logTransportWithDirection(direction, clientIp, req, data) {
|
|
|
121
349
|
}
|
|
122
350
|
}
|
|
123
351
|
catch {
|
|
124
|
-
// debug module not available or failed to patch
|
|
352
|
+
// debug module not available or failed to patch, ignore
|
|
125
353
|
}
|
|
126
354
|
})();
|
|
127
355
|
export default logger;
|
|
@@ -3,7 +3,7 @@ import { randomUUID, timingSafeEqual } from 'node:crypto';
|
|
|
3
3
|
import { Server } from '@modelcontextprotocol/sdk/server/index.js';
|
|
4
4
|
import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';
|
|
5
5
|
import { ListToolsRequestSchema, CallToolRequestSchema, } from '@modelcontextprotocol/sdk/types.js';
|
|
6
|
-
import logger from '../logger.js';
|
|
6
|
+
import logger, { resolveRequestId } from '../logger.js';
|
|
7
7
|
import { getLocalIp } from '../utils.js';
|
|
8
8
|
import actualToolsManager from '../actualToolsManager.js';
|
|
9
9
|
import { getConnectionState, connectToActualForSession, shutdownActualForSession, shutdownActual, canAcceptNewSession } from '../actualConnection.js';
|
|
@@ -389,7 +389,8 @@ bindHost = 'localhost', advertisedUrl) {
|
|
|
389
389
|
// Run in AsyncLocalStorage context so tools can access sessionId
|
|
390
390
|
// and the adapter can enforce per-request budget ACL (#156).
|
|
391
391
|
const allowedBudgetsInit = req.allowedBudgets;
|
|
392
|
-
|
|
392
|
+
const requestId = resolveRequestId(req.get('x-correlation-id'));
|
|
393
|
+
await requestContext.run({ sessionId: undefined, requestId, allowedBudgets: allowedBudgetsInit, principal: resolvePrincipal(req) }, async () => {
|
|
393
394
|
await transport.handleRequest(req, res, req.body);
|
|
394
395
|
});
|
|
395
396
|
}
|
|
@@ -483,7 +484,8 @@ bindHost = 'localhost', advertisedUrl) {
|
|
|
483
484
|
// Run in AsyncLocalStorage context so tools and the adapter can access
|
|
484
485
|
// sessionId (pool branch, #134) and allowedBudgets (ACL enforcement, #156).
|
|
485
486
|
const allowedBudgets = req.allowedBudgets;
|
|
486
|
-
|
|
487
|
+
const requestId = resolveRequestId(req.get('x-correlation-id'));
|
|
488
|
+
await requestContext.run({ sessionId, requestId, allowedBudgets, principal: resolvePrincipal(req) }, async () => {
|
|
487
489
|
await transport.handleRequest(req, res, req.body);
|
|
488
490
|
});
|
|
489
491
|
}
|
|
@@ -28,25 +28,18 @@ Example: Update account name and offbudget status:
|
|
|
28
28
|
}`,
|
|
29
29
|
inputSchema: InputSchema,
|
|
30
30
|
call: async (args, _meta) => {
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
await adapter.updateAccount(input.id, input.fields);
|
|
37
|
-
return {
|
|
38
|
-
success: true,
|
|
39
|
-
accountId: input.id,
|
|
40
|
-
updatedFields: Object.keys(input.fields),
|
|
41
|
-
};
|
|
42
|
-
}
|
|
43
|
-
catch (error) {
|
|
44
|
-
if (error instanceof z.ZodError) {
|
|
45
|
-
const fieldErrors = error.issues.map((e) => `${e.path.join('.')}: ${e.message}`).join('; ');
|
|
46
|
-
throw new Error(`Invalid account update data: ${fieldErrors}`);
|
|
47
|
-
}
|
|
48
|
-
throw error;
|
|
31
|
+
// Zod validation errors are formatted centrally by actualToolsManager (#206);
|
|
32
|
+
// this tool only adds its domain guard for an empty update.
|
|
33
|
+
const input = InputSchema.parse(args || {});
|
|
34
|
+
if (!input.fields || Object.keys(input.fields).length === 0) {
|
|
35
|
+
throw new Error('No fields provided to update. Include at least one field: name, offbudget, or closed.');
|
|
49
36
|
}
|
|
37
|
+
await adapter.updateAccount(input.id, input.fields);
|
|
38
|
+
return {
|
|
39
|
+
success: true,
|
|
40
|
+
accountId: input.id,
|
|
41
|
+
updatedFields: Object.keys(input.fields),
|
|
42
|
+
};
|
|
50
43
|
},
|
|
51
44
|
};
|
|
52
45
|
export default tool;
|
|
@@ -11,29 +11,8 @@ const tool = {
|
|
|
11
11
|
description: "Create a category. REQUIRED: group_id (category group UUID). Use actual_category_groups_get to find available groups. Optional: is_income (boolean, defaults to false for expense categories).",
|
|
12
12
|
inputSchema: InputSchema,
|
|
13
13
|
call: async (args, _meta) => {
|
|
14
|
-
//
|
|
15
|
-
|
|
16
|
-
try {
|
|
17
|
-
input = InputSchema.parse(args || {});
|
|
18
|
-
}
|
|
19
|
-
catch (error) {
|
|
20
|
-
if (error instanceof z.ZodError) {
|
|
21
|
-
const issues = error.issues.map(issue => {
|
|
22
|
-
if (issue.path.includes('group_id')) {
|
|
23
|
-
return `Invalid group_id: ${issue.message}. Use actual_category_groups_get to find valid category group UUIDs.`;
|
|
24
|
-
}
|
|
25
|
-
if (issue.path.includes('name')) {
|
|
26
|
-
return `Invalid name: ${issue.message}`;
|
|
27
|
-
}
|
|
28
|
-
if (issue.path.includes('is_income')) {
|
|
29
|
-
return `Invalid is_income: ${issue.message}. Must be true or false.`;
|
|
30
|
-
}
|
|
31
|
-
return `${issue.path.join('.')}: ${issue.message}`;
|
|
32
|
-
});
|
|
33
|
-
throw new Error(`Validation failed:\n${issues.join('\n')}`);
|
|
34
|
-
}
|
|
35
|
-
throw error;
|
|
36
|
-
}
|
|
14
|
+
// Zod validation errors are formatted centrally by actualToolsManager (#206).
|
|
15
|
+
const input = InputSchema.parse(args || {});
|
|
37
16
|
try {
|
|
38
17
|
// Input already has correct field names (group_id, is_income)
|
|
39
18
|
// Convert null to false (LibreChat sometimes sends null instead of undefined)
|
|
@@ -35,25 +35,18 @@ Example: Mark payee as transfer account:
|
|
|
35
35
|
}`,
|
|
36
36
|
inputSchema: InputSchema,
|
|
37
37
|
call: async (args, _meta) => {
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
await adapter.updatePayee(input.id, input.fields);
|
|
44
|
-
return {
|
|
45
|
-
success: true,
|
|
46
|
-
payeeId: input.id,
|
|
47
|
-
updatedFields: Object.keys(input.fields),
|
|
48
|
-
};
|
|
49
|
-
}
|
|
50
|
-
catch (error) {
|
|
51
|
-
if (error instanceof z.ZodError) {
|
|
52
|
-
const fieldErrors = error.issues.map((e) => `${e.path.join('.')}: ${e.message}`).join('; ');
|
|
53
|
-
throw new Error(`Invalid payee update data: ${fieldErrors}`);
|
|
54
|
-
}
|
|
55
|
-
throw error;
|
|
38
|
+
// Zod validation errors are formatted centrally by actualToolsManager (#206);
|
|
39
|
+
// this tool only adds its domain guard for an empty update.
|
|
40
|
+
const input = InputSchema.parse(args || {});
|
|
41
|
+
if (!input.fields || Object.keys(input.fields).length === 0) {
|
|
42
|
+
throw new Error('No fields provided to update. Include at least one field: name or transfer_acct.');
|
|
56
43
|
}
|
|
44
|
+
await adapter.updatePayee(input.id, input.fields);
|
|
45
|
+
return {
|
|
46
|
+
success: true,
|
|
47
|
+
payeeId: input.id,
|
|
48
|
+
updatedFields: Object.keys(input.fields),
|
|
49
|
+
};
|
|
57
50
|
},
|
|
58
51
|
};
|
|
59
52
|
export default tool;
|
|
@@ -55,75 +55,67 @@ Action operators: 'set', 'set-split-amount', 'link-schedule', 'append-notes'.
|
|
|
55
55
|
Example: {stage: "post", conditionsOp: "and", conditions: [{field: "imported_payee", op: "contains", value: "Amazon"}], actions: [{op: "set", field: "category", value: "category-uuid"}]}`,
|
|
56
56
|
inputSchema: InputSchema,
|
|
57
57
|
call: async (args, _meta) => {
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
}
|
|
65
|
-
// Validate action field values for ID-type fields
|
|
66
|
-
if (action.op === 'set' && action.field) {
|
|
67
|
-
// Check if using category field with text value instead of ID
|
|
68
|
-
if (action.field === 'category' && typeof action.value === 'string' && !action.value.match(/^[0-9a-f-]{36}$/i)) {
|
|
69
|
-
throw new Error(`Action field "category" expects a category ID (UUID), but got text value "${action.value}". ` +
|
|
70
|
-
`Use the category UUID from your budget data. You can list categories to find the correct UUID.`);
|
|
71
|
-
}
|
|
72
|
-
// Check if using payee field with text value instead of ID
|
|
73
|
-
if (action.field === 'payee' && typeof action.value === 'string' && !action.value.match(/^[0-9a-f-]{36}$/i)) {
|
|
74
|
-
throw new Error(`Action field "payee" expects a payee ID (UUID), but got text value "${action.value}". ` +
|
|
75
|
-
`Use the payee UUID from your budget data. You can list payees to find the correct UUID.`);
|
|
76
|
-
}
|
|
77
|
-
// Check if using account field with text value instead of ID
|
|
78
|
-
if (action.field === 'account' && typeof action.value === 'string' && !action.value.match(/^[0-9a-f-]{36}$/i)) {
|
|
79
|
-
throw new Error(`Action field "account" expects an account ID (UUID), but got text value "${action.value}". ` +
|
|
80
|
-
`Use the account UUID from your budget data. You can list accounts to find the correct UUID.`);
|
|
81
|
-
}
|
|
82
|
-
}
|
|
83
|
-
// Validate append-notes and prepend-notes have string values
|
|
84
|
-
if ((action.op === 'append-notes' || action.op === 'prepend-notes') && typeof action.value !== 'string') {
|
|
85
|
-
throw new Error(`Action "${action.op}" requires a string value, but got ${typeof action.value}. ` +
|
|
86
|
-
`Example: {op: "${action.op}", value: "text to ${action.op === 'append-notes' ? 'append' : 'prepend'}"}`);
|
|
87
|
-
}
|
|
58
|
+
// Zod validation errors are formatted centrally by actualToolsManager (#206).
|
|
59
|
+
const input = InputSchema.parse(args || {});
|
|
60
|
+
// Validate that actions with op="set" have a field
|
|
61
|
+
for (const action of input.actions) {
|
|
62
|
+
if (action.op === 'set' && !action.field) {
|
|
63
|
+
throw new Error('Action with op="set" requires a "field" property (e.g., "category", "payee", "notes", "cleared")');
|
|
88
64
|
}
|
|
89
|
-
// Validate field
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
`Field "${condition.field}" is a ${fieldInfo.type} field and only supports: ${fieldInfo.operators.join(', ')}. ` +
|
|
96
|
-
`Please use one of these operators instead.`);
|
|
65
|
+
// Validate action field values for ID-type fields
|
|
66
|
+
if (action.op === 'set' && action.field) {
|
|
67
|
+
// Check if using category field with text value instead of ID
|
|
68
|
+
if (action.field === 'category' && typeof action.value === 'string' && !action.value.match(/^[0-9a-f-]{36}$/i)) {
|
|
69
|
+
throw new Error(`Action field "category" expects a category ID (UUID), but got text value "${action.value}". ` +
|
|
70
|
+
`Use the category UUID from your budget data. You can list categories to find the correct UUID.`);
|
|
97
71
|
}
|
|
98
72
|
// Check if using payee field with text value instead of ID
|
|
99
|
-
if (
|
|
100
|
-
throw new Error(`
|
|
101
|
-
`
|
|
102
|
-
`Example: {field: "imported_payee", op: "contains", value: "${condition.value}"}`);
|
|
103
|
-
}
|
|
104
|
-
// Similar validation for account and category
|
|
105
|
-
if (['account', 'category'].includes(condition.field) && typeof condition.value === 'string' && !condition.value.match(/^[0-9a-f-]{36}$/i)) {
|
|
106
|
-
throw new Error(`Field "${condition.field}" expects an ID (UUID), but got text value "${condition.value}". ` +
|
|
107
|
-
`Use the ${condition.field} UUID from your budget data. List ${condition.field === 'account' ? 'accounts' : 'categories'} to find the correct UUID.`);
|
|
73
|
+
if (action.field === 'payee' && typeof action.value === 'string' && !action.value.match(/^[0-9a-f-]{36}$/i)) {
|
|
74
|
+
throw new Error(`Action field "payee" expects a payee ID (UUID), but got text value "${action.value}". ` +
|
|
75
|
+
`Use the payee UUID from your budget data. You can list payees to find the correct UUID.`);
|
|
108
76
|
}
|
|
109
|
-
//
|
|
110
|
-
if (
|
|
111
|
-
throw new Error(`
|
|
112
|
-
`
|
|
77
|
+
// Check if using account field with text value instead of ID
|
|
78
|
+
if (action.field === 'account' && typeof action.value === 'string' && !action.value.match(/^[0-9a-f-]{36}$/i)) {
|
|
79
|
+
throw new Error(`Action field "account" expects an account ID (UUID), but got text value "${action.value}". ` +
|
|
80
|
+
`Use the account UUID from your budget data. You can list accounts to find the correct UUID.`);
|
|
113
81
|
}
|
|
114
82
|
}
|
|
115
|
-
//
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
83
|
+
// Validate append-notes and prepend-notes have string values
|
|
84
|
+
if ((action.op === 'append-notes' || action.op === 'prepend-notes') && typeof action.value !== 'string') {
|
|
85
|
+
throw new Error(`Action "${action.op}" requires a string value, but got ${typeof action.value}. ` +
|
|
86
|
+
`Example: {op: "${action.op}", value: "text to ${action.op === 'append-notes' ? 'append' : 'prepend'}"}`);
|
|
87
|
+
}
|
|
119
88
|
}
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
89
|
+
// Validate field usage to guide users toward correct field selection
|
|
90
|
+
for (const condition of input.conditions) {
|
|
91
|
+
const fieldInfo = FIELD_OPERATORS[condition.field];
|
|
92
|
+
// Validate operator is compatible with field type
|
|
93
|
+
if (fieldInfo && !fieldInfo.operators.includes(condition.op)) {
|
|
94
|
+
throw new Error(`Invalid operator "${condition.op}" for field "${condition.field}". ` +
|
|
95
|
+
`Field "${condition.field}" is a ${fieldInfo.type} field and only supports: ${fieldInfo.operators.join(', ')}. ` +
|
|
96
|
+
`Please use one of these operators instead.`);
|
|
97
|
+
}
|
|
98
|
+
// Check if using payee field with text value instead of ID
|
|
99
|
+
if (condition.field === 'payee' && typeof condition.value === 'string' && !condition.value.match(/^[0-9a-f-]{36}$/i)) {
|
|
100
|
+
throw new Error(`Field "payee" expects a payee ID (UUID), but got text value "${condition.value}". ` +
|
|
101
|
+
`To match payee names with text, use "imported_payee" field instead. ` +
|
|
102
|
+
`Example: {field: "imported_payee", op: "contains", value: "${condition.value}"}`);
|
|
103
|
+
}
|
|
104
|
+
// Similar validation for account and category
|
|
105
|
+
if (['account', 'category'].includes(condition.field) && typeof condition.value === 'string' && !condition.value.match(/^[0-9a-f-]{36}$/i)) {
|
|
106
|
+
throw new Error(`Field "${condition.field}" expects an ID (UUID), but got text value "${condition.value}". ` +
|
|
107
|
+
`Use the ${condition.field} UUID from your budget data. List ${condition.field === 'account' ? 'accounts' : 'categories'} to find the correct UUID.`);
|
|
108
|
+
}
|
|
109
|
+
// Validate oneOf/notOneOf operators expect array values
|
|
110
|
+
if (['oneOf', 'notOneOf'].includes(condition.op) && !Array.isArray(condition.value)) {
|
|
111
|
+
throw new Error(`Operator "${condition.op}" expects an array of values, but got ${typeof condition.value}. ` +
|
|
112
|
+
`Example: {field: "${condition.field}", op: "${condition.op}", value: ["uuid-1", "uuid-2"]}`);
|
|
124
113
|
}
|
|
125
|
-
throw error;
|
|
126
114
|
}
|
|
115
|
+
// No automatic translation - require explicit field names
|
|
116
|
+
const ruleData = JSON.parse(JSON.stringify(input)); // deep clone
|
|
117
|
+
const ruleId = await adapter.createRule(ruleData);
|
|
118
|
+
return { id: ruleId, success: true };
|
|
127
119
|
},
|
|
128
120
|
};
|
|
129
121
|
export default tool;
|
|
@@ -115,89 +115,81 @@ IMPORTANT Field Types:
|
|
|
115
115
|
Returns: { id, created: boolean } — created=true if new rule was created, false if existing rule was updated.`,
|
|
116
116
|
inputSchema: InputSchema,
|
|
117
117
|
call: async (args, _meta) => {
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
}
|
|
127
|
-
if (condition.field === 'payee' && typeof condition.value === 'string' && !condition.value.match(/^[0-9a-f-]{36}$/i)) {
|
|
128
|
-
throw new Error(`Field "payee" expects a UUID, but got "${condition.value}". ` +
|
|
129
|
-
`Use "imported_payee" for text matching instead.`);
|
|
130
|
-
}
|
|
131
|
-
if (['account', 'category'].includes(condition.field) && typeof condition.value === 'string' && !condition.value.match(/^[0-9a-f-]{36}$/i)) {
|
|
132
|
-
throw new Error(`Field "${condition.field}" expects a UUID, but got text "${condition.value}".`);
|
|
133
|
-
}
|
|
134
|
-
if (['oneOf', 'notOneOf'].includes(condition.op) && !Array.isArray(condition.value)) {
|
|
135
|
-
throw new Error(`Operator "${condition.op}" expects an array of values.`);
|
|
136
|
-
}
|
|
118
|
+
// Zod validation errors are formatted centrally by actualToolsManager (#206).
|
|
119
|
+
const input = InputSchema.parse(args || {});
|
|
120
|
+
// ── Validate conditions ──
|
|
121
|
+
for (const condition of input.conditions) {
|
|
122
|
+
const fieldInfo = FIELD_OPERATORS[condition.field];
|
|
123
|
+
if (fieldInfo && !fieldInfo.operators.includes(condition.op)) {
|
|
124
|
+
throw new Error(`Invalid operator "${condition.op}" for field "${condition.field}". ` +
|
|
125
|
+
`Field "${condition.field}" is a ${fieldInfo.type} field and only supports: ${fieldInfo.operators.join(', ')}.`);
|
|
137
126
|
}
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
127
|
+
if (condition.field === 'payee' && typeof condition.value === 'string' && !condition.value.match(/^[0-9a-f-]{36}$/i)) {
|
|
128
|
+
throw new Error(`Field "payee" expects a UUID, but got "${condition.value}". ` +
|
|
129
|
+
`Use "imported_payee" for text matching instead.`);
|
|
130
|
+
}
|
|
131
|
+
if (['account', 'category'].includes(condition.field) && typeof condition.value === 'string' && !condition.value.match(/^[0-9a-f-]{36}$/i)) {
|
|
132
|
+
throw new Error(`Field "${condition.field}" expects a UUID, but got text "${condition.value}".`);
|
|
133
|
+
}
|
|
134
|
+
if (['oneOf', 'notOneOf'].includes(condition.op) && !Array.isArray(condition.value)) {
|
|
135
|
+
throw new Error(`Operator "${condition.op}" expects an array of values.`);
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
// ── Validate actions ──
|
|
139
|
+
for (const action of input.actions) {
|
|
140
|
+
if (action.op === 'set' && !action.field) {
|
|
141
|
+
throw new Error('Action with op="set" requires a "field" property.');
|
|
142
|
+
}
|
|
143
|
+
if (action.op === 'set' && action.field) {
|
|
144
|
+
for (const idField of ['category', 'payee', 'account']) {
|
|
145
|
+
if (action.field === idField && typeof action.value === 'string' && !action.value.match(/^[0-9a-f-]{36}$/i)) {
|
|
146
|
+
throw new Error(`Action field "${idField}" expects a UUID, but got "${action.value}".`);
|
|
148
147
|
}
|
|
149
148
|
}
|
|
150
|
-
|
|
151
|
-
|
|
149
|
+
}
|
|
150
|
+
if (['append-notes', 'prepend-notes'].includes(action.op) && typeof action.value !== 'string') {
|
|
151
|
+
throw new Error(`Action "${action.op}" requires a string value.`);
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
// Fetch existing rules and either update (matched) or create (no match) inside
|
|
155
|
+
// ONE withWriteSession cycle so the read and the write share a single lock
|
|
156
|
+
// acquisition (#142).
|
|
157
|
+
return await adapter.withWriteSession(async () => {
|
|
158
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
159
|
+
const existingRules = await rawGetRules();
|
|
160
|
+
let matchedRule = null;
|
|
161
|
+
for (const rule of existingRules) {
|
|
162
|
+
const r = rule;
|
|
163
|
+
if (!r.id || typeof r.id !== 'string')
|
|
164
|
+
continue;
|
|
165
|
+
const existingConditions = Array.isArray(r.conditions) ? r.conditions : [];
|
|
166
|
+
const existingConditionsOp = r.conditionsOp || 'and';
|
|
167
|
+
if (conditionsMatch(existingConditions, existingConditionsOp, input.conditions, input.conditionsOp)) {
|
|
168
|
+
matchedRule = r;
|
|
169
|
+
break;
|
|
152
170
|
}
|
|
153
171
|
}
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
172
|
+
const ruleData = JSON.parse(JSON.stringify(input)); // deep clone for API call
|
|
173
|
+
if (matchedRule) {
|
|
174
|
+
// UPDATE existing rule. The Actual Budget API expects the FULL merged rule
|
|
175
|
+
// object passed as a single argument (matches adapter.updateRule's behaviour).
|
|
158
176
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
159
|
-
const
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
matchedRule = r;
|
|
169
|
-
break;
|
|
170
|
-
}
|
|
171
|
-
}
|
|
172
|
-
const ruleData = JSON.parse(JSON.stringify(input)); // deep clone for API call
|
|
173
|
-
if (matchedRule) {
|
|
174
|
-
// UPDATE existing rule. The Actual Budget API expects the FULL merged rule
|
|
175
|
-
// object passed as a single argument (matches adapter.updateRule's behaviour).
|
|
176
|
-
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
177
|
-
const merged = {
|
|
178
|
-
id: matchedRule.id,
|
|
179
|
-
stage: ruleData.stage ?? matchedRule.stage,
|
|
180
|
-
conditionsOp: ruleData.conditionsOp ?? matchedRule.conditionsOp,
|
|
181
|
-
conditions: ruleData.conditions ?? matchedRule.conditions ?? [],
|
|
182
|
-
actions: ruleData.actions ?? matchedRule.actions ?? [],
|
|
183
|
-
};
|
|
184
|
-
await rawUpdateRule(merged);
|
|
185
|
-
return { id: matchedRule.id, created: false };
|
|
186
|
-
}
|
|
187
|
-
else {
|
|
188
|
-
// CREATE new rule
|
|
189
|
-
const rawId = await rawCreateRule(ruleData);
|
|
190
|
-
return { id: normalizeToId(rawId), created: true };
|
|
191
|
-
}
|
|
192
|
-
});
|
|
193
|
-
}
|
|
194
|
-
catch (error) {
|
|
195
|
-
if (error instanceof z.ZodError) {
|
|
196
|
-
const fieldErrors = error.issues.map((e) => `${e.path.join('.')}: ${e.message}`).join('; ');
|
|
197
|
-
throw new Error(`Invalid rule data: ${fieldErrors}`);
|
|
177
|
+
const merged = {
|
|
178
|
+
id: matchedRule.id,
|
|
179
|
+
stage: ruleData.stage ?? matchedRule.stage,
|
|
180
|
+
conditionsOp: ruleData.conditionsOp ?? matchedRule.conditionsOp,
|
|
181
|
+
conditions: ruleData.conditions ?? matchedRule.conditions ?? [],
|
|
182
|
+
actions: ruleData.actions ?? matchedRule.actions ?? [],
|
|
183
|
+
};
|
|
184
|
+
await rawUpdateRule(merged);
|
|
185
|
+
return { id: matchedRule.id, created: false };
|
|
198
186
|
}
|
|
199
|
-
|
|
200
|
-
|
|
187
|
+
else {
|
|
188
|
+
// CREATE new rule
|
|
189
|
+
const rawId = await rawCreateRule(ruleData);
|
|
190
|
+
return { id: normalizeToId(rawId), created: true };
|
|
191
|
+
}
|
|
192
|
+
});
|
|
201
193
|
},
|
|
202
194
|
};
|
|
203
195
|
export default tool;
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "actual-mcp-server",
|
|
3
3
|
"displayName": "Actual MCP Server",
|
|
4
|
-
"version": "0.6.
|
|
4
|
+
"version": "0.6.48",
|
|
5
5
|
"engines": {
|
|
6
6
|
"node": ">=20.0.0",
|
|
7
7
|
"npm": ">=10.0.0"
|
|
@@ -30,7 +30,7 @@
|
|
|
30
30
|
"verify-tools": "npm run build && node scripts/verify-tools.js",
|
|
31
31
|
"check:coverage": "node scripts/list-actual-api-methods.mjs",
|
|
32
32
|
"direct-sync": "node scripts/direct-sync/bank-sync-direct.mjs",
|
|
33
|
-
"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/config_https_validation.test.js && node tests/unit/config_insecure_upstream.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/budgets_transfer.test.js && node tests/unit/transactions_uncategorized.test.js && node tests/unit/httpServer_session_init.test.js && node tests/unit/httpServer_session_not_found.test.js && node tests/unit/manual_mcp_client_retry.test.js && node tests/unit/manual_mcp_client_session.test.js && node tests/unit/manual_mcp_client_circuit.test.js && node tests/unit/manual_runner_killswitch.test.js && node tests/unit/adapter_auth_rate_limit.test.js && node tests/unit/adapter_session_reuse.test.js && node tests/unit/retry_classifier.test.js && node tests/unit/adapter_module_surface.test.js && node tests/unit/adapter_nonidempotent_no_retry.test.js && node tests/unit/pool_liveness.test.js && node tests/unit/pool_shutdown_all.test.js && node tests/unit/query_where_operators.test.js && node tests/unit/query_run_validation.test.js && node tests/unit/adapter_with_write_session.test.js && node tests/unit/category_groups_delete.test.js && node tests/unit/rules_delete.test.js && node tests/unit/schedules_delete.test.js && node tests/unit/payees_delete.test.js && node tests/unit/rules_create_or_update.test.js && node tests/unit/unhandled-rejection.test.js && node tests/unit/rejection-allowlist-purity.test.js && node tests/unit/httpServer_bearer_auth.test.js && node tests/unit/httpServer_oidc_audience.test.js && node tests/unit/httpServer_oidc_auth_verification.test.js && node tests/unit/httpServer_body_limit.test.js && node tests/unit/adapter_write_pool_cooperation.test.js && node tests/unit/budget_acl_enforcement.test.js && node tests/unit/budget_preference_store.test.js && node tests/unit/budget_preference_restore.test.js && node tests/unit/tags_list.test.js && node tests/unit/tags_create.test.js && node tests/unit/tags_update.test.js && node tests/unit/tags_delete.test.js && node tests/unit/notes_get.test.js && node tests/unit/notes_update.test.js && node tests/unit/payees_common_list.test.js && node tests/unit/transactions_update_guard.test.js && node tests/unit/entities_search.test.js && node tests/unit/tool_count_sync.test.js && node tests/unit/check_coverage.test.js",
|
|
33
|
+
"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/config_https_validation.test.js && node tests/unit/config_insecure_upstream.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/budgets_transfer.test.js && node tests/unit/transactions_uncategorized.test.js && node tests/unit/httpServer_session_init.test.js && node tests/unit/httpServer_session_not_found.test.js && node tests/unit/manual_mcp_client_retry.test.js && node tests/unit/manual_mcp_client_session.test.js && node tests/unit/manual_mcp_client_circuit.test.js && node tests/unit/manual_runner_killswitch.test.js && node tests/unit/adapter_auth_rate_limit.test.js && node tests/unit/adapter_session_reuse.test.js && node tests/unit/retry_classifier.test.js && node tests/unit/adapter_module_surface.test.js && node tests/unit/adapter_nonidempotent_no_retry.test.js && node tests/unit/pool_liveness.test.js && node tests/unit/pool_shutdown_all.test.js && node tests/unit/query_where_operators.test.js && node tests/unit/query_run_validation.test.js && node tests/unit/adapter_with_write_session.test.js && node tests/unit/category_groups_delete.test.js && node tests/unit/rules_delete.test.js && node tests/unit/schedules_delete.test.js && node tests/unit/payees_delete.test.js && node tests/unit/rules_create_or_update.test.js && node tests/unit/unhandled-rejection.test.js && node tests/unit/rejection-allowlist-purity.test.js && node tests/unit/httpServer_bearer_auth.test.js && node tests/unit/httpServer_oidc_audience.test.js && node tests/unit/httpServer_oidc_auth_verification.test.js && node tests/unit/httpServer_body_limit.test.js && node tests/unit/adapter_write_pool_cooperation.test.js && node tests/unit/budget_acl_enforcement.test.js && node tests/unit/budget_preference_store.test.js && node tests/unit/budget_preference_restore.test.js && node tests/unit/tags_list.test.js && node tests/unit/tags_create.test.js && node tests/unit/tags_update.test.js && node tests/unit/tags_delete.test.js && node tests/unit/notes_get.test.js && node tests/unit/notes_update.test.js && node tests/unit/payees_common_list.test.js && node tests/unit/transactions_update_guard.test.js && node tests/unit/entities_search.test.js && node tests/unit/zod_error_format.test.js && node tests/unit/tool_count_sync.test.js && node tests/unit/stdio_transport.test.js && node tests/unit/logger_structured.test.js && node tests/unit/logger_redaction.test.js && node tests/unit/logger_correlation.test.js && node tests/unit/check_coverage.test.js",
|
|
34
34
|
"test:adapter": "npm run build && node dist/src/tests_adapter_runner.js",
|
|
35
35
|
"test:e2e": "npx playwright test",
|
|
36
36
|
"test:e2e:docker": "./tests/e2e/run-docker-e2e.sh",
|