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.4 | **Tool Count:** 74 (verified LibreChat-compatible)
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
- * Stdio / non-session callers fall back to the env-default budget and
1775
- * cannot switch (returns an error).
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. Stdio/local callers operate on the env-default budget; ' +
1794
- 'configure ACTUAL_BUDGET_SYNC_ID (or the BUDGET_n_* variants) to select a different default.');
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
- if (_skipApiInitForTests) {
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
- return queueWriteOperation(async () => {
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
- const result = await mcp.executeTool(rawName, args ?? {});
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. Note: The account must not have any transactions. This operation cannot be undone.',
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",
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",