actual-mcp-server 0.11.4 → 0.11.6
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
CHANGED
|
@@ -41,7 +41,7 @@ Most Actual Budget MCP implementations are simple stdio bridges designed for sin
|
|
|
41
41
|
- **74 tools, the most comprehensive coverage available.** Accounts, transactions, categories, payees, tags, notes, rules, budgets, batch operations, bank sync, and more. Covers the reachable Actual Budget API with no genuine gaps.
|
|
42
42
|
- **HTTP and stdio transport.** Runs as a real remote server for LibreChat/LobeChat (`--http`), or as a direct local process for Claude Desktop (`--stdio`). No Docker or HTTP server is needed for local use.
|
|
43
43
|
- **6 exclusive ActualQL-powered tools.** Search and summarise transactions by month, amount, category, or payee using Actual Budget's native query engine. Aggregated results, no raw data dumped into the AI context window.
|
|
44
|
-
- **Multi-budget switching at runtime.** Configure multiple budget files and let the AI switch between them mid-conversation with `actual_budgets_switch`.
|
|
44
|
+
- **Multi-budget switching at runtime.** Configure multiple budget files and let the AI switch between them mid-conversation with `actual_budgets_switch`. Works on both transports: HTTP keys the active budget to the MCP session, and stdio (Claude Desktop, Claude Code, Cursor) gets a synthetic per-process session so a switch is scoped to that process rather than shared globally (#348).
|
|
45
45
|
- **Multi-user ready with OIDC.** Secure every session with JWKS-validated JWTs and per-user budget ACLs. No shared tokens required.
|
|
46
46
|
- **Production-grade reliability.** Connection pooling (up to 15 concurrent sessions), automatic retry with exponential backoff, and a full test suite (unit + E2E + integration).
|
|
47
47
|
|
|
@@ -774,4 +774,4 @@ The software is provided **as-is**, without warranty of any kind. The author acc
|
|
|
774
774
|
|
|
775
775
|
---
|
|
776
776
|
|
|
777
|
-
**Version:** 0.11.
|
|
777
|
+
**Version:** 0.11.6 | **Tool Count:** 74 (verified LibreChat-compatible)
|
|
@@ -172,6 +172,28 @@ class ActualConnectionPool {
|
|
|
172
172
|
* downloadBudget(newSyncId) call. switchBudget's fast path uses this so
|
|
173
173
|
* subsequent comparisons reflect the loaded budget. See #172.
|
|
174
174
|
*/
|
|
175
|
+
/**
|
|
176
|
+
* #349 (review follow-up): invalidate the tracked syncId on EVERY entry.
|
|
177
|
+
*
|
|
178
|
+
* `@actual-app/api` is a process-global singleton with ONE loaded budget, while
|
|
179
|
+
* this pool tracks up to MAX_CONCURRENT_SESSIONS entries that each carry their
|
|
180
|
+
* own `syncId`. An import performed by session A therefore changes the loaded
|
|
181
|
+
* budget for sessions B..N as well, but only A knows it happened. Invalidating
|
|
182
|
+
* A alone leaves B's next switchBudget matching the #172 fast path against a
|
|
183
|
+
* record that is no longer true, returning success while B operates on A's
|
|
184
|
+
* imported budget: the same failure #349 fixed, arriving from a direction the
|
|
185
|
+
* user cannot see.
|
|
186
|
+
*
|
|
187
|
+
* Returns the number of entries invalidated, for logging.
|
|
188
|
+
*/
|
|
189
|
+
invalidateAllLoadedSyncIds(sentinel) {
|
|
190
|
+
let n = 0;
|
|
191
|
+
for (const entry of this.connections.values()) {
|
|
192
|
+
entry.syncId = sentinel;
|
|
193
|
+
n += 1;
|
|
194
|
+
}
|
|
195
|
+
return n;
|
|
196
|
+
}
|
|
175
197
|
updateLoadedSyncId(sessionId, newSyncId) {
|
|
176
198
|
const entry = this.connections.get(sessionId);
|
|
177
199
|
if (entry) {
|
|
@@ -1771,8 +1771,10 @@ export async function getBudgets() {
|
|
|
1771
1771
|
*
|
|
1772
1772
|
* Issue #156:
|
|
1773
1773
|
* * Per-session: writes to the per-session map keyed by current sessionId.
|
|
1774
|
-
*
|
|
1775
|
-
*
|
|
1774
|
+
* Since #348 stdio HAS a session (a synthetic per-process id minted in
|
|
1775
|
+
* stdioServer.ts), so it can switch. Genuinely session-less callers (CLI
|
|
1776
|
+
* scripts, startup health checks) still fall back to the env-default budget
|
|
1777
|
+
* and cannot switch (returns an error).
|
|
1776
1778
|
* * ACL: refuses when the target budget's syncId is not in this session's
|
|
1777
1779
|
* allowedBudgets.
|
|
1778
1780
|
* * Exact match only (substring matching removed: it was a sharp edge that
|
|
@@ -1790,8 +1792,10 @@ export async function switchBudget(name) {
|
|
|
1790
1792
|
// Stdio / no-session callers: per-session map has no slot for them, so the
|
|
1791
1793
|
// switch would have no effect. Refuse explicitly rather than silently no-op.
|
|
1792
1794
|
if (!sessionId) {
|
|
1793
|
-
throw new Error('Budget switch requires an MCP session.
|
|
1794
|
-
'
|
|
1795
|
+
throw new Error('Budget switch requires an MCP session. This caller has none, which means a CLI script or an ' +
|
|
1796
|
+
'internal startup path rather than a client: both HTTP and stdio clients get a session (#348). ' +
|
|
1797
|
+
'Such callers operate on the env-default budget; configure ACTUAL_BUDGET_SYNC_ID (or the ' +
|
|
1798
|
+
'BUDGET_n_* variants) to select a different default.');
|
|
1795
1799
|
}
|
|
1796
1800
|
const key = name.toLowerCase();
|
|
1797
1801
|
const found = budgetRegistry.get(key);
|
|
@@ -1890,7 +1894,22 @@ export async function switchBudget(name) {
|
|
|
1890
1894
|
// next withActualApi call would find no pool entry and fall back to the
|
|
1891
1895
|
// legacy init+shutdown path. Failure here is logged but not fatal: the
|
|
1892
1896
|
// legacy fallback still works, just less efficiently.
|
|
1893
|
-
|
|
1897
|
+
//
|
|
1898
|
+
// #348 EXCEPTION: never for a stdio session. stdio gained a synthetic session
|
|
1899
|
+
// id so it could switch budgets at all, but it must stay on the legacy
|
|
1900
|
+
// init/shutdown cycle. A pooled entry for stdio would be created here and then
|
|
1901
|
+
// NEVER refreshed, because connectionPool.touch() is called only from
|
|
1902
|
+
// httpServer.ts; it would expire after SESSION_IDLE_TIMEOUT_MINUTES and
|
|
1903
|
+
// cleanupIdleConnections would api.shutdown() it WITHOUT holding withApiLock,
|
|
1904
|
+
// which can tear the singleton down underneath an in-flight operation. The
|
|
1905
|
+
// legacy path re-reads getActiveBudgetConfig() on every call, so the switch
|
|
1906
|
+
// still takes effect; it just costs an init per operation, which is what stdio
|
|
1907
|
+
// already did before this ticket.
|
|
1908
|
+
if (store?.transport === 'stdio') {
|
|
1909
|
+
logger.debug(`[ADAPTER] switchBudget: session ${sessionId} is stdio; skipping pool materialisation ` +
|
|
1910
|
+
'(stdio stays on the legacy init/shutdown path by design, #348).');
|
|
1911
|
+
}
|
|
1912
|
+
else if (_skipApiInitForTests) {
|
|
1894
1913
|
setApiInitialized(true);
|
|
1895
1914
|
}
|
|
1896
1915
|
else {
|
|
@@ -2037,13 +2056,40 @@ export async function exportBudget() {
|
|
|
2037
2056
|
*/
|
|
2038
2057
|
export async function importBudget(input, opts = {}) {
|
|
2039
2058
|
observability.incrementToolCall('actual.budgets.import').catch(() => { });
|
|
2040
|
-
|
|
2059
|
+
const result = await queueWriteOperation(async () => {
|
|
2041
2060
|
return await withConcurrency(() => retry(() => rawImportBudget(input, opts), {
|
|
2042
2061
|
retries: 0,
|
|
2043
2062
|
backoffMs: 200,
|
|
2044
2063
|
isRetryable: isRetryableError,
|
|
2045
2064
|
}));
|
|
2046
2065
|
});
|
|
2066
|
+
// #349: an import CHANGES WHICH BUDGET IS LOADED, so the pool's record of it
|
|
2067
|
+
// must stop naming the old one.
|
|
2068
|
+
//
|
|
2069
|
+
// switchBudget's #172 fast path skips the re-download when
|
|
2070
|
+
// `currentEntry.syncId === found.syncId`, trusting that field as the record of
|
|
2071
|
+
// what is loaded. Every other path that changes the loaded budget keeps it in
|
|
2072
|
+
// sync; this one did not. The result was a switch BACK to the configured budget
|
|
2073
|
+
// returning `{success: true}` while the session stayed on the imported copy, so
|
|
2074
|
+
// subsequent writes and deletes landed in the wrong budget. That is the
|
|
2075
|
+
// reported-success-with-no-effect class of #347, with financial data at the
|
|
2076
|
+
// other end of it.
|
|
2077
|
+
//
|
|
2078
|
+
// The sentinel is deliberate rather than the imported id alone: configured sync
|
|
2079
|
+
// ids are UUIDs, so an `imported:` prefix can never compare equal to one, which
|
|
2080
|
+
// makes the fast path structurally unable to match after an import. An imported
|
|
2081
|
+
// budget frequently has no cloud sync id at all, so there is no true value to
|
|
2082
|
+
// record here; what matters is that the stale one is gone.
|
|
2083
|
+
// EVERY entry, not just this session's. The api singleton is process-global with
|
|
2084
|
+
// one loaded budget, so an import by session A changes what B..N are looking at
|
|
2085
|
+
// too. Invalidating only A would leave the others matching the fast path against
|
|
2086
|
+
// a record that is no longer true.
|
|
2087
|
+
const invalidated = connectionPool.invalidateAllLoadedSyncIds(`imported:${result.id}`);
|
|
2088
|
+
if (invalidated > 0) {
|
|
2089
|
+
logger.debug(`[ADAPTER] importBudget: invalidated the pooled syncId on ${invalidated} session(s) ` +
|
|
2090
|
+
`(now "imported:${result.id}"), so a switch back re-downloads instead of no-opping (#349).`);
|
|
2091
|
+
}
|
|
2092
|
+
return result;
|
|
2047
2093
|
}
|
|
2048
2094
|
/**
|
|
2049
2095
|
* Read the budget's synced preferences: number format, date format, currency,
|
|
@@ -2,10 +2,33 @@
|
|
|
2
2
|
import { Server } from '@modelcontextprotocol/sdk/server/index.js';
|
|
3
3
|
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
|
|
4
4
|
import { ListToolsRequestSchema, CallToolRequestSchema } from '@modelcontextprotocol/sdk/types.js';
|
|
5
|
+
import { randomUUID } from 'node:crypto';
|
|
5
6
|
import logger from '../logger.js';
|
|
6
7
|
import actualToolsManager from '../actualToolsManager.js';
|
|
8
|
+
import { requestContext } from '../lib/requestContext.js';
|
|
7
9
|
export async function startStdioServer(mcp, capabilities, implementedTools, serverDescription, serverInstructions, toolSchemas, version) {
|
|
8
10
|
const toolsList = Array.isArray(implementedTools) ? implementedTools : [];
|
|
11
|
+
// #348: a synthetic session identity for this stdio process.
|
|
12
|
+
//
|
|
13
|
+
// WHY THIS EXISTS. The active budget lives in a per-session map keyed on
|
|
14
|
+
// sessionId (`sessionBudgetState` in actual-adapter.ts), read by
|
|
15
|
+
// getActiveBudgetConfig() from requestContext. Until this, stdio never entered
|
|
16
|
+
// a requestContext scope, so it had no sessionId, no slot in that map, and
|
|
17
|
+
// actual_budgets_switch refused outright rather than writing somewhere nobody
|
|
18
|
+
// reads. That refusal was correct: the alternative reports success while the
|
|
19
|
+
// budget never changes, which is the silent-no-op class of #347.
|
|
20
|
+
//
|
|
21
|
+
// WHY NOT A GLOBAL. #156 removed a process-global activeBudgetKey because in
|
|
22
|
+
// multi-user HTTP mode one user's switch changed the budget for everyone,
|
|
23
|
+
// across ACL boundaries. Reintroducing a global "just for stdio" would put that
|
|
24
|
+
// back in shared code. A per-PROCESS id is safe precisely because stdio is
|
|
25
|
+
// single-user by construction: one process, one pipe, one human. Two stdio
|
|
26
|
+
// processes get two ids and two slots, so nothing leaks between them.
|
|
27
|
+
//
|
|
28
|
+
// A uuid rather than a fixed string, so log lines are distinguishable across
|
|
29
|
+
// restarts and can never collide with an HTTP session id.
|
|
30
|
+
const stdioSessionId = `stdio-${randomUUID()}`;
|
|
31
|
+
logger.debug(`[STDIO] session id ${stdioSessionId}`);
|
|
9
32
|
const server = new Server({ name: serverDescription || 'actual-mcp-server', version: version || '0.1.0' }, { capabilities, instructions: serverInstructions });
|
|
10
33
|
// List tools handler — mirrors createServerInstance() in httpServer.ts
|
|
11
34
|
server.setRequestHandler(ListToolsRequestSchema, async () => {
|
|
@@ -33,7 +56,29 @@ export async function startStdioServer(mcp, capabilities, implementedTools, serv
|
|
|
33
56
|
throw new Error('Tool name must be a string');
|
|
34
57
|
}
|
|
35
58
|
logger.debug(`[STDIO] tools/call ${rawName}`);
|
|
36
|
-
|
|
59
|
+
// Wrap the DISPATCH, not server.connect(). The transport invokes this handler
|
|
60
|
+
// from an I/O event, which is outside any scope established around connect(),
|
|
61
|
+
// so an AsyncLocalStorage context opened there would not be visible here.
|
|
62
|
+
// Everything downstream is awaited inside this callback, so it propagates.
|
|
63
|
+
//
|
|
64
|
+
// Only `sessionId` is placed in the store, deliberately:
|
|
65
|
+
// - no `principal`, or getActiveBudgetConfig() would attempt the #189
|
|
66
|
+
// preferred-budget restore for a caller that has no authenticated
|
|
67
|
+
// identity, and the env default would stop being authoritative.
|
|
68
|
+
// - `transport: 'stdio'` is what keeps stdio OFF the pooled path.
|
|
69
|
+
// switchBudget's slow path calls connectionPool.getConnection(), so
|
|
70
|
+
// WITHOUT this marker the first switch would silently move stdio onto
|
|
71
|
+
// the pooled branch: writes would then rely on api.sync() instead of the
|
|
72
|
+
// legacy init/shutdown cycle every existing stdio persistence behaviour
|
|
73
|
+
// is built on, and the entry would never be touched (touch() is called
|
|
74
|
+
// only from httpServer.ts), so it would expire after the idle timeout and
|
|
75
|
+
// be torn down by the cleanup sweep without the api lock, possibly
|
|
76
|
+
// mid-operation. That is a far larger change than this ticket, and it is
|
|
77
|
+
// not one we want by accident.
|
|
78
|
+
// - `allowedBudgets` is absent, so under AUTH_PROVIDER=oidc switchBudget
|
|
79
|
+
// still denies. Fail closed: a local pipe must not gain budget access an
|
|
80
|
+
// HTTP caller would need an ACL for.
|
|
81
|
+
const result = await requestContext.run({ sessionId: stdioSessionId, transport: 'stdio' }, () => mcp.executeTool(rawName, args ?? {}));
|
|
37
82
|
return {
|
|
38
83
|
content: [{ type: 'text', text: typeof result === 'string' ? result : JSON.stringify(result) }],
|
|
39
84
|
};
|
|
@@ -3,13 +3,65 @@ import adapter from '../lib/actual-adapter.js';
|
|
|
3
3
|
const InputSchema = z.object({
|
|
4
4
|
id: z.string().describe('Account ID to delete'),
|
|
5
5
|
});
|
|
6
|
+
/**
|
|
7
|
+
* #347: this tool used to return `{success: true}` unconditionally, which was a
|
|
8
|
+
* lie for CLOSED accounts.
|
|
9
|
+
*
|
|
10
|
+
* Verified in upstream source. `api.deleteAccount(id)` maps to
|
|
11
|
+
* `account-close({id, forced: true})`, and `closeAccount` opens with:
|
|
12
|
+
*
|
|
13
|
+
* const account = await db.first('SELECT * FROM accounts WHERE id = ? AND tombstone = 0', [id]);
|
|
14
|
+
* if (!account || account.closed === 1) return; // <- silent no-op
|
|
15
|
+
*
|
|
16
|
+
* So a closed account is a no-op with no error, and the tool reported success
|
|
17
|
+
* anyway. Confirmed against a live server: nine deletions all reported success
|
|
18
|
+
* and the seven closed ones survived.
|
|
19
|
+
*
|
|
20
|
+
* WHY VERIFY AFTER, NOT CHECK BEFORE. The obvious fix is to look the account up
|
|
21
|
+
* first and refuse if it is closed. That breaks a case which looks identical from
|
|
22
|
+
* here but is not: `closeAccount` DELETES (tombstones) an account that has zero
|
|
23
|
+
* transactions, and `reopenAccount` only clears the `closed` flag, never the
|
|
24
|
+
* tombstone. `getAccounts()` filters `tombstone = 0`, so a close-then-reopen
|
|
25
|
+
* leaves an id that no longer appears in any listing. A pre-check would call that
|
|
26
|
+
* "not found" and throw, where the caller's intent (make this account not exist)
|
|
27
|
+
* is in fact already satisfied. The repo's own teardown idiom does exactly this:
|
|
28
|
+
* close first, then delete.
|
|
29
|
+
*
|
|
30
|
+
* Verifying AFTER answers the question the caller actually asked, "is it gone?",
|
|
31
|
+
* rather than a proxy for it. It also closes a race the pre-check could not: the
|
|
32
|
+
* read and the write land in different sessions, so a concurrent close could
|
|
33
|
+
* commit between them and reproduce the original false success.
|
|
34
|
+
*
|
|
35
|
+
* The deliberate consequence is that deleting an id that never existed still
|
|
36
|
+
* reports success. That is idempotent-delete semantics, it matches the behaviour
|
|
37
|
+
* before this change, and the post-condition ("no account with this id exists")
|
|
38
|
+
* is true either way.
|
|
39
|
+
*/
|
|
6
40
|
const tool = {
|
|
7
41
|
name: 'actual_accounts_delete',
|
|
8
|
-
description: 'Delete an account from Actual Budget.
|
|
42
|
+
description: 'Delete an account from Actual Budget. DESTRUCTIVE: if the account has transactions, they are ' +
|
|
43
|
+
'deleted too, paired transfer transactions are unlinked, and the transfer payee is removed. This ' +
|
|
44
|
+
'cannot be undone. A CLOSED account cannot be deleted (Actual silently ignores it): reopen it with ' +
|
|
45
|
+
'actual_accounts_reopen first. Deleting an account that does not exist reports success.',
|
|
9
46
|
inputSchema: InputSchema,
|
|
10
47
|
call: async (args, _meta) => {
|
|
11
48
|
const input = InputSchema.parse(args || {});
|
|
12
49
|
await adapter.deleteAccount(input.id);
|
|
50
|
+
// The verification the old code skipped. One read, after the write, so the
|
|
51
|
+
// success claim is about the observed state rather than about the call having
|
|
52
|
+
// returned.
|
|
53
|
+
const remaining = (await adapter.getAccounts());
|
|
54
|
+
const survivor = Array.isArray(remaining) ? remaining.find((a) => a?.id === input.id) : undefined;
|
|
55
|
+
if (survivor) {
|
|
56
|
+
// Reachable when the account is closed: upstream returned without acting.
|
|
57
|
+
// Anything else that leaves it present is equally worth surfacing, so the
|
|
58
|
+
// message covers the general case and names the likely one.
|
|
59
|
+
throw new Error(`Account "${survivor.name ?? input.id}" (${input.id}) still exists after the delete` +
|
|
60
|
+
(survivor.closed === true
|
|
61
|
+
? ': it is CLOSED, and Actual silently ignores a delete on a closed account. Reopen it with ' +
|
|
62
|
+
'actual_accounts_reopen and delete it again, or leave it closed if you only wanted it out of the way.'
|
|
63
|
+
: '. The delete was accepted but had no effect; check the account state in Actual.'));
|
|
64
|
+
}
|
|
13
65
|
return { success: true };
|
|
14
66
|
},
|
|
15
67
|
};
|
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.11.
|
|
4
|
+
"version": "0.11.6",
|
|
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/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/compose_profile_sync.test.js && node tests/unit/advertised_tools_sync.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/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/budgets_transfer.test.js && node tests/unit/transactions_uncategorized.test.js && node tests/unit/httpServer_session_init.test.js && node tests/unit/httpServer_session_not_found.test.js && node tests/unit/manual_mcp_client_retry.test.js && node tests/unit/manual_mcp_client_session.test.js && node tests/unit/manual_mcp_client_circuit.test.js && node tests/unit/manual_runner_killswitch.test.js && node tests/unit/adapter_auth_rate_limit.test.js && node tests/unit/adapter_session_reuse.test.js && node tests/unit/retry_classifier.test.js && node tests/unit/adapter_module_surface.test.js && node tests/unit/adapter_nonidempotent_no_retry.test.js && node tests/unit/pool_liveness.test.js && node tests/unit/pool_shutdown_all.test.js && node tests/unit/query_where_operators.test.js && node tests/unit/query_run_validation.test.js && node tests/unit/adapter_with_write_session.test.js && node tests/unit/category_groups_delete.test.js && node tests/unit/rules_delete.test.js && node tests/unit/schedules_delete.test.js && node tests/unit/payees_delete.test.js && node tests/unit/rules_create_or_update.test.js && node tests/unit/unhandled-rejection.test.js && node tests/unit/rejection-allowlist-purity.test.js && node tests/unit/httpServer_bearer_auth.test.js && node tests/unit/httpServer_oidc_audience.test.js && node tests/unit/httpServer_oidc_auth_verification.test.js && node tests/unit/httpServer_oidc_jwks_discovery.test.js && node tests/unit/httpServer_body_limit.test.js && node tests/unit/httpServer_bind_host.test.js && node tests/unit/adapter_write_pool_cooperation.test.js && node tests/unit/budget_acl_enforcement.test.js && node tests/unit/budget_preference_store.test.js && node tests/unit/budget_preference_restore.test.js && node tests/unit/tags_list.test.js && node tests/unit/tags_create.test.js && node tests/unit/tags_update.test.js && node tests/unit/tags_delete.test.js && node tests/unit/notes_get.test.js && node tests/unit/notes_update.test.js && node tests/unit/payees_common_list.test.js && node tests/unit/transactions_update_guard.test.js && node tests/unit/transactions_update_splits.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/tool_count_sync.test.js && node tests/unit/stdio_transport.test.js && node tests/unit/logger_structured.test.js && node tests/unit/logger_redaction.test.js && node tests/unit/logger_correlation.test.js && node tests/unit/check_coverage.test.js && 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/observability_smoke.test.js && node tests/unit/residue_pattern.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/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/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",
|
|
33
|
+
"test:unit-js": "node tests/unit/transactions_create.test.js && node tests/unit/generated_tools.smoke.test.js && node tests/unit/schema_validation.test.js && node tests/unit/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/compose_profile_sync.test.js && node tests/unit/advertised_tools_sync.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/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/budgets_transfer.test.js && node tests/unit/transactions_uncategorized.test.js && node tests/unit/httpServer_session_init.test.js && node tests/unit/httpServer_session_not_found.test.js && node tests/unit/manual_mcp_client_retry.test.js && node tests/unit/manual_mcp_client_session.test.js && node tests/unit/manual_mcp_client_circuit.test.js && node tests/unit/manual_runner_killswitch.test.js && node tests/unit/adapter_auth_rate_limit.test.js && node tests/unit/adapter_session_reuse.test.js && node tests/unit/retry_classifier.test.js && node tests/unit/adapter_module_surface.test.js && node tests/unit/adapter_nonidempotent_no_retry.test.js && node tests/unit/pool_liveness.test.js && node tests/unit/pool_shutdown_all.test.js && node tests/unit/query_where_operators.test.js && node tests/unit/query_run_validation.test.js && node tests/unit/adapter_with_write_session.test.js && node tests/unit/category_groups_delete.test.js && node tests/unit/rules_delete.test.js && node tests/unit/schedules_delete.test.js && node tests/unit/accounts_delete.test.js && node tests/unit/payees_delete.test.js && node tests/unit/rules_create_or_update.test.js && node tests/unit/unhandled-rejection.test.js && node tests/unit/rejection-allowlist-purity.test.js && node tests/unit/httpServer_bearer_auth.test.js && node tests/unit/httpServer_oidc_audience.test.js && node tests/unit/httpServer_oidc_auth_verification.test.js && node tests/unit/httpServer_oidc_jwks_discovery.test.js && node tests/unit/httpServer_body_limit.test.js && node tests/unit/httpServer_bind_host.test.js && node tests/unit/adapter_write_pool_cooperation.test.js && node tests/unit/budget_acl_enforcement.test.js && node tests/unit/budget_preference_store.test.js && node tests/unit/budget_preference_restore.test.js && node tests/unit/tags_list.test.js && node tests/unit/tags_create.test.js && node tests/unit/tags_update.test.js && node tests/unit/tags_delete.test.js && node tests/unit/notes_get.test.js && node tests/unit/notes_update.test.js && node tests/unit/payees_common_list.test.js && node tests/unit/transactions_update_guard.test.js && node tests/unit/transactions_update_splits.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/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/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/observability_smoke.test.js && node tests/unit/residue_pattern.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/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",
|
|
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",
|