actual-mcp-server 0.22.42 → 0.22.46
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/index.js +24 -10
- package/dist/src/lib/ActualMCPConnection.js +0 -1
- package/dist/src/lib/actual-adapter/query.js +95 -7
- package/dist/src/lib/actual-adapter.js +42 -2
- package/dist/src/lib/query-validator.js +6 -1
- package/dist/src/lib/serverInstructions.js +36 -0
- package/dist/src/server/httpServer.js +5 -9
- package/dist/src/server/stdioServer.js +2 -2
- package/dist/src/tests/testMcpClient.js +18 -25
- package/dist/src/tools/query_run.js +1 -0
- 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.46 | **Tool Count:** 84 (verified LibreChat-compatible)
|
|
840
840
|
|
|
841
841
|
## Sponsor
|
|
842
842
|
|
package/dist/src/index.js
CHANGED
|
@@ -9,6 +9,7 @@ import { getMcpServerVersion } from './lib/mcp-version.js';
|
|
|
9
9
|
import path from 'node:path';
|
|
10
10
|
import { z } from 'zod';
|
|
11
11
|
import { isKnownBenignRejection } from './lib/rejection-allowlist.js';
|
|
12
|
+
import { buildServerInstructions } from './lib/serverInstructions.js';
|
|
12
13
|
import { format } from 'node:util';
|
|
13
14
|
/*
|
|
14
15
|
* #277 / #541: this module's version comes from `getMcpServerVersion()` (src/lib/mcp-version.ts),
|
|
@@ -104,7 +105,9 @@ const KNOWN_FLAGS = new Set([
|
|
|
104
105
|
'--http', '--stdio', '--test-actual-connection', '--test-mcp-client',
|
|
105
106
|
'--debug', '--help', '-h', '--version', '-v',
|
|
106
107
|
]);
|
|
107
|
-
|
|
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));
|
|
108
111
|
if (unknownFlags.length > 0) {
|
|
109
112
|
console.error(`Unknown option: ${unknownFlags.join(', ')}\nRun \`actual-mcp-server --help\` for usage.`);
|
|
110
113
|
process.exit(1);
|
|
@@ -211,8 +214,6 @@ Docs & source: https://github.com/agigante80/actual-mcp-server`);
|
|
|
211
214
|
const useTestActualConnection = args.includes('--test-actual-connection');
|
|
212
215
|
const useTestMcpClient = args.includes('--test-mcp-client');
|
|
213
216
|
const SERVER_DESCRIPTION = 'Bridge MCP server exposing Actual finance API to LibreChat.';
|
|
214
|
-
const SERVER_INSTRUCTIONS = 'Welcome to the Actual MCP server. The tools listed here are only the ones currently confirmed and tested, ' +
|
|
215
|
-
'but the server can proxy any API call supported by Actual. As we expand coverage, more tools will be officially exposed.';
|
|
216
217
|
async function main() {
|
|
217
218
|
// Mutual exclusion — stdio and http are incompatible transports
|
|
218
219
|
if (useHttp && useStdio) {
|
|
@@ -234,6 +235,9 @@ Docs & source: https://github.com/agigante80/actual-mcp-server`);
|
|
|
234
235
|
// The PUBLISHED tools (#483). This is the one name list every tools/list path receives
|
|
235
236
|
// (HTTP SDK handler, the no-session LobeChat path, the expired-session shim, stdio).
|
|
236
237
|
const implementedTools = actualToolsManager.getPublishedToolNames();
|
|
238
|
+
// The initialize `instructions` (#560): built once from the published set and the same
|
|
239
|
+
// resolved policy, before any transport starts, so no transport can send a different text.
|
|
240
|
+
const serverInstructionsText = buildServerInstructions(implementedTools, actualToolsManager.getToolPolicy().readOnly);
|
|
237
241
|
// Extract schemas map with tool name → JSON schema
|
|
238
242
|
const toolSchemas = {};
|
|
239
243
|
for (const toolName of implementedTools) {
|
|
@@ -312,13 +316,23 @@ Docs & source: https://github.com/agigante80/actual-mcp-server`);
|
|
|
312
316
|
// If requested, run the MCP client-side tests now that all variables are ready
|
|
313
317
|
if (useTestMcpClient) {
|
|
314
318
|
logger.info('⚙️ --test-mcp-client specified, starting HTTP server and running client-side MCP tests...');
|
|
315
|
-
// start server in http mode. Bind all interfaces by default (#239)
|
|
316
|
-
|
|
317
|
-
await startHttpServer(mcp, PORT, HTTP_PATH, capabilities, implementedTools, SERVER_DESCRIPTION, SERVER_INSTRUCTIONS, toolSchemas, version, bindHost, advertisedUrl);
|
|
319
|
+
// start server in http mode. Bind all interfaces by default (#239).
|
|
320
|
+
await startHttpServer(mcp, PORT, HTTP_PATH, capabilities, implementedTools, SERVER_DESCRIPTION, serverInstructionsText, toolSchemas, version, bindHost, advertisedUrl);
|
|
318
321
|
// dynamic import to avoid circular at top
|
|
319
322
|
const { testMcpClient } = await import('./tests/testMcpClient.js');
|
|
320
323
|
try {
|
|
321
|
-
|
|
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);
|
|
322
336
|
logger.info('✅ MCP client-side tests passed');
|
|
323
337
|
process.exit(0);
|
|
324
338
|
}
|
|
@@ -343,13 +357,13 @@ Docs & source: https://github.com/agigante80/actual-mcp-server`);
|
|
|
343
357
|
logger.info(`• OAuth Required: false`);
|
|
344
358
|
logger.info(`• Capabilities: ${Object.keys(capabilities).join(', ')}`);
|
|
345
359
|
logger.info(`• Tools: ${implementedTools.join(', ') || 'none'}`);
|
|
346
|
-
logger.info(`• Server Instructions: ${
|
|
360
|
+
logger.info(`• Server Instructions: ${serverInstructionsText}`);
|
|
347
361
|
logger.info(`• MCP endpoint (advertised): ${advertisedUrl}`);
|
|
348
362
|
logger.info('---------');
|
|
349
363
|
}
|
|
350
364
|
if (useHttp) {
|
|
351
365
|
logger.info('Mode: HTTP');
|
|
352
|
-
await startHttpServer(mcp, PORT, HTTP_PATH, capabilities, implementedTools, SERVER_DESCRIPTION,
|
|
366
|
+
await startHttpServer(mcp, PORT, HTTP_PATH, capabilities, implementedTools, SERVER_DESCRIPTION, serverInstructionsText, toolSchemas, version,
|
|
353
367
|
// bind host (ensure server accepts connections on that interface)
|
|
354
368
|
bindHost,
|
|
355
369
|
// advertised URL shown to clients
|
|
@@ -357,7 +371,7 @@ Docs & source: https://github.com/agigante80/actual-mcp-server`);
|
|
|
357
371
|
}
|
|
358
372
|
else if (useStdio) {
|
|
359
373
|
logger.debug('Mode: stdio');
|
|
360
|
-
await startStdioServer(mcp, capabilities, implementedTools, SERVER_DESCRIPTION,
|
|
374
|
+
await startStdioServer(mcp, capabilities, implementedTools, SERVER_DESCRIPTION, serverInstructionsText, toolSchemas, version);
|
|
361
375
|
}
|
|
362
376
|
else {
|
|
363
377
|
logger.error('❌ Please specify a transport mode: --http or --stdio');
|
|
@@ -97,7 +97,6 @@ export class ActualMCPConnection extends EventEmitter {
|
|
|
97
97
|
prompts: false,
|
|
98
98
|
models: false,
|
|
99
99
|
logging: false,
|
|
100
|
-
serverInstructions: 'This server exposes Actual Finance tools via MCP. You must provide ACTUAL_SERVER_URL, ACTUAL_PASSWORD, and ACTUAL_BUDGET_SYNC_ID as environment variables.'
|
|
101
100
|
};
|
|
102
101
|
}
|
|
103
102
|
/** Executes a tool requested by the client */
|
|
@@ -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 });
|
|
@@ -240,3 +267,64 @@ export function parseWhereClause(query, whereClause, tableName) {
|
|
|
240
267
|
}
|
|
241
268
|
return query;
|
|
242
269
|
}
|
|
270
|
+
// #561: the joined name columns an empty-result hint can check, and the table that lists each.
|
|
271
|
+
const NAME_CONDITION_TABLES = {
|
|
272
|
+
'category.name': { table: 'categories', noun: 'category' },
|
|
273
|
+
'payee.name': { table: 'payees', noun: 'payee' },
|
|
274
|
+
'account.name': { table: 'accounts', noun: 'account' },
|
|
275
|
+
};
|
|
276
|
+
/**
|
|
277
|
+
* #561: the `=`, `IN` and `LIKE` conditions on category.name, payee.name or account.name in a
|
|
278
|
+
* query built by parseWhereClause, read back from its serialized filterExpressions. `!=` and
|
|
279
|
+
* `NOT LIKE` are skipped: an unknown name there cannot make the result empty. Each condition is
|
|
280
|
+
* ONE lookup, an IN list included (the ticket allows one extra read per name condition), and the
|
|
281
|
+
* lookup reuses the main query's operator and value, so a `$oneof` element compiles exactly as in
|
|
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.
|
|
285
|
+
*/
|
|
286
|
+
export function nameConditionLookups(filterExpressions) {
|
|
287
|
+
const out = [];
|
|
288
|
+
const isScalar = (v) => typeof v === 'string' || typeof v === 'number';
|
|
289
|
+
for (const expr of filterExpressions) {
|
|
290
|
+
if (expr === null || typeof expr !== 'object')
|
|
291
|
+
continue;
|
|
292
|
+
for (const [field, cond] of Object.entries(expr)) {
|
|
293
|
+
const target = NAME_CONDITION_TABLES[field];
|
|
294
|
+
if (!target)
|
|
295
|
+
continue;
|
|
296
|
+
if (isScalar(cond)) {
|
|
297
|
+
out.push({ ...target, nameFilter: cond, value: String(cond) });
|
|
298
|
+
}
|
|
299
|
+
else if (cond !== null && typeof cond === 'object') {
|
|
300
|
+
const c = cond;
|
|
301
|
+
if (typeof c.$like === 'string') {
|
|
302
|
+
out.push({ ...target, nameFilter: { $like: c.$like }, value: c.$like });
|
|
303
|
+
}
|
|
304
|
+
else if (Array.isArray(c.$oneof) && c.$oneof.length > 0 && c.$oneof.every(isScalar)) {
|
|
305
|
+
out.push({
|
|
306
|
+
...target,
|
|
307
|
+
nameFilter: { $oneof: c.$oneof },
|
|
308
|
+
inValues: c.$oneof.map((v) => String(v).replace(/''/g, "'")),
|
|
309
|
+
});
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
return out;
|
|
315
|
+
}
|
|
316
|
+
/**
|
|
317
|
+
* #561: the hint lines for one condition, given the names its lookup returned. Each value is
|
|
318
|
+
* echoed at most 100 characters. For an IN list the comparison is exact, because upstream compiles
|
|
319
|
+
* `$oneof` to a plain SQL IN, which is case-sensitive: 'groceries' does not match 'Groceries' in
|
|
320
|
+
* the main query, so it must not count as found here either (#561 review round 2).
|
|
321
|
+
*/
|
|
322
|
+
export function nameConditionHintLines(lookup, foundNames) {
|
|
323
|
+
const line = (v) => `No ${lookup.noun} matches '${v.slice(0, 100)}'. ` +
|
|
324
|
+
`List the exact names with: SELECT id, name FROM ${lookup.table}`;
|
|
325
|
+
if (lookup.inValues) {
|
|
326
|
+
const found = new Set(foundNames);
|
|
327
|
+
return [...new Set(lookup.inValues)].filter((v) => !found.has(v)).map(line);
|
|
328
|
+
}
|
|
329
|
+
return foundNames.length === 0 ? [line(lookup.value ?? '')] : [];
|
|
330
|
+
}
|
|
@@ -3468,6 +3468,43 @@ export async function resetBudgetHold(month) {
|
|
|
3468
3468
|
await withConcurrency(() => retry(() => rawResetBudgetHold(month), { retries: 2, backoffMs: 200 }));
|
|
3469
3469
|
}, { preservesListings: PRESERVES_ALL_ENTITY_LISTINGS });
|
|
3470
3470
|
}
|
|
3471
|
+
/**
|
|
3472
|
+
* #561: an empty result whose category.name, payee.name or account.name condition names no
|
|
3473
|
+
* existing entity gains a sibling `hint` naming the value and the query that lists the exact
|
|
3474
|
+
* names. Runs INSIDE runQuery's withActualApi session: raw reads only, never `adapter.*` (the api
|
|
3475
|
+
* lock is not reentrant). Each lookup is a `q()` filter object carrying the main query's own
|
|
3476
|
+
* operator and value, so the value never reaches a query string. A non-empty result, or one with
|
|
3477
|
+
* no name condition, is returned untouched with no lookup; a failed lookup drops the hint rather
|
|
3478
|
+
* than failing a query that succeeded.
|
|
3479
|
+
*/
|
|
3480
|
+
async function withEmptyResultHint(result, query, q) {
|
|
3481
|
+
// Upstream answers `{ data }`; a bare array is tolerated the way getFinancialAnalysisSnapshot does.
|
|
3482
|
+
const rows = (r) => Array.isArray(r) ? r : Array.isArray(r?.data) ? r.data : undefined;
|
|
3483
|
+
const data = rows(result);
|
|
3484
|
+
if (!data || data.length > 0)
|
|
3485
|
+
return result;
|
|
3486
|
+
const lookups = nameConditionLookups(query.serialize?.().filterExpressions ?? []);
|
|
3487
|
+
if (lookups.length === 0)
|
|
3488
|
+
return result;
|
|
3489
|
+
try {
|
|
3490
|
+
const lines = [];
|
|
3491
|
+
for (const lookup of lookups) {
|
|
3492
|
+
const found = await withConcurrency(() => rawRunQuery(lookup.inValues
|
|
3493
|
+
? q(lookup.table).filter({ name: lookup.nameFilter }).select(['name'])
|
|
3494
|
+
: q(lookup.table).filter({ name: lookup.nameFilter }).select(['name']).limit(1)));
|
|
3495
|
+
const names = (rows(found) ?? []).map((row) => String(row?.name ?? ''));
|
|
3496
|
+
lines.push(...nameConditionHintLines(lookup, names));
|
|
3497
|
+
}
|
|
3498
|
+
if (lines.length === 0)
|
|
3499
|
+
return result;
|
|
3500
|
+
const hint = lines.join('\n');
|
|
3501
|
+
return Array.isArray(result) ? { data: result, hint } : { ...result, hint };
|
|
3502
|
+
}
|
|
3503
|
+
catch (err) {
|
|
3504
|
+
logger.warn('[ADAPTER] Empty-result name lookup failed; returning the result without a hint', errorLogFields(err));
|
|
3505
|
+
return result;
|
|
3506
|
+
}
|
|
3507
|
+
}
|
|
3471
3508
|
export async function runQuery(queryString) {
|
|
3472
3509
|
try {
|
|
3473
3510
|
return await withActualApi(async () => {
|
|
@@ -3510,6 +3547,7 @@ export async function runQuery(queryString) {
|
|
|
3510
3547
|
}
|
|
3511
3548
|
const trimmed = queryString.trim();
|
|
3512
3549
|
let query;
|
|
3550
|
+
let hasWhereClause = false; // #561: only a parsed SQL WHERE can carry a name condition
|
|
3513
3551
|
// Check for GraphQL-like query syntax: query Name { table(...) { fields } }
|
|
3514
3552
|
const graphqlMatch = trimmed.match(/^query\s+\w+\s*\{\s*(\w+)\s*\(([^)]*)\)\s*\{([^}]+)\}\s*\}$/is);
|
|
3515
3553
|
if (graphqlMatch) {
|
|
@@ -3577,6 +3615,7 @@ export async function runQuery(queryString) {
|
|
|
3577
3615
|
// types and coerce boolean literals to real booleans (#420).
|
|
3578
3616
|
if (whereClause) {
|
|
3579
3617
|
query = parseWhereClause(query, whereClause, tableName);
|
|
3618
|
+
hasWhereClause = true;
|
|
3580
3619
|
}
|
|
3581
3620
|
// Apply ORDER BY
|
|
3582
3621
|
if (orderField) {
|
|
@@ -3593,7 +3632,7 @@ export async function runQuery(queryString) {
|
|
|
3593
3632
|
}
|
|
3594
3633
|
}
|
|
3595
3634
|
try {
|
|
3596
|
-
|
|
3635
|
+
const result = await withConcurrency(async () => {
|
|
3597
3636
|
try {
|
|
3598
3637
|
return await rawRunQuery(query);
|
|
3599
3638
|
}
|
|
@@ -3607,6 +3646,7 @@ export async function runQuery(queryString) {
|
|
|
3607
3646
|
throw err;
|
|
3608
3647
|
}
|
|
3609
3648
|
});
|
|
3649
|
+
return hasWhereClause ? await withEmptyResultHint(result, query, q) : result;
|
|
3610
3650
|
}
|
|
3611
3651
|
catch (error) {
|
|
3612
3652
|
// Enhance error messages with helpful context
|
|
@@ -3707,7 +3747,7 @@ export async function getFinancialAnalysisSnapshot(params) {
|
|
|
3707
3747
|
}
|
|
3708
3748
|
// WHERE-clause translation extracted to ./actual-adapter/query.ts (#166).
|
|
3709
3749
|
// Imported for internal use by runQuery and re-exported (unit-tested directly).
|
|
3710
|
-
import { parseWhereClause } from './actual-adapter/query.js';
|
|
3750
|
+
import { parseWhereClause, nameConditionLookups, nameConditionHintLines } from './actual-adapter/query.js';
|
|
3711
3751
|
export { parseWhereClause };
|
|
3712
3752
|
export async function runBankSync(accountId) {
|
|
3713
3753
|
try {
|
|
@@ -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
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The `instructions` string of the MCP `initialize` result (#560).
|
|
3
|
+
*
|
|
4
|
+
* ONE source, built from the tools this process actually publishes (#483), so the text can
|
|
5
|
+
* never point the model at a tool that MCP_TOOLSETS, MCP_TOOLS or MCP_READ_ONLY hid. Clients
|
|
6
|
+
* that read the field (LibreChat with `serverInstructions: true`, Claude Desktop, Claude Code,
|
|
7
|
+
* Gemini's MCP connector) inject it into the system prompt on every turn, so it holds
|
|
8
|
+
* cross-tool rules only: call order, the data conventions every tool shares, and how to
|
|
9
|
+
* recover. Facts that belong to one tool (join syntax, LIKE semantics, what an empty query
|
|
10
|
+
* result means) live in that tool's description and are deliberately not repeated here, where
|
|
11
|
+
* they would drift. The whole text stays at most 512 characters so it stands on its own in
|
|
12
|
+
* clients that truncate.
|
|
13
|
+
*
|
|
14
|
+
* Ids are promised for accounts and categories only: actual_get_context caps payees at
|
|
15
|
+
* payeeLimit (default 500) and its own description routes the rest elsewhere.
|
|
16
|
+
*
|
|
17
|
+
* Read-only comes from the resolved tool policy, never from tool annotations, which are hints
|
|
18
|
+
* that nothing in src/ may branch on.
|
|
19
|
+
*/
|
|
20
|
+
export function buildServerInstructions(published, readOnly) {
|
|
21
|
+
const has = new Set(published);
|
|
22
|
+
const parts = [
|
|
23
|
+
'This server exposes an Actual Budget through MCP tools.',
|
|
24
|
+
'Amounts are integer cents (5000 is 50.00, negative is an outflow) and dates are YYYY-MM-DD strings.',
|
|
25
|
+
];
|
|
26
|
+
if (has.has('actual_get_context')) {
|
|
27
|
+
parts.push('Call actual_get_context first and use the account and category ids it returns, never guessed names.');
|
|
28
|
+
if (has.has('actual_query_run')) {
|
|
29
|
+
parts.push('In actual_query_run, filter on the id columns (account, category, payee) rather than on names; an empty result can mean a name was wrong.');
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
if (readOnly) {
|
|
33
|
+
parts.push('This server is read-only, so do not offer to create, change or delete anything.');
|
|
34
|
+
}
|
|
35
|
+
return parts.join(' ');
|
|
36
|
+
}
|
|
@@ -54,7 +54,7 @@ function resolvePrincipal(req) {
|
|
|
54
54
|
export async function startHttpServer(mcp, port, httpPath, capabilities, // was passed by index.ts
|
|
55
55
|
implementedTools, // was passed by index.ts
|
|
56
56
|
serverDescription, // was passed by index.ts
|
|
57
|
-
|
|
57
|
+
instructions, // was passed by index.ts (#560: built once there)
|
|
58
58
|
toolSchemas, // was passed by index.ts
|
|
59
59
|
version, // server version from package.json
|
|
60
60
|
bindHost = 'localhost', // #239: interface to bind; forwarded to listen()
|
|
@@ -376,14 +376,11 @@ advertisedUrl) {
|
|
|
376
376
|
const capabilitiesObj = capabilities && Object.keys(capabilities).length
|
|
377
377
|
? capabilities
|
|
378
378
|
: { tools: toolsList.reduce((acc, n) => { acc[n] = {}; return acc; }, {}) };
|
|
379
|
+
// Only the two ServerOptions the MCP spec defines (#560). tools/list is served by the
|
|
380
|
+
// request handler, not by these options; the SDK ignored the extra keys this once passed.
|
|
379
381
|
const serverOptions = {
|
|
380
|
-
|
|
381
|
-
instructions: serverInstructions || "Welcome to the Actual MCP server.",
|
|
382
|
-
serverInstructions: { instructions: serverInstructions || "Welcome to the Actual MCP server." },
|
|
382
|
+
instructions,
|
|
383
383
|
capabilities: capabilitiesObj,
|
|
384
|
-
implementedTools: toolsList,
|
|
385
|
-
// Include tools array explicitly so initialize result contains tools: string[]
|
|
386
|
-
tools: toolsList,
|
|
387
384
|
};
|
|
388
385
|
const server = new Server({
|
|
389
386
|
name: serverDescription || "actual-mcp-server",
|
|
@@ -915,8 +912,7 @@ advertisedUrl) {
|
|
|
915
912
|
const serverIp = process.env.MCP_BRIDGE_PUBLIC_HOST || getLocalIp();
|
|
916
913
|
const mcpInfo = () => ({
|
|
917
914
|
description: serverDescription || "Actual MCP server",
|
|
918
|
-
instructions
|
|
919
|
-
serverInstructions: { instructions: serverInstructions || "Welcome to the Actual MCP server." },
|
|
915
|
+
instructions,
|
|
920
916
|
capabilities: capabilities && Object.keys(capabilities).length ? capabilities : { tools: toolsList.reduce((a, n) => ({ ...a, [n]: {} }), {}) },
|
|
921
917
|
tools: toolsList,
|
|
922
918
|
advertisedUrl: advertisedUrl || `${scheme}://${serverIp}:${port}${httpPath}`,
|
|
@@ -85,7 +85,7 @@ export function createStdioCallToolHandler(mcp, stdioSessionId) {
|
|
|
85
85
|
}
|
|
86
86
|
};
|
|
87
87
|
}
|
|
88
|
-
export async function startStdioServer(mcp, capabilities, implementedTools, serverDescription,
|
|
88
|
+
export async function startStdioServer(mcp, capabilities, implementedTools, serverDescription, instructions, toolSchemas, version) {
|
|
89
89
|
const toolsList = Array.isArray(implementedTools) ? implementedTools : [];
|
|
90
90
|
// #348: a synthetic session identity for this stdio process.
|
|
91
91
|
//
|
|
@@ -108,7 +108,7 @@ export async function startStdioServer(mcp, capabilities, implementedTools, serv
|
|
|
108
108
|
// restarts and can never collide with an HTTP session id.
|
|
109
109
|
const stdioSessionId = `stdio-${randomUUID()}`;
|
|
110
110
|
logger.debug(`[STDIO] session id ${stdioSessionId}`);
|
|
111
|
-
const server = new Server({ name: serverDescription || 'actual-mcp-server', version: version || '0.1.0' }, { capabilities, instructions
|
|
111
|
+
const server = new Server({ name: serverDescription || 'actual-mcp-server', version: version || '0.1.0' }, { capabilities, instructions });
|
|
112
112
|
attachTransportErrorLogger(server, 'stdio');
|
|
113
113
|
// List tools handler — mirrors createServerInstance() in httpServer.ts
|
|
114
114
|
server.setRequestHandler(ListToolsRequestSchema, async () => {
|
|
@@ -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).
|
|
@@ -22,11 +22,9 @@ export async function testMcpClient(advertisedUrl, _port, httpPath) {
|
|
|
22
22
|
const resultObj = resultRaw && typeof resultRaw === 'object' ? resultRaw : undefined;
|
|
23
23
|
if (!resultObj)
|
|
24
24
|
throw new Error('Probe result is not an object');
|
|
25
|
-
|
|
26
|
-
if (
|
|
27
|
-
|
|
28
|
-
throw new Error('serverInstructions missing or wrong type');
|
|
29
|
-
}
|
|
25
|
+
// #560: instructions is the spec's plain string, on /mcp-info as on initialize.
|
|
26
|
+
if (typeof resultObj['instructions'] !== 'string') {
|
|
27
|
+
throw new Error('Probe instructions missing or not a string');
|
|
30
28
|
}
|
|
31
29
|
const capabilitiesVal = resultObj['capabilities'];
|
|
32
30
|
if (!capabilitiesVal || typeof capabilitiesVal !== 'object' || !('tools' in capabilitiesVal)) {
|
|
@@ -36,7 +34,7 @@ export async function testMcpClient(advertisedUrl, _port, httpPath) {
|
|
|
36
34
|
if (!Array.isArray(toolsVal)) {
|
|
37
35
|
throw new Error('tools missing or not array');
|
|
38
36
|
}
|
|
39
|
-
console.info('Probe OK: capabilities/tools/
|
|
37
|
+
console.info('Probe OK: capabilities/tools/instructions present');
|
|
40
38
|
// Now do JSON-RPC initialize + tools/list using the HTTP endpoint
|
|
41
39
|
const rpcUrl = `${base.origin}${httpPath}`;
|
|
42
40
|
const initPayload = {
|
|
@@ -77,7 +75,7 @@ export async function testMcpClient(advertisedUrl, _port, httpPath) {
|
|
|
77
75
|
}
|
|
78
76
|
if (sessionId)
|
|
79
77
|
console.info(`Received session id header: ${sessionId}`);
|
|
80
|
-
// Validate initialize result fields (capabilities/tools/
|
|
78
|
+
// Validate initialize result fields (capabilities/tools/instructions)
|
|
81
79
|
const initResultRaw = initJson['result'];
|
|
82
80
|
const initResult = initResultRaw && typeof initResultRaw === 'object' ? initResultRaw : undefined;
|
|
83
81
|
if (!initResult || typeof initResult['capabilities'] !== 'object') {
|
|
@@ -90,21 +88,12 @@ export async function testMcpClient(advertisedUrl, _port, httpPath) {
|
|
|
90
88
|
if (!hasTools) {
|
|
91
89
|
throw new Error('initialize result.tools missing or not array and capabilities.tools not present');
|
|
92
90
|
}
|
|
93
|
-
//
|
|
94
|
-
|
|
95
|
-
if (
|
|
96
|
-
|
|
97
|
-
}
|
|
98
|
-
else if (typeof si === 'string') {
|
|
99
|
-
// ok
|
|
100
|
-
}
|
|
101
|
-
else if (typeof si === 'object' && typeof si['instructions'] === 'string') {
|
|
102
|
-
// ok
|
|
91
|
+
// #560: the spec's InitializeResult.instructions, a string. The harness never read the spec
|
|
92
|
+
// field before (it accepted a non-spec key in any shape), so this is now a hard check.
|
|
93
|
+
if (typeof initResult['instructions'] !== 'string') {
|
|
94
|
+
throw new Error('initialize result.instructions missing or not a string');
|
|
103
95
|
}
|
|
104
|
-
|
|
105
|
-
console.warn('Warning: serverInstructions present but in unexpected shape — continuing tests');
|
|
106
|
-
}
|
|
107
|
-
console.info('Initialize OK: capabilities/tools/serverInstructions present');
|
|
96
|
+
console.info('Initialize OK: capabilities/tools/instructions present');
|
|
108
97
|
// call tools/list (JSON-RPC) to verify RPC path works
|
|
109
98
|
const listPayload = { jsonrpc: '2.0', id: 2, method: 'tools/list', params: {} };
|
|
110
99
|
const listHeaders = { ...commonPostHeaders };
|
|
@@ -115,8 +104,12 @@ export async function testMcpClient(advertisedUrl, _port, httpPath) {
|
|
|
115
104
|
headers: listHeaders,
|
|
116
105
|
body: JSON.stringify(listPayload),
|
|
117
106
|
});
|
|
118
|
-
if (!listRes.ok)
|
|
119
|
-
|
|
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
|
+
}
|
|
120
113
|
const listJsonRaw = await listRes.json();
|
|
121
114
|
const listJsonObj = listJsonRaw && typeof listJsonRaw === 'object' ? listJsonRaw : undefined;
|
|
122
115
|
if (!listJsonObj || !('result' in listJsonObj))
|
|
@@ -15,6 +15,7 @@ Example: "SELECT id, date, amount, payee.name FROM transactions WHERE amount < 0
|
|
|
15
15
|
WHERE supports: =, !=, >, >=, <, <=; IN (v1, v2); LIKE / NOT LIKE (case and accent-insensitive, % wildcard, e.g. imported_payee LIKE '%amazon%'); IS NULL / IS NOT NULL; boolean columns as true / false (cleared = false, category.hidden = true); conditions joined with AND. OR, REGEXP, NOT IN and parenthesised groups are not supported and return an error (a query is never silently run unfiltered). Transactions come back in split-INLINE mode: "is_parent = true" returns nothing, use "is_parent = false" to exclude split children.
|
|
16
16
|
|
|
17
17
|
Joins use dot notation: payee.name, category.name, account.name (NOT payee_name). Amounts are in cents: $100.00 = 10000. Dates are quoted YYYY-MM-DD strings: date >= '2025-01-01'.
|
|
18
|
+
An empty result may carry a "hint" naming a category.name, payee.name or account.name value that matches no entity, with the query that lists the exact names.
|
|
18
19
|
|
|
19
20
|
Tables:
|
|
20
21
|
- transactions: id, date, amount, notes, cleared, account, payee, category
|
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.46",
|
|
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",
|
|
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",
|
|
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",
|