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 +2 -2
- package/dist/src/lib/actual-adapter.js +24 -5
- package/dist/src/server/stdioServer.js +46 -1
- package/package.json +2 -2
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.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
|
-
*
|
|
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 {
|
|
@@ -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
|
};
|
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.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",
|