actual-mcp-server 0.6.31 → 0.6.32

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
@@ -730,4 +730,4 @@ The software is provided **as-is**, without warranty of any kind. The author acc
730
730
 
731
731
  ---
732
732
 
733
- **Version:** 0.6.31 | **Tool Count:** 63 (verified LibreChat-compatible)
733
+ **Version:** 0.6.32 | **Tool Count:** 63 (verified LibreChat-compatible)
package/dist/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "actual-mcp-server",
3
3
  "displayName": "Actual MCP Server",
4
- "version": "0.6.31",
4
+ "version": "0.6.32",
5
5
  "engines": {
6
6
  "node": ">=20.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/config_https_validation.test.js && node tests/unit/config_insecure_upstream.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_body_limit.test.js && node tests/unit/adapter_write_pool_cooperation.test.js && node tests/unit/budget_acl_enforcement.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/config_https_validation.test.js && node tests/unit/config_insecure_upstream.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_body_limit.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",
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",
@@ -15,6 +15,7 @@ import retry, { isRetryableError } from './retry.js';
15
15
  import logger from '../logger.js';
16
16
  import config from '../config.js';
17
17
  import { parseBudgetRegistry } from './budget-registry.js';
18
+ import { getPreferredBudgetSyncId, setPreferredBudgetSyncId, pickAllowedPreferredBudget } from './budget-preference-store.js';
18
19
  import { requestContext } from './requestContext.js';
19
20
  import { connectionPool } from './ActualConnectionPool.js';
20
21
  import { isApiInitialized, setApiInitialized } from './apiState.js';
@@ -54,7 +55,8 @@ function getActiveBudgetConfig() {
54
55
  // call works at runtime. If we're not in any requestContext.run scope (stdio,
55
56
  // startup health checks), sessionId is undefined and we fall back to the
56
57
  // env-default budget (first registry entry).
57
- const sessionId = requestContext.getStore()?.sessionId;
58
+ const store = requestContext.getStore();
59
+ const sessionId = store?.sessionId;
58
60
  if (sessionId) {
59
61
  const key = sessionBudgetState.get(sessionId);
60
62
  if (key) {
@@ -62,6 +64,16 @@ function getActiveBudgetConfig() {
62
64
  if (found)
63
65
  return found;
64
66
  }
67
+ // #189 Phase 1: no in-session selection yet (e.g. a fresh session after a
68
+ // server restart + client re-initialize). Restore the principal's persisted
69
+ // budget, but ONLY if the live ACL still permits it (pickAllowedPreferredBudget
70
+ // re-checks allowedBudgets, so a stale preference can never widen access).
71
+ // Memoize into the session slot so subsequent calls take the fast path above.
72
+ const restored = pickAllowedPreferredBudget(getPreferredBudgetSyncId(store?.principal), store?.allowedBudgets, [...budgetRegistry.values()]);
73
+ if (restored) {
74
+ sessionBudgetState.set(sessionId, restored.name.toLowerCase());
75
+ return restored;
76
+ }
65
77
  }
66
78
  return [...budgetRegistry.values()][0];
67
79
  }
@@ -1607,6 +1619,10 @@ export async function switchBudget(name) {
1607
1619
  throw new Error(`Budget ACL: budget "${found.name}" (${found.syncId}) is not in this session's allowedBudgets.`);
1608
1620
  }
1609
1621
  }
1622
+ // #189 Phase 1: the principal's chosen budget is persisted at each commit
1623
+ // point below (paired with the sessionBudgetState write), NOT here, so a
1624
+ // switch that throws before committing never leaves a stale preference. The
1625
+ // helper is keyed by a hash of the principal and never throws.
1610
1626
  // Fast path (#172): if the current pool entry's auth descriptor matches the
1611
1627
  // target budget's (same serverUrl + password + encryptionPassword), skip
1612
1628
  // release + re-auth. Just download the new budget file on the already-
@@ -1620,6 +1636,7 @@ export async function switchBudget(name) {
1620
1636
  if (sameAuth && currentEntry.syncId === found.syncId) {
1621
1637
  // No-op: already on this exact budget. Keep session map consistent and return.
1622
1638
  sessionBudgetState.set(sessionId, key);
1639
+ setPreferredBudgetSyncId(store?.principal, found.syncId); // #189: persist at commit
1623
1640
  logger.info(`[ADAPTER] switchBudget no-op for session ${sessionId}: already on "${found.name}" (${found.syncId})`);
1624
1641
  return { name: found.name, syncId: found.syncId, serverUrl: found.serverUrl };
1625
1642
  }
@@ -1644,6 +1661,7 @@ export async function switchBudget(name) {
1644
1661
  }
1645
1662
  connectionPool.updateLoadedSyncId(sessionId, found.syncId);
1646
1663
  sessionBudgetState.set(sessionId, key);
1664
+ setPreferredBudgetSyncId(store?.principal, found.syncId); // #189: persist at commit
1647
1665
  logger.info(`[ADAPTER] Active budget switched for session ${sessionId} to: "${found.name}" (${found.syncId}) on ${found.serverUrl}`);
1648
1666
  return { name: found.name, syncId: found.syncId, serverUrl: found.serverUrl };
1649
1667
  }
@@ -1659,6 +1677,7 @@ export async function switchBudget(name) {
1659
1677
  // Update the per-session active-budget slot. Subsequent getActiveBudgetConfig
1660
1678
  // calls for this session now return the new budget.
1661
1679
  sessionBudgetState.set(sessionId, key);
1680
+ setPreferredBudgetSyncId(store?.principal, found.syncId); // #189: persist at commit
1662
1681
  // Materialise a fresh pool entry bound to the new budget. Without this, the
1663
1682
  // next withActualApi call would find no pool entry and fall back to the
1664
1683
  // legacy init+shutdown path. Failure here is logged but not fatal: the
@@ -0,0 +1,97 @@
1
+ // Per-principal active-budget preference store (#189, Phase 1 of #173).
2
+ //
3
+ // After a server restart the client re-initializes and gets a NEW session, so
4
+ // the per-session active-budget selection (sessionBudgetState in actual-adapter)
5
+ // is lost and the user silently reverts to the env-default budget. This store
6
+ // remembers a principal's last active budget so the new session can restore it.
7
+ //
8
+ // Security model (see #189):
9
+ // * The key at rest is sha256(principal) hex, never the raw OIDC sub / email /
10
+ // token. The file maps hash -> budget syncId only.
11
+ // * It is NEVER trusted as authorization: the caller re-checks the live ACL
12
+ // (allowedBudgets) before applying a restored preference, so a stale
13
+ // preference can never widen access (pickAllowedPreferredBudget below).
14
+ // * It is keyed per principal, not per session-id, so it does not participate
15
+ // in the #167 session-liveness pool and cannot drift with it.
16
+ // * Missing / corrupt / unwritable file degrades to a no-op (the feature
17
+ // simply does nothing); it never throws into the request path.
18
+ import { createHash } from 'node:crypto';
19
+ import { readFileSync, writeFileSync, renameSync, mkdirSync, chmodSync } from 'node:fs';
20
+ import { dirname, join } from 'node:path';
21
+ import logger from '../logger.js';
22
+ import config from '../config.js';
23
+ const FILE_NAME = 'budget-preferences.json';
24
+ /** sha256 hex of the principal. Never store the raw principal. */
25
+ function hashPrincipal(principal) {
26
+ return createHash('sha256').update(principal, 'utf8').digest('hex');
27
+ }
28
+ function storePath() {
29
+ const dir = config.MCP_BRIDGE_DATA_DIR;
30
+ if (!dir)
31
+ return null;
32
+ return join(dir, FILE_NAME);
33
+ }
34
+ function readAll() {
35
+ const p = storePath();
36
+ if (!p)
37
+ return {};
38
+ try {
39
+ const parsed = JSON.parse(readFileSync(p, 'utf8'));
40
+ return parsed && typeof parsed === 'object' ? parsed : {};
41
+ }
42
+ catch {
43
+ // Missing or corrupt file: treat as empty. The feature no-ops rather than
44
+ // crashing the request that triggered the read.
45
+ return {};
46
+ }
47
+ }
48
+ /** The budget syncId this principal last selected, or undefined. */
49
+ export function getPreferredBudgetSyncId(principal) {
50
+ if (!principal)
51
+ return undefined;
52
+ return readAll()[hashPrincipal(principal)];
53
+ }
54
+ /** Persist the principal's active budget. Best-effort: never throws. */
55
+ export function setPreferredBudgetSyncId(principal, syncId) {
56
+ const p = storePath();
57
+ if (!p || !principal || !syncId)
58
+ return;
59
+ try {
60
+ const all = readAll();
61
+ if (all[hashPrincipal(principal)] === syncId)
62
+ return; // unchanged, skip write
63
+ all[hashPrincipal(principal)] = syncId;
64
+ mkdirSync(dirname(p), { recursive: true });
65
+ // Atomic write: temp + rename so a crash mid-write cannot corrupt the file.
66
+ const tmp = `${p}.tmp`;
67
+ writeFileSync(tmp, JSON.stringify(all), { mode: 0o600 });
68
+ renameSync(tmp, p);
69
+ try {
70
+ chmodSync(p, 0o600);
71
+ }
72
+ catch { /* best-effort perms */ }
73
+ }
74
+ catch (e) {
75
+ logger.debug(`[BUDGET-PREF] failed to persist preference (non-fatal): ${e instanceof Error ? e.message : e}`);
76
+ }
77
+ }
78
+ /**
79
+ * Pure restore decision: given a principal's stored syncId, the live ACL, and
80
+ * the budget registry, return the budget to restore, or undefined.
81
+ *
82
+ * The ACL re-check is the security boundary: a stored preference is applied
83
+ * ONLY if `allowedBudgets` is unrestricted (`['*']`) or contains the budget's
84
+ * syncId. This is why a stale preference can never widen access. Extracted as a
85
+ * pure function so it is unit-testable without the adapter / requestContext.
86
+ */
87
+ export function pickAllowedPreferredBudget(storedSyncId, allowedBudgets, registryValues) {
88
+ if (!storedSyncId)
89
+ return undefined;
90
+ const found = registryValues.find(b => b.syncId === storedSyncId);
91
+ if (!found)
92
+ return undefined;
93
+ if (allowedBudgets && !allowedBudgets.includes('*') && !allowedBudgets.includes(found.syncId)) {
94
+ return undefined; // ACL no longer permits this budget
95
+ }
96
+ return found;
97
+ }
@@ -21,6 +21,16 @@ import * as fs from 'node:fs';
21
21
  // `requestContext` from this module.
22
22
  import { requestContext } from '../lib/requestContext.js';
23
23
  export { requestContext };
24
+ // Resolve the authenticated principal for the per-principal budget preference
25
+ // (#189). OIDC: the verified JWT subject. Static-bearer: a single shared
26
+ // identity (all bearer callers are the same user). Auth-disabled: undefined, so
27
+ // the preference simply no-ops. Never derived from a client-supplied value.
28
+ function resolvePrincipal(req) {
29
+ if (config.AUTH_PROVIDER === 'oidc') {
30
+ return req.auth?.subject;
31
+ }
32
+ return config.MCP_SSE_AUTHORIZATION ? 'static-bearer' : undefined;
33
+ }
24
34
  export async function startHttpServer(mcp, port, httpPath, capabilities, // was passed by index.ts
25
35
  implementedTools, // was passed by index.ts
26
36
  serverDescription, // was passed by index.ts
@@ -379,7 +389,7 @@ bindHost = 'localhost', advertisedUrl) {
379
389
  // Run in AsyncLocalStorage context so tools can access sessionId
380
390
  // and the adapter can enforce per-request budget ACL (#156).
381
391
  const allowedBudgetsInit = req.allowedBudgets;
382
- await requestContext.run({ sessionId: undefined, allowedBudgets: allowedBudgetsInit }, async () => {
392
+ await requestContext.run({ sessionId: undefined, allowedBudgets: allowedBudgetsInit, principal: resolvePrincipal(req) }, async () => {
383
393
  await transport.handleRequest(req, res, req.body);
384
394
  });
385
395
  }
@@ -473,7 +483,7 @@ bindHost = 'localhost', advertisedUrl) {
473
483
  // Run in AsyncLocalStorage context so tools and the adapter can access
474
484
  // sessionId (pool branch, #134) and allowedBudgets (ACL enforcement, #156).
475
485
  const allowedBudgets = req.allowedBudgets;
476
- await requestContext.run({ sessionId, allowedBudgets }, async () => {
486
+ await requestContext.run({ sessionId, allowedBudgets, principal: resolvePrincipal(req) }, async () => {
477
487
  await transport.handleRequest(req, res, req.body);
478
488
  });
479
489
  }
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.6.31",
4
+ "version": "0.6.32",
5
5
  "engines": {
6
6
  "node": ">=20.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/config_https_validation.test.js && node tests/unit/config_insecure_upstream.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_body_limit.test.js && node tests/unit/adapter_write_pool_cooperation.test.js && node tests/unit/budget_acl_enforcement.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/config_https_validation.test.js && node tests/unit/config_insecure_upstream.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_body_limit.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",
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",