actual-mcp-server 0.22.40 → 0.22.44
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 +8 -6
- package/dist/src/lib/ActualMCPConnection.js +0 -1
- package/dist/src/lib/actual-adapter/query.js +60 -0
- package/dist/src/lib/actual-adapter.js +99 -12
- package/dist/src/lib/serverInstructions.js +36 -0
- package/dist/src/server/httpServer.js +82 -9
- package/dist/src/server/stdioServer.js +2 -2
- package/dist/src/tests/testMcpClient.js +10 -21
- 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.44 | **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),
|
|
@@ -211,8 +212,6 @@ Docs & source: https://github.com/agigante80/actual-mcp-server`);
|
|
|
211
212
|
const useTestActualConnection = args.includes('--test-actual-connection');
|
|
212
213
|
const useTestMcpClient = args.includes('--test-mcp-client');
|
|
213
214
|
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
215
|
async function main() {
|
|
217
216
|
// Mutual exclusion — stdio and http are incompatible transports
|
|
218
217
|
if (useHttp && useStdio) {
|
|
@@ -234,6 +233,9 @@ Docs & source: https://github.com/agigante80/actual-mcp-server`);
|
|
|
234
233
|
// The PUBLISHED tools (#483). This is the one name list every tools/list path receives
|
|
235
234
|
// (HTTP SDK handler, the no-session LobeChat path, the expired-session shim, stdio).
|
|
236
235
|
const implementedTools = actualToolsManager.getPublishedToolNames();
|
|
236
|
+
// The initialize `instructions` (#560): built once from the published set and the same
|
|
237
|
+
// resolved policy, before any transport starts, so no transport can send a different text.
|
|
238
|
+
const serverInstructionsText = buildServerInstructions(implementedTools, actualToolsManager.getToolPolicy().readOnly);
|
|
237
239
|
// Extract schemas map with tool name → JSON schema
|
|
238
240
|
const toolSchemas = {};
|
|
239
241
|
for (const toolName of implementedTools) {
|
|
@@ -314,7 +316,7 @@ Docs & source: https://github.com/agigante80/actual-mcp-server`);
|
|
|
314
316
|
logger.info('⚙️ --test-mcp-client specified, starting HTTP server and running client-side MCP tests...');
|
|
315
317
|
// start server in http mode. Bind all interfaces by default (#239): advertisedUrl
|
|
316
318
|
// uses the LAN IP (getLocalIp), so the self-test client must be able to reach it.
|
|
317
|
-
await startHttpServer(mcp, PORT, HTTP_PATH, capabilities, implementedTools, SERVER_DESCRIPTION,
|
|
319
|
+
await startHttpServer(mcp, PORT, HTTP_PATH, capabilities, implementedTools, SERVER_DESCRIPTION, serverInstructionsText, toolSchemas, version, bindHost, advertisedUrl);
|
|
318
320
|
// dynamic import to avoid circular at top
|
|
319
321
|
const { testMcpClient } = await import('./tests/testMcpClient.js');
|
|
320
322
|
try {
|
|
@@ -343,13 +345,13 @@ Docs & source: https://github.com/agigante80/actual-mcp-server`);
|
|
|
343
345
|
logger.info(`• OAuth Required: false`);
|
|
344
346
|
logger.info(`• Capabilities: ${Object.keys(capabilities).join(', ')}`);
|
|
345
347
|
logger.info(`• Tools: ${implementedTools.join(', ') || 'none'}`);
|
|
346
|
-
logger.info(`• Server Instructions: ${
|
|
348
|
+
logger.info(`• Server Instructions: ${serverInstructionsText}`);
|
|
347
349
|
logger.info(`• MCP endpoint (advertised): ${advertisedUrl}`);
|
|
348
350
|
logger.info('---------');
|
|
349
351
|
}
|
|
350
352
|
if (useHttp) {
|
|
351
353
|
logger.info('Mode: HTTP');
|
|
352
|
-
await startHttpServer(mcp, PORT, HTTP_PATH, capabilities, implementedTools, SERVER_DESCRIPTION,
|
|
354
|
+
await startHttpServer(mcp, PORT, HTTP_PATH, capabilities, implementedTools, SERVER_DESCRIPTION, serverInstructionsText, toolSchemas, version,
|
|
353
355
|
// bind host (ensure server accepts connections on that interface)
|
|
354
356
|
bindHost,
|
|
355
357
|
// advertised URL shown to clients
|
|
@@ -357,7 +359,7 @@ Docs & source: https://github.com/agigante80/actual-mcp-server`);
|
|
|
357
359
|
}
|
|
358
360
|
else if (useStdio) {
|
|
359
361
|
logger.debug('Mode: stdio');
|
|
360
|
-
await startStdioServer(mcp, capabilities, implementedTools, SERVER_DESCRIPTION,
|
|
362
|
+
await startStdioServer(mcp, capabilities, implementedTools, SERVER_DESCRIPTION, serverInstructionsText, toolSchemas, version);
|
|
361
363
|
}
|
|
362
364
|
else {
|
|
363
365
|
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 */
|
|
@@ -240,3 +240,63 @@ export function parseWhereClause(query, whereClause, tableName) {
|
|
|
240
240
|
}
|
|
241
241
|
return query;
|
|
242
242
|
}
|
|
243
|
+
// #561: the joined name columns an empty-result hint can check, and the table that lists each.
|
|
244
|
+
const NAME_CONDITION_TABLES = {
|
|
245
|
+
'category.name': { table: 'categories', noun: 'category' },
|
|
246
|
+
'payee.name': { table: 'payees', noun: 'payee' },
|
|
247
|
+
'account.name': { table: 'accounts', noun: 'account' },
|
|
248
|
+
};
|
|
249
|
+
/**
|
|
250
|
+
* #561: the `=`, `IN` and `LIKE` conditions on category.name, payee.name or account.name in a
|
|
251
|
+
* query built by parseWhereClause, read back from its serialized filterExpressions. `!=` and
|
|
252
|
+
* `NOT LIKE` are skipped: an unknown name there cannot make the result empty. Each condition is
|
|
253
|
+
* ONE lookup, an IN list included (the ticket allows one extra read per name condition), and the
|
|
254
|
+
* 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). A numeric-looking
|
|
256
|
+
* literal arrives coerced to a number and is kept as is.
|
|
257
|
+
*/
|
|
258
|
+
export function nameConditionLookups(filterExpressions) {
|
|
259
|
+
const out = [];
|
|
260
|
+
const isScalar = (v) => typeof v === 'string' || typeof v === 'number';
|
|
261
|
+
for (const expr of filterExpressions) {
|
|
262
|
+
if (expr === null || typeof expr !== 'object')
|
|
263
|
+
continue;
|
|
264
|
+
for (const [field, cond] of Object.entries(expr)) {
|
|
265
|
+
const target = NAME_CONDITION_TABLES[field];
|
|
266
|
+
if (!target)
|
|
267
|
+
continue;
|
|
268
|
+
if (isScalar(cond)) {
|
|
269
|
+
out.push({ ...target, nameFilter: cond, value: String(cond) });
|
|
270
|
+
}
|
|
271
|
+
else if (cond !== null && typeof cond === 'object') {
|
|
272
|
+
const c = cond;
|
|
273
|
+
if (typeof c.$like === 'string') {
|
|
274
|
+
out.push({ ...target, nameFilter: { $like: c.$like }, value: c.$like });
|
|
275
|
+
}
|
|
276
|
+
else if (Array.isArray(c.$oneof) && c.$oneof.length > 0 && c.$oneof.every(isScalar)) {
|
|
277
|
+
out.push({
|
|
278
|
+
...target,
|
|
279
|
+
nameFilter: { $oneof: c.$oneof },
|
|
280
|
+
inValues: c.$oneof.map((v) => String(v).replace(/''/g, "'")),
|
|
281
|
+
});
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
return out;
|
|
287
|
+
}
|
|
288
|
+
/**
|
|
289
|
+
* #561: the hint lines for one condition, given the names its lookup returned. Each value is
|
|
290
|
+
* echoed at most 100 characters. For an IN list the comparison is exact, because upstream compiles
|
|
291
|
+
* `$oneof` to a plain SQL IN, which is case-sensitive: 'groceries' does not match 'Groceries' in
|
|
292
|
+
* the main query, so it must not count as found here either (#561 review round 2).
|
|
293
|
+
*/
|
|
294
|
+
export function nameConditionHintLines(lookup, foundNames) {
|
|
295
|
+
const line = (v) => `No ${lookup.noun} matches '${v.slice(0, 100)}'. ` +
|
|
296
|
+
`List the exact names with: SELECT id, name FROM ${lookup.table}`;
|
|
297
|
+
if (lookup.inValues) {
|
|
298
|
+
const found = new Set(foundNames);
|
|
299
|
+
return [...new Set(lookup.inValues)].filter((v) => !found.has(v)).map(line);
|
|
300
|
+
}
|
|
301
|
+
return foundNames.length === 0 ? [line(lookup.value ?? '')] : [];
|
|
302
|
+
}
|
|
@@ -52,11 +52,35 @@ logger.info(`[ADAPTER] Budget registry: ${budgetRegistry.size} budget(s) — ` +
|
|
|
52
52
|
* stdio mode, startup health checks) fall back to the env-default budget
|
|
53
53
|
* (first entry in budgetRegistry).
|
|
54
54
|
*
|
|
55
|
-
* Lifecycle: entries are
|
|
56
|
-
*
|
|
57
|
-
*
|
|
55
|
+
* Lifecycle (#564): entries are released on EVERY session end. The HTTP
|
|
56
|
+
* transport's teardownSession (idle eviction, the #530 orphan sweep, a client
|
|
57
|
+
* DELETE) calls clearSessionBudgetState, and actual_session_close does too (it
|
|
58
|
+
* also covers stdio). connectionPool.shutdownConnection does NOT release them:
|
|
59
|
+
* switchBudget calls it to drop the stale pool entry and must keep the selection.
|
|
60
|
+
*
|
|
61
|
+
* Every write goes through setSessionBudget, which skips a session the transport
|
|
62
|
+
* layer no longer holds, so a switch or a #189 restore that was in flight across
|
|
63
|
+
* the teardown cannot re-create the entry after it was released.
|
|
58
64
|
*/
|
|
59
65
|
const sessionBudgetState = new Map();
|
|
66
|
+
/**
|
|
67
|
+
* #564: is this session still held by its transport? The HTTP server registers
|
|
68
|
+
* `transports.has`; the default (stdio, unit tests, scripts) treats every session
|
|
69
|
+
* as live, which is the behaviour before #564.
|
|
70
|
+
*/
|
|
71
|
+
let isSessionLive = () => true;
|
|
72
|
+
/** #563: why a queued write whose session ended before it ran was refused. */
|
|
73
|
+
const SESSION_ENDED_BEFORE_WRITE = 'The session ended before this write ran, so it was not applied. Start a new session and retry.';
|
|
74
|
+
export function setSessionLivenessCheck(check) {
|
|
75
|
+
isSessionLive = check;
|
|
76
|
+
}
|
|
77
|
+
function setSessionBudget(sessionId, key) {
|
|
78
|
+
if (!isSessionLive(sessionId)) {
|
|
79
|
+
logger.debug(`[ADAPTER] Session ${sessionId} ended before its budget selection was committed; not recording it`);
|
|
80
|
+
return;
|
|
81
|
+
}
|
|
82
|
+
sessionBudgetState.set(sessionId, key);
|
|
83
|
+
}
|
|
60
84
|
function getActiveBudgetConfig() {
|
|
61
85
|
// _resolveSessionId is declared below; function declarations hoist so this
|
|
62
86
|
// call works at runtime. If we're not in any requestContext.run scope (stdio,
|
|
@@ -78,7 +102,7 @@ function getActiveBudgetConfig() {
|
|
|
78
102
|
// Memoize into the session slot so subsequent calls take the fast path above.
|
|
79
103
|
const restored = pickAllowedPreferredBudget(getPreferredBudgetSyncId(store?.principal), store?.allowedBudgets, [...budgetRegistry.values()]);
|
|
80
104
|
if (restored) {
|
|
81
|
-
|
|
105
|
+
setSessionBudget(sessionId, restored.name.toLowerCase());
|
|
82
106
|
return restored;
|
|
83
107
|
}
|
|
84
108
|
}
|
|
@@ -1136,6 +1160,17 @@ async function processWriteQueue() {
|
|
|
1136
1160
|
'so it was failed immediately rather than waiting for the same bound. Retry it.', { cause: stuckLoadError }));
|
|
1137
1161
|
continue;
|
|
1138
1162
|
}
|
|
1163
|
+
// #563: the session ended (a client DELETE, an eviction) after this write was queued.
|
|
1164
|
+
// Teardown released its budget selection (#564), so its store would now resolve to the
|
|
1165
|
+
// default budget, one the session never chose and its principal may not be allowed.
|
|
1166
|
+
// Refuse instead. Outside the try on purpose, like the fail-fast above: the write never
|
|
1167
|
+
// ran, so it must not reach the pool-drop classification. A plain Error, not a typed
|
|
1168
|
+
// refusal: those mean the caller's input was wrong, and this one is not (gate round 2,
|
|
1169
|
+
// tool-author lens). The check is repeated just before the operation runs, below.
|
|
1170
|
+
if (entry.sessionId && !isSessionLive(entry.sessionId)) {
|
|
1171
|
+
reject(new Error(SESSION_ENDED_BEFORE_WRITE));
|
|
1172
|
+
continue;
|
|
1173
|
+
}
|
|
1139
1174
|
try {
|
|
1140
1175
|
// #390 round 2: run each op in the context it was ENQUEUED in.
|
|
1141
1176
|
//
|
|
@@ -1181,6 +1216,17 @@ async function processWriteQueue() {
|
|
|
1181
1216
|
throw waitErr;
|
|
1182
1217
|
}
|
|
1183
1218
|
await ensureLoadedBudgetMatchesSession();
|
|
1219
|
+
// #563 round 3: the check above ran before the awaits in this block. Holding the
|
|
1220
|
+
// api lock does not hold teardown off: with the pool disabled nothing in teardown
|
|
1221
|
+
// takes the lock, so the session can end (and its budget selection be released)
|
|
1222
|
+
// while this op waits on an abandoned load, and the precondition would then have
|
|
1223
|
+
// loaded the default budget. Re-check last, immediately before the write, with nothing
|
|
1224
|
+
// awaited in between. Accepted: a session that ended during the wait may cost one
|
|
1225
|
+
// wasted default-budget load, which is harmless because the write is still refused.
|
|
1226
|
+
// The error matches no pool-drop pattern, so the catch below only logs and rejects it.
|
|
1227
|
+
if (entry.sessionId && !isSessionLive(entry.sessionId)) {
|
|
1228
|
+
throw new Error(SESSION_ENDED_BEFORE_WRITE);
|
|
1229
|
+
}
|
|
1184
1230
|
// #407: an operation may carry its OWN bound. Only a budget import does today, and
|
|
1185
1231
|
// it needs one: the import is legitimately long, and bounding it at the general
|
|
1186
1232
|
// operation timeout abandons it while every other session is already waiting on it
|
|
@@ -3422,6 +3468,43 @@ export async function resetBudgetHold(month) {
|
|
|
3422
3468
|
await withConcurrency(() => retry(() => rawResetBudgetHold(month), { retries: 2, backoffMs: 200 }));
|
|
3423
3469
|
}, { preservesListings: PRESERVES_ALL_ENTITY_LISTINGS });
|
|
3424
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
|
+
}
|
|
3425
3508
|
export async function runQuery(queryString) {
|
|
3426
3509
|
try {
|
|
3427
3510
|
return await withActualApi(async () => {
|
|
@@ -3464,6 +3547,7 @@ export async function runQuery(queryString) {
|
|
|
3464
3547
|
}
|
|
3465
3548
|
const trimmed = queryString.trim();
|
|
3466
3549
|
let query;
|
|
3550
|
+
let hasWhereClause = false; // #561: only a parsed SQL WHERE can carry a name condition
|
|
3467
3551
|
// Check for GraphQL-like query syntax: query Name { table(...) { fields } }
|
|
3468
3552
|
const graphqlMatch = trimmed.match(/^query\s+\w+\s*\{\s*(\w+)\s*\(([^)]*)\)\s*\{([^}]+)\}\s*\}$/is);
|
|
3469
3553
|
if (graphqlMatch) {
|
|
@@ -3531,6 +3615,7 @@ export async function runQuery(queryString) {
|
|
|
3531
3615
|
// types and coerce boolean literals to real booleans (#420).
|
|
3532
3616
|
if (whereClause) {
|
|
3533
3617
|
query = parseWhereClause(query, whereClause, tableName);
|
|
3618
|
+
hasWhereClause = true;
|
|
3534
3619
|
}
|
|
3535
3620
|
// Apply ORDER BY
|
|
3536
3621
|
if (orderField) {
|
|
@@ -3547,7 +3632,7 @@ export async function runQuery(queryString) {
|
|
|
3547
3632
|
}
|
|
3548
3633
|
}
|
|
3549
3634
|
try {
|
|
3550
|
-
|
|
3635
|
+
const result = await withConcurrency(async () => {
|
|
3551
3636
|
try {
|
|
3552
3637
|
return await rawRunQuery(query);
|
|
3553
3638
|
}
|
|
@@ -3561,6 +3646,7 @@ export async function runQuery(queryString) {
|
|
|
3561
3646
|
throw err;
|
|
3562
3647
|
}
|
|
3563
3648
|
});
|
|
3649
|
+
return hasWhereClause ? await withEmptyResultHint(result, query, q) : result;
|
|
3564
3650
|
}
|
|
3565
3651
|
catch (error) {
|
|
3566
3652
|
// Enhance error messages with helpful context
|
|
@@ -3661,7 +3747,7 @@ export async function getFinancialAnalysisSnapshot(params) {
|
|
|
3661
3747
|
}
|
|
3662
3748
|
// WHERE-clause translation extracted to ./actual-adapter/query.ts (#166).
|
|
3663
3749
|
// Imported for internal use by runQuery and re-exported (unit-tested directly).
|
|
3664
|
-
import { parseWhereClause } from './actual-adapter/query.js';
|
|
3750
|
+
import { parseWhereClause, nameConditionLookups, nameConditionHintLines } from './actual-adapter/query.js';
|
|
3665
3751
|
export { parseWhereClause };
|
|
3666
3752
|
export async function runBankSync(accountId) {
|
|
3667
3753
|
try {
|
|
@@ -3859,7 +3945,7 @@ export async function switchBudget(name) {
|
|
|
3859
3945
|
(currentEntry.encryptionPassword ?? '') === (found.encryptionPassword ?? '');
|
|
3860
3946
|
if (sameAuth && currentEntry.syncId === found.syncId) {
|
|
3861
3947
|
// No-op: already on this exact budget. Keep session map consistent and return.
|
|
3862
|
-
|
|
3948
|
+
setSessionBudget(sessionId, key);
|
|
3863
3949
|
setPreferredBudgetSyncId(store?.principal, found.syncId); // #189: persist at commit
|
|
3864
3950
|
logger.info(`[ADAPTER] switchBudget no-op for session ${sessionId}: already on "${found.name}" (${found.syncId})`);
|
|
3865
3951
|
return { name: found.name, syncId: found.syncId, serverUrl: found.serverUrl };
|
|
@@ -3879,7 +3965,7 @@ export async function switchBudget(name) {
|
|
|
3879
3965
|
});
|
|
3880
3966
|
}
|
|
3881
3967
|
connectionPool.updateLoadedSyncId(sessionId, found.syncId);
|
|
3882
|
-
|
|
3968
|
+
setSessionBudget(sessionId, key);
|
|
3883
3969
|
setPreferredBudgetSyncId(store?.principal, found.syncId); // #189: persist at commit
|
|
3884
3970
|
logger.info(`[ADAPTER] Active budget switched for session ${sessionId} to: "${found.name}" (${found.syncId}) on ${found.serverUrl}`);
|
|
3885
3971
|
return { name: found.name, syncId: found.syncId, serverUrl: found.serverUrl };
|
|
@@ -3895,7 +3981,7 @@ export async function switchBudget(name) {
|
|
|
3895
3981
|
}
|
|
3896
3982
|
// Update the per-session active-budget slot. Subsequent getActiveBudgetConfig
|
|
3897
3983
|
// calls for this session now return the new budget.
|
|
3898
|
-
|
|
3984
|
+
setSessionBudget(sessionId, key);
|
|
3899
3985
|
setPreferredBudgetSyncId(store?.principal, found.syncId); // #189: persist at commit
|
|
3900
3986
|
// Materialise a fresh pool entry bound to the new budget. Without this, the
|
|
3901
3987
|
// next withActualApi call would find no pool entry and fall back to the
|
|
@@ -3938,9 +4024,10 @@ export async function switchBudget(name) {
|
|
|
3938
4024
|
return { name: found.name, syncId: found.syncId, serverUrl: found.serverUrl };
|
|
3939
4025
|
}
|
|
3940
4026
|
/**
|
|
3941
|
-
* Clear the per-session budget state for a session
|
|
3942
|
-
*
|
|
3943
|
-
*
|
|
4027
|
+
* Clear the per-session budget state for a session, so the map does not
|
|
4028
|
+
* accumulate entries for ended sessions (#156). Called from the HTTP
|
|
4029
|
+
* transport's teardownSession on every session end (#564) and from
|
|
4030
|
+
* session_close, which also covers stdio.
|
|
3944
4031
|
*/
|
|
3945
4032
|
export function clearSessionBudgetState(sessionId) {
|
|
3946
4033
|
sessionBudgetState.delete(sessionId);
|
|
@@ -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
|
+
}
|
|
@@ -9,6 +9,8 @@ import { getLocalIp } from '../utils.js';
|
|
|
9
9
|
import actualToolsManager from '../actualToolsManager.js';
|
|
10
10
|
import { getConnectionState, connectToActualForSession, shutdownActualForSession, shutdownActual, canAcceptNewSession, isConnectionPoolEnabled } from '../actualConnection.js';
|
|
11
11
|
import { connectionPool } from '../lib/ActualConnectionPool.js';
|
|
12
|
+
import { clearSessionBudgetState, setSessionLivenessCheck } from '../lib/actual-adapter.js';
|
|
13
|
+
import { errorLogFields } from '../lib/errors.js';
|
|
12
14
|
import observability from '../observability.js';
|
|
13
15
|
import config from '../config.js';
|
|
14
16
|
import { createRemoteJWKSet, jwtVerify } from 'jose';
|
|
@@ -52,7 +54,7 @@ function resolvePrincipal(req) {
|
|
|
52
54
|
export async function startHttpServer(mcp, port, httpPath, capabilities, // was passed by index.ts
|
|
53
55
|
implementedTools, // was passed by index.ts
|
|
54
56
|
serverDescription, // was passed by index.ts
|
|
55
|
-
|
|
57
|
+
instructions, // was passed by index.ts (#560: built once there)
|
|
56
58
|
toolSchemas, // was passed by index.ts
|
|
57
59
|
version, // server version from package.json
|
|
58
60
|
bindHost = 'localhost', // #239: interface to bind; forwarded to listen()
|
|
@@ -299,6 +301,9 @@ advertisedUrl) {
|
|
|
299
301
|
// window without a served request, with reason `orphaned`; same teardown. The
|
|
300
302
|
// body is synchronous on purpose: the pool walks its registrations in one pass.
|
|
301
303
|
connectionPool.onSessionEvicted(teardownSession);
|
|
304
|
+
// #564: a session's selected budget lives as long as its transport. A selection
|
|
305
|
+
// committed after teardown (a switch in flight across it) is dropped by the adapter.
|
|
306
|
+
setSessionLivenessCheck((id) => transports.has(id));
|
|
302
307
|
function teardownSession(sessionId, reason) {
|
|
303
308
|
const transport = transports.get(sessionId);
|
|
304
309
|
if (transport) {
|
|
@@ -312,6 +317,7 @@ advertisedUrl) {
|
|
|
312
317
|
transports.delete(sessionId);
|
|
313
318
|
sessionInitPromises.delete(sessionId);
|
|
314
319
|
forgetSessionOwner(sessionId);
|
|
320
|
+
clearSessionBudgetState(sessionId);
|
|
315
321
|
connectionPool.forgetSession(sessionId);
|
|
316
322
|
logger.info(`[SESSION] Transport torn down for ${reason} session: ${sessionId}`);
|
|
317
323
|
}
|
|
@@ -370,14 +376,11 @@ advertisedUrl) {
|
|
|
370
376
|
const capabilitiesObj = capabilities && Object.keys(capabilities).length
|
|
371
377
|
? capabilities
|
|
372
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.
|
|
373
381
|
const serverOptions = {
|
|
374
|
-
|
|
375
|
-
instructions: serverInstructions || "Welcome to the Actual MCP server.",
|
|
376
|
-
serverInstructions: { instructions: serverInstructions || "Welcome to the Actual MCP server." },
|
|
382
|
+
instructions,
|
|
377
383
|
capabilities: capabilitiesObj,
|
|
378
|
-
implementedTools: toolsList,
|
|
379
|
-
// Include tools array explicitly so initialize result contains tools: string[]
|
|
380
|
-
tools: toolsList,
|
|
381
384
|
};
|
|
382
385
|
const server = new Server({
|
|
383
386
|
name: serverDescription || "actual-mcp-server",
|
|
@@ -521,6 +524,14 @@ advertisedUrl) {
|
|
|
521
524
|
const transport = new StreamableHTTPServerTransport({
|
|
522
525
|
sessionIdGenerator: () => randomUUID(),
|
|
523
526
|
enableJsonResponse: true,
|
|
527
|
+
// #563: the SDK awaits this on a valid DELETE, BEFORE it answers 200, so the pool
|
|
528
|
+
// slot is free by the time the client hears the session ended. It waits for the api
|
|
529
|
+
// mutex (#392), so another session's call in flight finishes first. Without evict:
|
|
530
|
+
// the DELETE route tears the session down itself after the 200.
|
|
531
|
+
onsessionclosed: async (sid) => {
|
|
532
|
+
if (isConnectionPoolEnabled())
|
|
533
|
+
await connectionPool.shutdownConnection(sid);
|
|
534
|
+
},
|
|
524
535
|
onsessioninitialized: async (sid) => {
|
|
525
536
|
logger.debug(`Session initialized: ${sid}`);
|
|
526
537
|
// Store the promise before starting initialization
|
|
@@ -826,6 +837,69 @@ advertisedUrl) {
|
|
|
826
837
|
connectionPool.touch(sessionId); // Refresh the pool's idle clock (#167)
|
|
827
838
|
await transport.handleRequest(req, res);
|
|
828
839
|
});
|
|
840
|
+
// #563: DELETE ends the session (MCP spec 2025-11-25, Session Management rule 5).
|
|
841
|
+
// Same gate, missing-id answer and ownership rule as POST; the SDK then validates
|
|
842
|
+
// MCP-Protocol-Version, runs onsessionclosed (which frees the pool slot, see the
|
|
843
|
+
// transport options), answers 200 and closes its transport. Only after a 200 do we
|
|
844
|
+
// tear the session down ourselves: a 400 for an unsupported version leaves the
|
|
845
|
+
// session open, as the SDK does. teardownSession is called directly rather than
|
|
846
|
+
// through the pool's eviction listener because the listener never fires with the
|
|
847
|
+
// pool disabled or with the entry already dropped (#530), which would leave the
|
|
848
|
+
// transport, owner and budget selection behind. The pool entry is shut down
|
|
849
|
+
// WITHOUT evict so the teardown runs once, logged as `closed`.
|
|
850
|
+
const sessionNotFound = { jsonrpc: '2.0', id: null, error: { code: -32001, message: 'Session not found. Please re-initialize by calling initialize without mcp-session-id header.' } };
|
|
851
|
+
app.delete(httpPath, async (req, res) => {
|
|
852
|
+
if (!authenticateRequest(req, res)) {
|
|
853
|
+
return;
|
|
854
|
+
}
|
|
855
|
+
const sessionId = req.headers['mcp-session-id'];
|
|
856
|
+
if (!sessionId) {
|
|
857
|
+
res.status(400).json({ jsonrpc: '2.0', id: null, error: { code: -32000, message: 'Bad Request: Mcp-Session-Id header is required' } });
|
|
858
|
+
return;
|
|
859
|
+
}
|
|
860
|
+
try {
|
|
861
|
+
let transport = transports.get(sessionId);
|
|
862
|
+
const initPromise = transport ? undefined : sessionInitPromises.get(sessionId);
|
|
863
|
+
if (initPromise) {
|
|
864
|
+
// A failed init is answered like an unknown id; its #438 record is peek-only and stays.
|
|
865
|
+
await initPromise.catch(() => undefined);
|
|
866
|
+
transport = transports.get(sessionId);
|
|
867
|
+
}
|
|
868
|
+
// A non-owner gets the unknown-id answer byte for byte (#529), so the reply never
|
|
869
|
+
// confirms that the id exists.
|
|
870
|
+
if (!transport || !sessionOwnerMatches(sessionId, resolvePrincipal(req))) {
|
|
871
|
+
if (transport)
|
|
872
|
+
logger.warn(`[SESSION] Refused a DELETE for session ${sessionId} from a principal that does not own it`);
|
|
873
|
+
res.status(404).json(sessionNotFound);
|
|
874
|
+
return;
|
|
875
|
+
}
|
|
876
|
+
const allowedBudgets = req.allowedBudgets;
|
|
877
|
+
const requestId = resolveRequestId(req.get('x-correlation-id'));
|
|
878
|
+
await requestContext.run({ sessionId, requestId, allowedBudgets, principal: resolvePrincipal(req) }, async () => {
|
|
879
|
+
await transport.handleRequest(req, res);
|
|
880
|
+
});
|
|
881
|
+
if (res.statusCode !== 200)
|
|
882
|
+
return;
|
|
883
|
+
teardownSession(sessionId, 'closed');
|
|
884
|
+
}
|
|
885
|
+
catch (err) {
|
|
886
|
+
logger.error(`[SESSION] DELETE for session ${sessionId} failed`, errorLogFields(err));
|
|
887
|
+
if (!res.headersSent) {
|
|
888
|
+
res.status(500).json({ jsonrpc: '2.0', id: null, error: { code: -32603, message: 'Internal error. The server logged the cause.' } });
|
|
889
|
+
}
|
|
890
|
+
}
|
|
891
|
+
});
|
|
892
|
+
// #563: every other method on the MCP path gets a JSON-RPC 405 with the Allow header
|
|
893
|
+
// RFC 9110 15.5.6 requires, instead of Express's HTML 404. HEAD is served by the GET
|
|
894
|
+
// route; OPTIONS answers 204 with the same Allow list.
|
|
895
|
+
app.all(httpPath, (req, res) => {
|
|
896
|
+
res.setHeader('Allow', 'GET, POST, DELETE');
|
|
897
|
+
if (req.method === 'OPTIONS') {
|
|
898
|
+
res.status(204).end();
|
|
899
|
+
return;
|
|
900
|
+
}
|
|
901
|
+
res.status(405).json({ jsonrpc: '2.0', id: null, error: { code: -32000, message: 'Method not allowed.' } });
|
|
902
|
+
});
|
|
829
903
|
// Quick GET server-info probe used by our test harness (testMcpClient.ts, the
|
|
830
904
|
// Playwright E2E) to check the server is up and advertises tools before the full
|
|
831
905
|
// JSON-RPC handshake. #286: this used to live on /.well-known/oauth-protected-resource
|
|
@@ -838,8 +912,7 @@ advertisedUrl) {
|
|
|
838
912
|
const serverIp = process.env.MCP_BRIDGE_PUBLIC_HOST || getLocalIp();
|
|
839
913
|
const mcpInfo = () => ({
|
|
840
914
|
description: serverDescription || "Actual MCP server",
|
|
841
|
-
instructions
|
|
842
|
-
serverInstructions: { instructions: serverInstructions || "Welcome to the Actual MCP server." },
|
|
915
|
+
instructions,
|
|
843
916
|
capabilities: capabilities && Object.keys(capabilities).length ? capabilities : { tools: toolsList.reduce((a, n) => ({ ...a, [n]: {} }), {}) },
|
|
844
917
|
tools: toolsList,
|
|
845
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 () => {
|
|
@@ -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
|
|
103
|
-
}
|
|
104
|
-
else {
|
|
105
|
-
console.warn('Warning: serverInstructions present but in unexpected shape — continuing tests');
|
|
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');
|
|
106
95
|
}
|
|
107
|
-
console.info('Initialize OK: capabilities/tools/
|
|
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 };
|
|
@@ -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.44",
|
|
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/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",
|