actual-mcp-server 0.11.4 → 0.11.5

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.5 | **Tool Count:** 74 (verified LibreChat-compatible)
@@ -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 {
@@ -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
  };
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.5",
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/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/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",