actual-mcp-server 0.22.44 → 0.22.47
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/src/auth/budget-acl-dynamic.js +4 -1
- package/dist/src/index.js +16 -4
- package/dist/src/lib/actual-adapter/query.js +37 -9
- package/dist/src/lib/actual-adapter.js +94 -29
- package/dist/src/lib/query-validator.js +6 -1
- package/dist/src/tests/testMcpClient.js +8 -4
- package/dist/src/tools/budgets_get_all.js +1 -1
- package/dist/src/tools/budgets_import.js +7 -8
- package/dist/src/tools/budgets_list_available.js +1 -1
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -836,7 +836,7 @@ The software is provided **as-is**, without warranty of any kind. The author acc
|
|
|
836
836
|
|
|
837
837
|
---
|
|
838
838
|
|
|
839
|
-
**Version:** 0.22.
|
|
839
|
+
**Version:** 0.22.47 | **Tool Count:** 84 (verified LibreChat-compatible)
|
|
840
840
|
|
|
841
841
|
## Sponsor
|
|
842
842
|
|
|
@@ -40,7 +40,10 @@
|
|
|
40
40
|
// point runs `_enforceBudgetAcl`, which refuses when the request context has no
|
|
41
41
|
// allowedBudgets. Resolving the ACL would therefore be blocked by the ACL it is
|
|
42
42
|
// trying to compute. We break that circularity by running ONLY this one call
|
|
43
|
-
// inside `requestContext.run({ allowedBudgets: ['*'] }, ...)`.
|
|
43
|
+
// inside `requestContext.run({ allowedBudgets: ['*'] }, ...)`. Since #570 the
|
|
44
|
+
// wildcard matters twice: `getBudgets()` also FILTERS its result to the context's
|
|
45
|
+
// allowedBudgets, so without it the resolver would see only the files the
|
|
46
|
+
// caller's (not yet computed) list already permits.
|
|
44
47
|
//
|
|
45
48
|
// That is sound rather than a loophole: `getBudgets()` lists budget FILES
|
|
46
49
|
// (name, id, who may access them). It reads no budget CONTENTS: no accounts, no
|
package/dist/src/index.js
CHANGED
|
@@ -105,7 +105,9 @@ const KNOWN_FLAGS = new Set([
|
|
|
105
105
|
'--http', '--stdio', '--test-actual-connection', '--test-mcp-client',
|
|
106
106
|
'--debug', '--help', '-h', '--version', '-v',
|
|
107
107
|
]);
|
|
108
|
-
|
|
108
|
+
// A bare `--` is the conventional end-of-options marker that `node script.js -- --flag` passes
|
|
109
|
+
// through (the npm `dev` and `test:mcp-client` launchers did, #565); it is not an option.
|
|
110
|
+
const unknownFlags = argsEarly.filter(a => a.startsWith('-') && a !== '--' && !KNOWN_FLAGS.has(a));
|
|
109
111
|
if (unknownFlags.length > 0) {
|
|
110
112
|
console.error(`Unknown option: ${unknownFlags.join(', ')}\nRun \`actual-mcp-server --help\` for usage.`);
|
|
111
113
|
process.exit(1);
|
|
@@ -314,13 +316,23 @@ Docs & source: https://github.com/agigante80/actual-mcp-server`);
|
|
|
314
316
|
// If requested, run the MCP client-side tests now that all variables are ready
|
|
315
317
|
if (useTestMcpClient) {
|
|
316
318
|
logger.info('⚙️ --test-mcp-client specified, starting HTTP server and running client-side MCP tests...');
|
|
317
|
-
// start server in http mode. Bind all interfaces by default (#239)
|
|
318
|
-
// uses the LAN IP (getLocalIp), so the self-test client must be able to reach it.
|
|
319
|
+
// start server in http mode. Bind all interfaces by default (#239).
|
|
319
320
|
await startHttpServer(mcp, PORT, HTTP_PATH, capabilities, implementedTools, SERVER_DESCRIPTION, serverInstructionsText, toolSchemas, version, bindHost, advertisedUrl);
|
|
320
321
|
// dynamic import to avoid circular at top
|
|
321
322
|
const { testMcpClient } = await import('./tests/testMcpClient.js');
|
|
322
323
|
try {
|
|
323
|
-
|
|
324
|
+
// #565: the self-test talks to the address this process bound, not advertisedUrl. The
|
|
325
|
+
// advertised LAN IP can be answered by another listener on the same port (a Docker
|
|
326
|
+
// container publishing it is NATed ahead of local sockets), which turned the run into a
|
|
327
|
+
// 401 from a different server. A wildcard bind is reached on loopback, '::' on '::1' (an
|
|
328
|
+
// IPv6-only socket refuses IPv4). The scheme is the listener's, not the advertised one
|
|
329
|
+
// (MCP_BRIDGE_PUBLIC_SCHEME and MCP_BRIDGE_USE_TLS only change what is advertised).
|
|
330
|
+
// Under native TLS the advertised host is kept: the certificate names it, not 127.0.0.1.
|
|
331
|
+
const nativeTls = process.env.MCP_ENABLE_HTTPS === 'true';
|
|
332
|
+
const loopback = bindHost === '0.0.0.0' ? '127.0.0.1' : bindHost === '::' ? '::1' : bindHost;
|
|
333
|
+
const selfHost = nativeTls ? advertisedHost : loopback;
|
|
334
|
+
const selfUrl = `${nativeTls ? 'https' : 'http'}://${selfHost.includes(':') ? `[${selfHost}]` : selfHost}:${PORT}${HTTP_PATH}`;
|
|
335
|
+
await testMcpClient(selfUrl, HTTP_PATH);
|
|
324
336
|
logger.info('✅ MCP client-side tests passed');
|
|
325
337
|
process.exit(0);
|
|
326
338
|
}
|
|
@@ -48,6 +48,15 @@ function _splitInList(valuesStr) {
|
|
|
48
48
|
/** A single-quoted literal whose internal quotes are SQL-ESCAPED by doubling.
|
|
49
49
|
* `'McDonald''s'` matches; `'McDonald's'` (a lone quote) does not. */
|
|
50
50
|
const SQL_ESCAPED_SINGLE_QUOTED = /^'(?:[^']|'')*'$/;
|
|
51
|
+
// ActualQL's value compiler escapes comparison and LIKE strings for SQLite, so SQL's doubled
|
|
52
|
+
// apostrophe must be decoded first. IN uses a separate path that deliberately preserves it.
|
|
53
|
+
function _stripWhereSqlLiteral(s) {
|
|
54
|
+
const t = s.trim();
|
|
55
|
+
if (SQL_ESCAPED_SINGLE_QUOTED.test(t)) {
|
|
56
|
+
return t.slice(1, -1).replace(/''/g, "'");
|
|
57
|
+
}
|
|
58
|
+
return _stripWhereQuotes(s);
|
|
59
|
+
}
|
|
51
60
|
function _isSafeInListElement(raw) {
|
|
52
61
|
const t = raw.trim();
|
|
53
62
|
// #433: a single-quoted element may now carry DOUBLED internal quotes. That is
|
|
@@ -83,11 +92,11 @@ function _coerceInListValue(s) {
|
|
|
83
92
|
}
|
|
84
93
|
return _coerceWhereValue(s);
|
|
85
94
|
}
|
|
86
|
-
// Coerce a
|
|
87
|
-
//
|
|
88
|
-
//
|
|
95
|
+
// Coerce a comparison operand (and an IN element with no doubled quote) to a number when it
|
|
96
|
+
// looks numeric, else keep the unquoted string. Empty stays a string so an empty literal is not
|
|
97
|
+
// turned into 0.
|
|
89
98
|
function _coerceWhereValue(s) {
|
|
90
|
-
const v =
|
|
99
|
+
const v = _stripWhereSqlLiteral(s);
|
|
91
100
|
if (v === '')
|
|
92
101
|
return v;
|
|
93
102
|
const n = Number(v);
|
|
@@ -158,7 +167,7 @@ export function parseWhereClause(query, whereClause, tableName) {
|
|
|
158
167
|
const notLikeMatch = trimmedCondition.match(/^([\w.]+)\s+NOT\s+LIKE\s+(.+)$/i);
|
|
159
168
|
if (notLikeMatch) {
|
|
160
169
|
const [, field, valueStr] = notLikeMatch;
|
|
161
|
-
query = query.filter({ [field]: { $notlike:
|
|
170
|
+
query = query.filter({ [field]: { $notlike: _stripWhereSqlLiteral(valueStr) } });
|
|
162
171
|
continue;
|
|
163
172
|
}
|
|
164
173
|
// LIKE: pattern match. ActualQL's $like runs through NORMALISE + UNICODE_LIKE,
|
|
@@ -167,7 +176,7 @@ export function parseWhereClause(query, whereClause, tableName) {
|
|
|
167
176
|
const likeMatch = trimmedCondition.match(/^([\w.]+)\s+LIKE\s+(.+)$/i);
|
|
168
177
|
if (likeMatch) {
|
|
169
178
|
const [, field, valueStr] = likeMatch;
|
|
170
|
-
query = query.filter({ [field]: { $like:
|
|
179
|
+
query = query.filter({ [field]: { $like: _stripWhereSqlLiteral(valueStr) } });
|
|
171
180
|
continue;
|
|
172
181
|
}
|
|
173
182
|
// IN clause: field IN (value1, value2, ...)
|
|
@@ -221,7 +230,25 @@ export function parseWhereClause(query, whereClause, tableName) {
|
|
|
221
230
|
// operators are left to their normal path, where ActualQL will reject them for a boolean as
|
|
222
231
|
// it does today, rather than us inventing an ordering the column does not have.
|
|
223
232
|
const isBool = _isBooleanField(tableName, field) && (actualOp === '$eq' || actualOp === '$ne');
|
|
224
|
-
const
|
|
233
|
+
const isText = tableName !== undefined && getFieldType(tableName, field) === 'string';
|
|
234
|
+
// #567 review: a quoted number stays a string on a text column. Coercing `notes = '123'` to
|
|
235
|
+
// the number 123 made upstream throw "Can't convert integer to string".
|
|
236
|
+
const finalValue = isBool
|
|
237
|
+
? _coerceBoolean(field, valueStr)
|
|
238
|
+
: isText ? _stripWhereSqlLiteral(valueStr) : _coerceWhereValue(valueStr);
|
|
239
|
+
// #567 review: upstream's val() (aql/compiler.ts) re-escapes a value only when it casts to
|
|
240
|
+
// type `string`. A value compared against an `id` column is inlined as `'${value}'` with NO
|
|
241
|
+
// escaping, so a quote in it closes the literal: `account = 'x'||(SELECT ...)||'x'` ran the
|
|
242
|
+
// subquery, past both validators, and decoding `''` made the doubled form do the same. A
|
|
243
|
+
// quote is therefore allowed only on a column our schema types as `string` (checked against
|
|
244
|
+
// upstream's schema: every one of them is `string` there, or does not exist and fails to
|
|
245
|
+
// compile). Any other or unknown type refuses it, which is fail closed: no id, date, number
|
|
246
|
+
// or boolean legitimately contains a single quote. LIKE and NOT LIKE always cast to string,
|
|
247
|
+
// so they need no such check.
|
|
248
|
+
if (!isText && typeof finalValue === 'string' && finalValue.includes("'")) {
|
|
249
|
+
throw new Error(`Invalid value for column "${field}": a single quote is only allowed in a value ` +
|
|
250
|
+
`compared against a text column (for example payee.name = 'O''Brien').`);
|
|
251
|
+
}
|
|
225
252
|
if (actualOp === '$eq') {
|
|
226
253
|
// Simple equality can use the direct field: value shorthand.
|
|
227
254
|
query = query.filter({ [field]: finalValue });
|
|
@@ -252,8 +279,9 @@ const NAME_CONDITION_TABLES = {
|
|
|
252
279
|
* `NOT LIKE` are skipped: an unknown name there cannot make the result empty. Each condition is
|
|
253
280
|
* ONE lookup, an IN list included (the ticket allows one extra read per name condition), and the
|
|
254
281
|
* lookup reuses the main query's operator and value, so a `$oneof` element compiles exactly as in
|
|
255
|
-
* the main query (where upstream inlines the doubled-quote form #433 keeps).
|
|
256
|
-
*
|
|
282
|
+
* the main query (where upstream inlines the doubled-quote form #433 keeps). The three name
|
|
283
|
+
* columns are text, so since #567 a numeric-looking `=` value arrives as a string; a number is
|
|
284
|
+
* still accepted (an IN element can be one) and kept as is.
|
|
257
285
|
*/
|
|
258
286
|
export function nameConditionLookups(filterExpressions) {
|
|
259
287
|
const out = [];
|
|
@@ -379,9 +379,7 @@ function abortBatchOnInfrastructureError(error, ctx) {
|
|
|
379
379
|
* Claude Desktop / Claude Code compatibility.
|
|
380
380
|
*/
|
|
381
381
|
function _enforceBudgetAcl(toolName) {
|
|
382
|
-
const
|
|
383
|
-
const sessionId = store?.sessionId;
|
|
384
|
-
const allowedBudgets = store?.allowedBudgets;
|
|
382
|
+
const sessionId = requestContext.getStore()?.sessionId;
|
|
385
383
|
// Trusted-local short-circuit. stdio and startup health checks run with no
|
|
386
384
|
// sessionId; in non-OIDC modes those are by-construction trusted (single
|
|
387
385
|
// user, local process). The ACL only applies when an authenticated multi-
|
|
@@ -389,20 +387,8 @@ function _enforceBudgetAcl(toolName) {
|
|
|
389
387
|
if (!sessionId && config.AUTH_PROVIDER !== 'oidc') {
|
|
390
388
|
return;
|
|
391
389
|
}
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
if (config.AUTH_PROVIDER === 'oidc' && !allowedBudgets) {
|
|
395
|
-
logger.warn(JSON.stringify({
|
|
396
|
-
event: 'acl_denied',
|
|
397
|
-
reason: 'no_allowed_budgets_in_context',
|
|
398
|
-
sessionId: sessionId ?? null,
|
|
399
|
-
tool: toolName ?? null,
|
|
400
|
-
}));
|
|
401
|
-
throw new Error('Budget ACL: no allowedBudgets in request context. ' +
|
|
402
|
-
'This request bypassed the budget-acl middleware. Refusing for safety. See #156.');
|
|
403
|
-
}
|
|
404
|
-
// No restriction.
|
|
405
|
-
if (!allowedBudgets || allowedBudgets.includes('*'))
|
|
390
|
+
const allowedBudgets = restrictedAllowedBudgets(toolName);
|
|
391
|
+
if (!allowedBudgets)
|
|
406
392
|
return;
|
|
407
393
|
const target = getActiveBudgetConfig();
|
|
408
394
|
if (!allowedBudgets.includes(target.syncId)) {
|
|
@@ -414,9 +400,46 @@ function _enforceBudgetAcl(toolName) {
|
|
|
414
400
|
sessionId: sessionId ?? null,
|
|
415
401
|
tool: toolName ?? null,
|
|
416
402
|
}));
|
|
417
|
-
|
|
403
|
+
// #570: the name and syncId stay in the log above, never in the caller-facing
|
|
404
|
+
// text. With no selection the active budget is the registry's first entry, so
|
|
405
|
+
// naming it told a principal allowed only another budget what the default was.
|
|
406
|
+
throw new Error("Budget ACL: the active budget is not in this session's allowedBudgets. " +
|
|
407
|
+
'Call actual_budgets_list_available and switch to a permitted budget.');
|
|
418
408
|
}
|
|
419
409
|
}
|
|
410
|
+
/**
|
|
411
|
+
* The request's budget allow-list when the caller is RESTRICTED, else `undefined` (#570).
|
|
412
|
+
*
|
|
413
|
+
* Shared by `_enforceBudgetAcl` (the active budget) and the paths that check nothing
|
|
414
|
+
* about the active budget: the two listings, the import and the switch's not-found
|
|
415
|
+
* list. One decision, so the two kinds of check cannot drift apart.
|
|
416
|
+
*
|
|
417
|
+
* - `AUTH_PROVIDER=oidc` with no `allowedBudgets` in context: the request slipped
|
|
418
|
+
* past the middleware, so throw (fail closed), with an `acl_denied` warn.
|
|
419
|
+
* - a list containing `"*"`, or no list outside OIDC (bearer, no-auth, stdio, CLI,
|
|
420
|
+
* startup): unrestricted.
|
|
421
|
+
* - otherwise: the list.
|
|
422
|
+
*
|
|
423
|
+
* Keyed on the request's allow-list, not on AUTH_PROVIDER, so any auth mode that
|
|
424
|
+
* attaches a list is restricted by it.
|
|
425
|
+
*/
|
|
426
|
+
function restrictedAllowedBudgets(toolName) {
|
|
427
|
+
const store = requestContext.getStore();
|
|
428
|
+
const allowedBudgets = store?.allowedBudgets;
|
|
429
|
+
if (config.AUTH_PROVIDER === 'oidc' && !allowedBudgets) {
|
|
430
|
+
logger.warn(JSON.stringify({
|
|
431
|
+
event: 'acl_denied',
|
|
432
|
+
reason: 'no_allowed_budgets_in_context',
|
|
433
|
+
sessionId: store?.sessionId ?? null,
|
|
434
|
+
tool: toolName ?? null,
|
|
435
|
+
}));
|
|
436
|
+
throw new Error('Budget ACL: no allowedBudgets in request context. ' +
|
|
437
|
+
'This request bypassed the budget-acl middleware. Refusing for safety. See #156.');
|
|
438
|
+
}
|
|
439
|
+
if (!allowedBudgets || allowedBudgets.includes('*'))
|
|
440
|
+
return undefined;
|
|
441
|
+
return allowedBudgets;
|
|
442
|
+
}
|
|
420
443
|
/**
|
|
421
444
|
* Helper to run an operation with the Actual API ready, deciding the lifecycle
|
|
422
445
|
* mode automatically:
|
|
@@ -3862,7 +3885,18 @@ export async function getBudgets() {
|
|
|
3862
3885
|
return withActualApi(async () => {
|
|
3863
3886
|
observability.incrementToolCall('actual.budgets.getAll').catch(() => { });
|
|
3864
3887
|
const raw = await withConcurrency(() => retry(() => rawGetBudgets(), { retries: 2, backoffMs: 200 }));
|
|
3865
|
-
|
|
3888
|
+
const files = Array.isArray(raw) ? raw : [];
|
|
3889
|
+
// #570: a restricted caller sees only files whose groupId (the sync id) it may
|
|
3890
|
+
// use. A file with no groupId was never uploaded and belongs to no list. The
|
|
3891
|
+
// dynamic ACL resolver calls this under its own `allowedBudgets: ['*']`, so it
|
|
3892
|
+
// still sees every file.
|
|
3893
|
+
const allowed = restrictedAllowedBudgets('actual_budgets_get_all');
|
|
3894
|
+
if (!allowed)
|
|
3895
|
+
return files;
|
|
3896
|
+
return files.filter(f => {
|
|
3897
|
+
const groupId = f?.groupId;
|
|
3898
|
+
return typeof groupId === 'string' && allowed.includes(groupId);
|
|
3899
|
+
});
|
|
3866
3900
|
});
|
|
3867
3901
|
}
|
|
3868
3902
|
/**
|
|
@@ -3899,12 +3933,18 @@ export async function switchBudget(name) {
|
|
|
3899
3933
|
}
|
|
3900
3934
|
const key = name.toLowerCase();
|
|
3901
3935
|
const found = budgetRegistry.get(key);
|
|
3902
|
-
|
|
3903
|
-
|
|
3904
|
-
|
|
3936
|
+
// #570: a restricted caller is told only about budgets it may use.
|
|
3937
|
+
const notFound = () => {
|
|
3938
|
+
const permitted = restrictedAllowedBudgets('actual_budgets_switch');
|
|
3939
|
+
const available = [...budgetRegistry.values()]
|
|
3940
|
+
.filter(b => !permitted || permitted.includes(b.syncId))
|
|
3941
|
+
.map(b => `"${b.name}"`).join(', ') || 'none permitted for this session';
|
|
3942
|
+
return new Error(`Budget "${name}" not found in configuration. ` +
|
|
3905
3943
|
`Available budgets: ${available}. ` +
|
|
3906
3944
|
`Add BUDGET_n_NAME/SYNC_ID/SERVER_URL vars to configure additional budgets.`);
|
|
3907
|
-
}
|
|
3945
|
+
};
|
|
3946
|
+
if (!found)
|
|
3947
|
+
throw notFound();
|
|
3908
3948
|
// ACL enforcement: the target budget must be in this session's allowedBudgets.
|
|
3909
3949
|
// OIDC mode: explicit ACL required. Non-OIDC: short-circuit allow (single-user).
|
|
3910
3950
|
if (config.AUTH_PROVIDER === 'oidc') {
|
|
@@ -3926,7 +3966,12 @@ export async function switchBudget(name) {
|
|
|
3926
3966
|
sessionId,
|
|
3927
3967
|
tool: 'actual_budgets_switch',
|
|
3928
3968
|
}));
|
|
3929
|
-
|
|
3969
|
+
// #570: a denied budget gets the SAME error as an unknown name, so guessing
|
|
3970
|
+
// names cannot confirm that a budget outside the list exists. The round 1
|
|
3971
|
+
// review accepted a distinct "Budget ACL:" refusal here; that was reversed
|
|
3972
|
+
// because hiding other budgets is the point of this ticket. The log above
|
|
3973
|
+
// keeps the syncId for the operator.
|
|
3974
|
+
throw notFound();
|
|
3930
3975
|
}
|
|
3931
3976
|
}
|
|
3932
3977
|
// #189 Phase 1: the principal's chosen budget is persisted at each commit
|
|
@@ -4033,10 +4078,13 @@ export function clearSessionBudgetState(sessionId) {
|
|
|
4033
4078
|
sessionBudgetState.delete(sessionId);
|
|
4034
4079
|
}
|
|
4035
4080
|
/**
|
|
4036
|
-
* Return
|
|
4081
|
+
* Return the configured budgets this caller may use (for actual_budgets_list_available).
|
|
4082
|
+
* A restricted caller sees only its permitted entries (#570); the startup registry log
|
|
4083
|
+
* reads the Map directly and is not filtered.
|
|
4037
4084
|
*/
|
|
4038
4085
|
export function getBudgetRegistry() {
|
|
4039
|
-
|
|
4086
|
+
const allowed = restrictedAllowedBudgets('actual_budgets_list_available');
|
|
4087
|
+
return [...budgetRegistry.values()].filter(b => !allowed || allowed.includes(b.syncId)).map(b => ({
|
|
4040
4088
|
name: b.name,
|
|
4041
4089
|
syncId: b.syncId,
|
|
4042
4090
|
serverUrl: b.serverUrl,
|
|
@@ -4237,11 +4285,28 @@ export async function exportBudget() {
|
|
|
4237
4285
|
* Side effect worth knowing at every call site: upstream `importBudget` LOADS the
|
|
4238
4286
|
* imported budget, so the session's active budget changes to the new file. It is
|
|
4239
4287
|
* therefore outside the `BUDGET_N_*` registry and outside whatever budget the ACL
|
|
4240
|
-
* resolved for this request. The
|
|
4241
|
-
*
|
|
4242
|
-
*
|
|
4288
|
+
* resolved for this request. The resulting budget is un-ACL'd because it did not
|
|
4289
|
+
* exist when the ACL was built, so a RESTRICTED caller is refused outright (#570),
|
|
4290
|
+
* before any lock or upstream call. Wildcard and unrestricted callers can still
|
|
4291
|
+
* import; the write queue also enforces the ACL on their active budget.
|
|
4292
|
+
*
|
|
4293
|
+
* Upstream creates a NEW cloud file for the import (fresh id, best-effort upload),
|
|
4294
|
+
* so it never overwrites another budget on the server. The local copy is keyed by
|
|
4295
|
+
* the archive's own id, so an archive of budget X replaces X's LOCAL copy.
|
|
4243
4296
|
*/
|
|
4244
4297
|
export async function importBudget(input, opts = {}) {
|
|
4298
|
+
const restricted = restrictedAllowedBudgets('actual_budgets_import');
|
|
4299
|
+
if (restricted) {
|
|
4300
|
+
logger.warn(JSON.stringify({
|
|
4301
|
+
event: 'acl_denied',
|
|
4302
|
+
reason: 'import_by_restricted_principal',
|
|
4303
|
+
allowedBudgets: restricted,
|
|
4304
|
+
sessionId: requestContext.getStore()?.sessionId ?? null,
|
|
4305
|
+
tool: 'actual_budgets_import',
|
|
4306
|
+
}));
|
|
4307
|
+
throw new Error('Budget ACL: actual_budgets_import is not available to a session with a restricted ' +
|
|
4308
|
+
'allowedBudgets list: the imported budget would belong to no permitted list.');
|
|
4309
|
+
}
|
|
4245
4310
|
observability.incrementToolCall('actual.budgets.import').catch(() => { });
|
|
4246
4311
|
const result = await queueWriteOperation(async () => {
|
|
4247
4312
|
// #394: an import re-points the process-global singleton exactly as a download does, so it
|
|
@@ -175,7 +175,12 @@ function extractWhereFields(sql) {
|
|
|
175
175
|
// comparison operator, but also before IN / LIKE / IS / BETWEEN (with their NOT forms). Before
|
|
176
176
|
// this, `bogus_col IN ('a')`, `bogus_col LIKE '%a%'` and `bogus_col IS NULL` extracted no field
|
|
177
177
|
// and so skipped the allowlist check entirely, while `bogus_col = 'a'` was correctly rejected.
|
|
178
|
-
|
|
178
|
+
// #566: the IN / LIKE / BETWEEN alternative must not capture the keyword NOT as a field. After
|
|
179
|
+
// `(\w+)\.(\w+)` consumes a joined name such as `payee.name`, the scan resumes at ` NOT LIKE`,
|
|
180
|
+
// and without the `(?!NOT\s)` lookahead that alternative read `NOT` as a column and rejected a
|
|
181
|
+
// valid query with `Field "NOT" does not exist`. The lookahead is exact on purpose: it skips only
|
|
182
|
+
// the word NOT followed by whitespace, so every real column name is still extracted and checked.
|
|
183
|
+
const fieldMatches = whereClause.matchAll(/(\w+)\.(\w+)|(?:^|\s)(\w+)\s*[=<>!]|(?:^|\s)(?!NOT\s)(\w+)\s+(?:NOT\s+)?(?:IN|LIKE|BETWEEN)\b|(?:^|\s)(\w+)\s+IS\b/gi);
|
|
179
184
|
for (const match of fieldMatches) {
|
|
180
185
|
if (match[1] && match[2]) {
|
|
181
186
|
// table.field
|
|
@@ -1,9 +1,9 @@
|
|
|
1
|
-
export async function testMcpClient(
|
|
1
|
+
export async function testMcpClient(serverUrl, httpPath) {
|
|
2
2
|
// Try builtin fetch or fallback to node-fetch if necessary
|
|
3
3
|
const globalFetch = globalThis.fetch;
|
|
4
4
|
const fetchAny = globalFetch ?? (await import('node-fetch')).default;
|
|
5
5
|
const fetchFn = fetchAny;
|
|
6
|
-
const base = new URL(
|
|
6
|
+
const base = new URL(serverUrl);
|
|
7
7
|
// quick probe endpoint (#286: the server-info probe moved off the reserved
|
|
8
8
|
// /.well-known/oauth-protected-resource OAuth path to the non-reserved /mcp-info,
|
|
9
9
|
// now a bare object; the `result ?? raw` unwrap below tolerates both shapes).
|
|
@@ -104,8 +104,12 @@ export async function testMcpClient(advertisedUrl, _port, httpPath) {
|
|
|
104
104
|
headers: listHeaders,
|
|
105
105
|
body: JSON.stringify(listPayload),
|
|
106
106
|
});
|
|
107
|
-
if (!listRes.ok)
|
|
108
|
-
|
|
107
|
+
if (!listRes.ok) {
|
|
108
|
+
// #565: a 404 here is usually the session's Actual connection failing (bad credentials or no
|
|
109
|
+
// reachable server); the body names the cause (#438), so carry it rather than a bare status.
|
|
110
|
+
const body = await listRes.json().catch(() => undefined);
|
|
111
|
+
throw new Error(`tools/list failed: ${listRes.status}${body === undefined ? '' : ` ${JSON.stringify(body).slice(0, 300)}`}`);
|
|
112
|
+
}
|
|
109
113
|
const listJsonRaw = await listRes.json();
|
|
110
114
|
const listJsonObj = listJsonRaw && typeof listJsonRaw === 'object' ? listJsonRaw : undefined;
|
|
111
115
|
if (!listJsonObj || !('result' in listJsonObj))
|
|
@@ -3,7 +3,7 @@ import adapter from '../lib/actual-adapter.js';
|
|
|
3
3
|
const InputSchema = z.object({});
|
|
4
4
|
const tool = {
|
|
5
5
|
name: 'actual_budgets_get_all',
|
|
6
|
-
description: 'Get
|
|
6
|
+
description: 'Get the budget files on the server that this session may use. Useful for multi-budget management and discovering available budgets.',
|
|
7
7
|
inputSchema: InputSchema,
|
|
8
8
|
call: async (_args, _meta) => {
|
|
9
9
|
const result = await adapter.getBudgets();
|
|
@@ -7,13 +7,12 @@ import adapter from '../lib/actual-adapter.js';
|
|
|
7
7
|
* TWO SIDE EFFECTS EVERY CALLER MUST KNOW, both verified against `importActual` in
|
|
8
8
|
* the installed `@actual-app/api` and against a live server in the Docker E2E run:
|
|
9
9
|
*
|
|
10
|
-
* 1. IT CAN OVERWRITE AN EXISTING BUDGET. The budget
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
* "a new budget" when the archive's id is not already present.
|
|
10
|
+
* 1. IT CAN OVERWRITE AN EXISTING BUDGET'S LOCAL COPY. The local budget directory is
|
|
11
|
+
* keyed by the id the ARCHIVE carries, so re-importing an export of budget X
|
|
12
|
+
* replaces X's local data. On the SERVER it never overwrites anything (#570):
|
|
13
|
+
* `importActual` clears the cloud file and group ids and uploads the result as a
|
|
14
|
+
* NEW budget file (best effort; a failed upload leaves it local only).
|
|
15
|
+
* A session with a restricted budget ACL is refused this tool outright (#570).
|
|
17
16
|
* 2. IT CHANGES THE ACTIVE BUDGET. The imported budget is loaded, so the session
|
|
18
17
|
* switches to it. If the archive's id is not in the `BUDGET_N_*` registry, that
|
|
19
18
|
* budget is also outside whatever set the ACL resolved for this request, and
|
|
@@ -66,7 +65,7 @@ export default createTool({
|
|
|
66
65
|
name: 'actual_budgets_import',
|
|
67
66
|
description: 'Restore or import a budget from an Actual Budget ".zip" export or a YNAB4/YNAB5 export. ' +
|
|
68
67
|
'DESTRUCTIVE: the budget id comes from the archive, so re-importing an export REPLACES that ' +
|
|
69
|
-
'budget\'s
|
|
68
|
+
'budget\'s local copy, and the result is uploaded to the server as a new budget file. Never call this to "have a look at" a file. Confirm ' +
|
|
70
69
|
'with the user before importing over a budget that holds real data. ' +
|
|
71
70
|
'It also LOADS the imported budget, so the active budget for this session changes; if the archive\'s ' +
|
|
72
71
|
'budget is not in the configured list, call actual_budgets_list_available and actual_budgets_switch ' +
|
|
@@ -3,7 +3,7 @@ import adapter from '../lib/actual-adapter.js';
|
|
|
3
3
|
const InputSchema = z.object({});
|
|
4
4
|
const tool = {
|
|
5
5
|
name: 'actual_budgets_list_available',
|
|
6
|
-
description: 'List
|
|
6
|
+
description: 'List the pre-configured budgets this session may switch to (a session limited to some budgets sees only those). ' +
|
|
7
7
|
'Each entry shows the budget name, sync ID, server URL, and whether it uses E2E encryption. ' +
|
|
8
8
|
'Budgets are configured via BUDGET_n_NAME / BUDGET_n_SYNC_ID / BUDGET_n_SERVER_URL env vars. ' +
|
|
9
9
|
'Pass the budget name to actual_budgets_switch to change the active budget.',
|
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.22.
|
|
4
|
+
"version": "0.22.47",
|
|
5
5
|
"engines": {
|
|
6
6
|
"node": ">=22.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/transactions_aggregate.test.js && node tests/unit/account_flow_summary.test.js && node tests/unit/recurring_expenses_summary.test.js && node tests/unit/generated_tools.smoke.test.js &&node tests/unit/schema_validation.test.js && node tests/unit/schema_json_openai_compat.test.js && node tests/unit/config_https_validation.test.js && node tests/unit/config_data_dir.test.js && node tests/unit/config_drift.test.js && node tests/unit/port_alignment.test.js && node tests/unit/dockerfile_data_dir_alignment.test.js && node tests/unit/unraid_template_alignment.test.js && node tests/unit/entrypoint_invariants.test.js && node tests/unit/e2e_spec_collection.test.js && node tests/unit/compose_profile_sync.test.js && node tests/unit/advertised_tools_sync.test.js && node tests/unit/new_tool_completeness.test.js && node tests/unit/knip_config.test.js && node tests/unit/readme_stats_sync.test.js && node tests/unit/config_insecure_upstream.test.js && node tests/unit/workflow_release_guards.test.js && node tests/unit/report_train_failure.test.js && node tests/unit/unit_chain_membership.test.js && node tests/unit/api_surface_drift.test.js && node tests/unit/write_effect_audit_staleness.test.js && node tests/unit/write_effect_audit_membership.test.js && node tests/unit/tool_annotations.test.js && node tests/unit/tool_id_schema_drift.test.js && node tests/unit/filter_id_resolution.test.js && node tests/unit/filter_id_tool_wiring.test.js && node tests/unit/api_coverage_baseline.test.js && node tests/unit/train_preflight.test.js && node tests/unit/verify_published_artifacts.test.js && node tests/unit/report_train_stale.test.js && node tests/unit/stdio_framing_check.test.js && node tests/unit/server_info.test.js && node tests/unit/transfers_create.test.js && node tests/unit/bot_target_branch.test.js && node tests/unit/auth_posture.test.js && node tests/unit/auth_posture_wiring.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/adapter_set_budget_batch.test.js && node tests/unit/budget_updates_batch.test.js && node tests/unit/rules_create.test.js && node tests/unit/adapter_rule_reference_guard.test.js && node tests/unit/rules_create_batch.test.js && node tests/unit/budgets_hold_for_next_month.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/httpServer_init_failure_report.test.js && node tests/unit/stdio_init_failure_report.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/accounts_delete.test.js && node tests/unit/update_tools_not_found.test.js && node tests/unit/accounts_close.test.js && node tests/unit/accounts_reopen.test.js && node tests/unit/payees_delete.test.js && node tests/unit/payees_merge.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/session_ownership.test.js && node tests/unit/httpServer_oidc_audience.test.js && node tests/unit/httpServer_oidc_auth_verification.test.js && node tests/unit/httpServer_oidc_jwks_discovery.test.js && node tests/unit/oidc_resource_identifier.test.js && node tests/unit/httpServer_oidc_resource_metadata.test.js && node tests/unit/oidc_error_cause.test.js && node tests/unit/httpServer_oidc_error_body.test.js && node tests/unit/oidc_token_source.test.js && node tests/unit/config_oidc_assertion_refines.test.js && node tests/unit/httpServer_oidc_assertion_mode.test.js && node tests/unit/httpServer_body_limit.test.js && node tests/unit/httpErrorHandler.test.js && node tests/unit/httpServer_error_handler_wiring.test.js && node tests/unit/httpServer_trust_proxy.test.js && node tests/unit/httpServer_batch_cap.test.js && node tests/unit/dotenv_stdout_quiet.test.js && node tests/unit/httpServer_bind_host.test.js && node tests/unit/adapter_write_pool_cooperation.test.js && node tests/unit/adapter_drain_listing_cache.test.js && node tests/unit/budget_acl_enforcement.test.js && node tests/unit/budget_selection_precondition.test.js && node tests/unit/budget_load_abandonment.test.js && node tests/unit/budget_loader_postcondition.test.js && node tests/unit/budget_loader_import_allowlist.test.js && node tests/unit/budget_import_tracking.test.js && node tests/unit/pool_shutdown_under_lock.test.js && node tests/unit/api_lock_affinity.test.js && node tests/unit/commit_message_closing_keyword.test.js && node tests/unit/write_queue_stranding.test.js && node tests/unit/write_queue_stranded_bound.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/transactions_update_splits.test.js && node tests/unit/transactions_update_split_convert.test.js && node tests/unit/entities_search.test.js && node tests/unit/transactions_import_schema.test.js && node tests/unit/tool_schema_shape.test.js && node tests/unit/zod_error_format.test.js && node tests/unit/transactions_update_batch.test.js && node tests/unit/tool_count_sync.test.js && node tests/unit/stdio_transport.test.js && node tests/unit/stdio_budget_switch.test.js && node tests/unit/logger_structured.test.js && node tests/unit/logger_redaction.test.js && node tests/unit/tool_content_logging.test.js && node tests/unit/adapter_error_logging.test.js && node tests/unit/logger_correlation.test.js && node tests/unit/check_coverage.test.js && node tests/unit/adapter_op_timeout.test.js && node tests/unit/adapter_write_queue_wakeup.test.js && node tests/unit/ci_workflow_guards.test.js && node tests/unit/node_version_guard.test.js && node tests/unit/node_version_drift.test.js && node tests/unit/playwright_version_drift.test.js && node tests/unit/observability_smoke.test.js && node tests/unit/residue_pattern.test.js && node tests/unit/batch_uncategorized_cleanup.test.js && node tests/unit/dual_transport_gate.test.js && node tests/unit/manual_assertions_use_helper.test.js && node tests/unit/server_version_guard.test.js && node tests/unit/server_version_probe_outside_op_timeout.test.js && node tests/unit/payee_rules_reference_filter.test.js && node tests/unit/oidc_metadata_discovery.test.js && node tests/unit/httpServer_oauth_metadata_route.test.js && node tests/unit/httpServer_mcp_info_route.test.js && node tests/unit/residue_sweep_balance_account.test.js && node tests/unit/tool_factory.test.js && node tests/unit/budget_registry_parse.test.js && node tests/unit/preferences_get.test.js && node tests/unit/budgets_export.test.js && node tests/unit/budgets_import.test.js && node tests/unit/budgets_import_syncid.test.js && node tests/unit/budget_acl_dynamic.test.js && node tests/unit/budget_acl_identity_map.test.js && node tests/unit/budget_acl_userinfo.test.js && node tests/unit/acl_e2e_fixture_contract.test.js && node tests/unit/budget_acl_middleware.test.js && node tests/unit/rules_stage.test.js && node tests/unit/oidc_scope_list.test.js && node tests/unit/get_context.test.js && node tests/unit/contributor_docs.test.js && node tests/unit/toolsets.test.js && node tests/unit/toolsets_entrypoint.test.js && node tests/unit/tools_list_size_budget.test.js && node tests/unit/rules_update_schedules_schema.test.js && node tests/unit/schedules_update_amount_guard.test.js && node tests/unit/source_text_helper.test.js && node tests/unit/transport_onerror.test.js && node tests/unit/adapter_transaction_batch_abort.test.js && node tests/unit/transactions_merge_adapter.test.js && node tests/unit/schedules_dead_conditions.test.js && node tests/unit/session_ownership_express.test.js && node tests/unit/pool_orphan_sweep.test.js && node tests/unit/session_budget_state_cleanup.test.js && node tests/unit/http_session_delete.test.js && node tests/unit/budget_acl_parse.test.js && node tests/unit/server_instructions.test.js && node tests/unit/query_run_empty_hint.test.js",
|
|
33
|
+
"test:unit-js": "node tests/unit/transactions_create.test.js && node tests/unit/transactions_aggregate.test.js && node tests/unit/account_flow_summary.test.js && node tests/unit/recurring_expenses_summary.test.js && node tests/unit/generated_tools.smoke.test.js &&node tests/unit/schema_validation.test.js && node tests/unit/schema_json_openai_compat.test.js && node tests/unit/config_https_validation.test.js && node tests/unit/config_data_dir.test.js && node tests/unit/config_drift.test.js && node tests/unit/port_alignment.test.js && node tests/unit/dockerfile_data_dir_alignment.test.js && node tests/unit/unraid_template_alignment.test.js && node tests/unit/entrypoint_invariants.test.js && node tests/unit/e2e_spec_collection.test.js && node tests/unit/compose_profile_sync.test.js && node tests/unit/advertised_tools_sync.test.js && node tests/unit/new_tool_completeness.test.js && node tests/unit/knip_config.test.js && node tests/unit/readme_stats_sync.test.js && node tests/unit/config_insecure_upstream.test.js && node tests/unit/workflow_release_guards.test.js && node tests/unit/report_train_failure.test.js && node tests/unit/unit_chain_membership.test.js && node tests/unit/api_surface_drift.test.js && node tests/unit/write_effect_audit_staleness.test.js && node tests/unit/write_effect_audit_membership.test.js && node tests/unit/tool_annotations.test.js && node tests/unit/tool_id_schema_drift.test.js && node tests/unit/filter_id_resolution.test.js && node tests/unit/filter_id_tool_wiring.test.js && node tests/unit/api_coverage_baseline.test.js && node tests/unit/train_preflight.test.js && node tests/unit/verify_published_artifacts.test.js && node tests/unit/report_train_stale.test.js && node tests/unit/stdio_framing_check.test.js && node tests/unit/server_info.test.js && node tests/unit/transfers_create.test.js && node tests/unit/bot_target_branch.test.js && node tests/unit/auth_posture.test.js && node tests/unit/auth_posture_wiring.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/adapter_set_budget_batch.test.js && node tests/unit/budget_updates_batch.test.js && node tests/unit/rules_create.test.js && node tests/unit/adapter_rule_reference_guard.test.js && node tests/unit/rules_create_batch.test.js && node tests/unit/budgets_hold_for_next_month.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/httpServer_init_failure_report.test.js && node tests/unit/stdio_init_failure_report.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/accounts_delete.test.js && node tests/unit/update_tools_not_found.test.js && node tests/unit/accounts_close.test.js && node tests/unit/accounts_reopen.test.js && node tests/unit/payees_delete.test.js && node tests/unit/payees_merge.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/session_ownership.test.js && node tests/unit/httpServer_oidc_audience.test.js && node tests/unit/httpServer_oidc_auth_verification.test.js && node tests/unit/httpServer_oidc_jwks_discovery.test.js && node tests/unit/oidc_resource_identifier.test.js && node tests/unit/httpServer_oidc_resource_metadata.test.js && node tests/unit/oidc_error_cause.test.js && node tests/unit/httpServer_oidc_error_body.test.js && node tests/unit/oidc_token_source.test.js && node tests/unit/config_oidc_assertion_refines.test.js && node tests/unit/httpServer_oidc_assertion_mode.test.js && node tests/unit/httpServer_body_limit.test.js && node tests/unit/httpErrorHandler.test.js && node tests/unit/httpServer_error_handler_wiring.test.js && node tests/unit/httpServer_trust_proxy.test.js && node tests/unit/httpServer_batch_cap.test.js && node tests/unit/dotenv_stdout_quiet.test.js && node tests/unit/httpServer_bind_host.test.js && node tests/unit/adapter_write_pool_cooperation.test.js && node tests/unit/adapter_drain_listing_cache.test.js && node tests/unit/budget_acl_enforcement.test.js && node tests/unit/budget_acl_listing.test.js && node tests/unit/budget_selection_precondition.test.js && node tests/unit/budget_load_abandonment.test.js && node tests/unit/budget_loader_postcondition.test.js && node tests/unit/budget_loader_import_allowlist.test.js && node tests/unit/budget_import_tracking.test.js && node tests/unit/pool_shutdown_under_lock.test.js && node tests/unit/api_lock_affinity.test.js && node tests/unit/commit_message_closing_keyword.test.js && node tests/unit/write_queue_stranding.test.js && node tests/unit/write_queue_stranded_bound.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/transactions_update_splits.test.js && node tests/unit/transactions_update_split_convert.test.js && node tests/unit/entities_search.test.js && node tests/unit/transactions_import_schema.test.js && node tests/unit/tool_schema_shape.test.js && node tests/unit/zod_error_format.test.js && node tests/unit/transactions_update_batch.test.js && node tests/unit/tool_count_sync.test.js && node tests/unit/stdio_transport.test.js && node tests/unit/stdio_budget_switch.test.js && node tests/unit/logger_structured.test.js && node tests/unit/logger_redaction.test.js && node tests/unit/tool_content_logging.test.js && node tests/unit/adapter_error_logging.test.js && node tests/unit/logger_correlation.test.js && node tests/unit/check_coverage.test.js && node tests/unit/adapter_op_timeout.test.js && node tests/unit/adapter_write_queue_wakeup.test.js && node tests/unit/ci_workflow_guards.test.js && node tests/unit/node_version_guard.test.js && node tests/unit/node_version_drift.test.js && node tests/unit/playwright_version_drift.test.js && node tests/unit/observability_smoke.test.js && node tests/unit/residue_pattern.test.js && node tests/unit/batch_uncategorized_cleanup.test.js && node tests/unit/dual_transport_gate.test.js && node tests/unit/manual_assertions_use_helper.test.js && node tests/unit/server_version_guard.test.js && node tests/unit/server_version_probe_outside_op_timeout.test.js && node tests/unit/payee_rules_reference_filter.test.js && node tests/unit/oidc_metadata_discovery.test.js && node tests/unit/httpServer_oauth_metadata_route.test.js && node tests/unit/httpServer_mcp_info_route.test.js && node tests/unit/residue_sweep_balance_account.test.js && node tests/unit/tool_factory.test.js && node tests/unit/budget_registry_parse.test.js && node tests/unit/preferences_get.test.js && node tests/unit/budgets_export.test.js && node tests/unit/budgets_import.test.js && node tests/unit/budgets_import_syncid.test.js && node tests/unit/budget_acl_dynamic.test.js && node tests/unit/budget_acl_identity_map.test.js && node tests/unit/budget_acl_userinfo.test.js && node tests/unit/acl_e2e_fixture_contract.test.js && node tests/unit/budget_acl_middleware.test.js && node tests/unit/rules_stage.test.js && node tests/unit/oidc_scope_list.test.js && node tests/unit/get_context.test.js && node tests/unit/contributor_docs.test.js && node tests/unit/toolsets.test.js && node tests/unit/toolsets_entrypoint.test.js && node tests/unit/tools_list_size_budget.test.js && node tests/unit/rules_update_schedules_schema.test.js && node tests/unit/schedules_update_amount_guard.test.js && node tests/unit/source_text_helper.test.js && node tests/unit/transport_onerror.test.js && node tests/unit/adapter_transaction_batch_abort.test.js && node tests/unit/transactions_merge_adapter.test.js && node tests/unit/schedules_dead_conditions.test.js && node tests/unit/session_ownership_express.test.js && node tests/unit/pool_orphan_sweep.test.js && node tests/unit/session_budget_state_cleanup.test.js && node tests/unit/http_session_delete.test.js && node tests/unit/budget_acl_parse.test.js && node tests/unit/server_instructions.test.js && node tests/unit/query_run_empty_hint.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",
|