# Architecture **Project:** Actual MCP Server **Last Updated:** 2026-06-07 --- ## Table of Contents - [System Architecture](#system-architecture) - [Component Overview](#component-overview) - [Data Flow](#data-flow) - [Module Structure](#module-structure) - [Execution Lifecycle](#execution-lifecycle) - [Configuration](#configuration) - [Transport Protocols](#transport-protocols) - [Error Handling](#error-handling) - [Performance & Reliability](#performance--reliability) --- ## System Architecture ### High-Level Diagram ``` ┌─────────────────┐ ┌──────────────────┐ ┌─────────────────┐ │ │ MCP │ │ REST │ │ │ MCP Client │◄────────┤ Actual MCP │◄────────┤ Actual Budget │ │ (LibreChat) │ │ Server │ │ Server │ │ │ │ │ │ │ └─────────────────┘ └──────────────────┘ └─────────────────┘ │ │ SQLite ▼ ┌─────────────────┐ │ Local Cache │ │ (Budget Data) │ └─────────────────┘ ``` ### Component Layers ``` ┌───────────────────────────────────────────────────────────┐ │ Client Layer (LibreChat, etc.) │ └───────────────────────────────────────────────────────────┘ │ HTTP / WebSocket ▼ ┌───────────────────────────────────────────────────────────┐ │ Transport Layer │ │ ┌──────────┐ │ │ │ HTTP │ │ │ │ Server │ │ │ └──────────┘ │ └───────────────────────────────────────────────────────────┘ │ MCP Protocol ▼ ┌───────────────────────────────────────────────────────────┐ │ MCP Protocol Layer │ │ ┌────────────────────────────────────────────────────┐ │ │ │ ActualMCPConnection │ │ │ │ (Request routing, response handling) │ │ │ └────────────────────────────────────────────────────┘ │ └───────────────────────────────────────────────────────────┘ │ Tool Calls ▼ ┌───────────────────────────────────────────────────────────┐ │ Business Logic Layer │ │ ┌────────────────────────────────────────────────────┐ │ │ │ ActualToolsManager │ │ │ │ (Tool registry, validation, dispatch) │ │ │ └────────────────────────────────────────────────────┘ │ │ │ │ ┌─────┐ ┌─────┐ ┌─────┐ ┌─────┐ ┌─────┐ │ │ │Tool1│ │Tool2│ │Tool3│ │ ... │ │ 70 │ │ │ └─────┘ └─────┘ └─────┘ └─────┘ └─────┘ │ └───────────────────────────────────────────────────────────┘ │ Adapter Functions ▼ ┌───────────────────────────────────────────────────────────┐ │ Data Access Layer │ │ ┌────────────────────────────────────────────────────┐ │ │ │ Actual Adapter │ │ │ │ (Retry logic, concurrency control, error mapping)│ │ │ └────────────────────────────────────────────────────┘ │ └───────────────────────────────────────────────────────────┘ │ @actual-app/api ▼ ┌───────────────────────────────────────────────────────────┐ │ External API Layer │ │ ┌────────────────────────────────────────────────────┐ │ │ │ Actual Budget API Client │ │ │ │ (Official @actual-app/api package) │ │ │ └────────────────────────────────────────────────────┘ │ └───────────────────────────────────────────────────────────┘ │ REST API / SQLite ▼ ┌───────────────────────────────────────────────────────────┐ │ Actual Budget Server │ │ (External Service) │ └───────────────────────────────────────────────────────────┘ ``` --- ## Component Overview ### Core Modules | Module | File | Responsibility | Key Functions | |--------|------|----------------|---------------| | **Main Entry** | `src/index.ts` | Orchestration, CLI parsing, server startup | `main()` | | **Connection Manager** | `src/actualConnection.ts` | Actual Budget API lifecycle | `connectToActual()`, `shutdownActual()` | | **Tool Manager** | `src/actualToolsManager.ts` | Tool registry and dispatch | `registerTools()`, `callTool()` | | **MCP Connection** | `src/lib/ActualMCPConnection.ts` | MCP protocol implementation | `handleToolCall()`, `handleRequest()` | | **Adapter Layer** | `src/lib/actual-adapter.ts` (+ leaf modules in `src/lib/actual-adapter/`) | `withActualApi` wrapper: retry, concurrency, pooled vs legacy execution (#134, split in #166) | All Actual API functions | | **Connection Pool** | `src/lib/ActualConnectionPool.ts` | Per-session connections (up to `MAX_CONCURRENT_SESSIONS`, default 15, counting sessions still connecting: a slot is reserved at initialize, #581), idle timeouts | `getConnection()`, `touch()`, `shutdownConnection()` | | **Request Context** | `src/lib/requestContext.ts` | `AsyncLocalStorage` carrying the active session id (and principal) across async boundaries | `requestContext.run()` | | **Singleton State** | `src/lib/apiState.ts` | Shared flag for the `@actual-app/api` singleton "live" state, probed by the adapter to decide pool reuse | `isApiInitialized()` | | **Configuration** | `src/config.ts` | Environment validation | `config`, `configSchema` | | **Logger** | `src/logger.ts` | Structured logging | `logger` singleton | | **Observability** | `src/observability.ts` | Metrics collection | `incrementToolCall()`, `getMetricsText()` | ### Transport Implementations | Transport | File | Status | Authentication | LibreChat Support | |-----------|------|--------|----------------|-------------------| | **HTTP** | `src/server/httpServer.ts` | ✅ Production | Bearer token OR OIDC/JWT | ✅ Fully supported | | **WebSocket** | *(removed)* | ❌ Removed | N/A | ❌ Not supported | | **SSE** | *(removed)* | ❌ Removed | N/A | N/A | ### Tool Definitions 84 tools organized by category: ``` src/tools/ ├── server_info.ts # Server version / connection info ├── server_get_version.ts # Actual Budget server version ├── get_id_by_name.ts # Exact name → UUID lookup (accounts/categories/payees/schedules) ├── entities_search.ts # Pattern/fuzzy name search for accounts/categories/payees (#204) ├── get_context.ts # High-level budget structure (accounts/categories/payees) in one call (#484) ├── session_list.ts # Session management ├── session_close.ts ├── account_groups_list.ts # Account groups (#429, Actual 26.9.0+) ├── account_groups_create.ts # create / update / delete ├── account_groups_update.ts ├── account_groups_delete.ts ├── accounts_create.ts # Accounts ├── accounts_list.ts ├── accounts_update.ts ├── accounts_delete.ts ├── accounts_close.ts ├── accounts_reopen.ts ├── accounts_get_balance.ts ├── account_flow_summary.ts # Cross-account balance-change reconciliation ├── transactions_aggregate.ts # Transactions (deterministic analysis) ├── recurring_expenses_summary.ts # Recurring-charge detection (heuristic) ├── transactions_create.ts ├── transactions_get.ts ├── transactions_update.ts ├── transactions_delete.ts ├── transactions_import.ts ├── transactions_merge.ts ├── transactions_filter.ts ├── transactions_search_by_amount.ts ├── transactions_search_by_category.ts ├── transactions_search_by_month.ts ├── transactions_search_by_payee.ts ├── transactions_summary_by_category.ts ├── transactions_summary_by_payee.ts ├── transfers_create.ts # Transfers ├── budgets_list_available.ts # Budgets ├── budgets_switch.ts ├── budgets_getMonth.ts ├── budgets_getMonths.ts ├── budgets_get_all.ts ├── budgets_setAmount.ts ├── budgets_transfer.ts ├── budgets_setCarryover.ts ├── budgets_holdForNextMonth.ts ├── budgets_resetHold.ts ├── budgets_export.ts # #332: writes a zip to ACTUAL_EXPORT_DIR ├── budgets_import.ts # #334: LOADS the imported budget (active budget changes) ├── budget_updates_batch.ts ├── preferences_get.ts # Preferences (#333) ├── categories_get.ts # Categories ├── categories_create.ts ├── categories_update.ts ├── categories_delete.ts ├── category_groups_get.ts # Category Groups ├── category_groups_create.ts ├── category_groups_update.ts ├── category_groups_delete.ts ├── payees_get.ts # Payees ├── payees_common_list.ts ├── payees_create.ts ├── payees_update.ts ├── payees_delete.ts ├── payees_merge.ts ├── payee_rules_get.ts # Payee Rules ├── rules_get.ts # Rules ├── rules_create.ts ├── rules_create_batch.ts ├── rules_create_or_update.ts ├── rules_update.ts ├── rules_delete.ts ├── tags_list.ts # Tags ├── tags_create.ts ├── tags_update.ts ├── tags_delete.ts ├── notes_get.ts # Notes ├── notes_update.ts ├── query_run.ts # Advanced ActualQL queries ├── bank_sync.ts # Bank synchronization └── index.ts # Tool exports ``` --- ## Data Flow ### Request Flow ``` 1. Client sends MCP request │ ├──> HTTP POST /http │ 2. Transport layer receives request │ └──> Parses JSON-RPC 2.0 format │ 3. ActualMCPConnection routes request │ ├──> tools/list → Returns available tools ├──> tools/call → Dispatches to ActualToolsManager └──> Other MCP methods │ 4. ActualToolsManager validates and calls tool │ ├──> Validates tool name exists ├──> Validates input schema (Zod) └──> Calls tool implementation function │ 5. Tool calls Actual Adapter function │ └──> Adapter applies retry logic & concurrency control │ 6. @actual-app/api makes REST call │ └──> Actual Budget Server processes request │ 7. Response flows back up the stack │ └──> JSON result or error returned to client ``` ### Tool Call Example ```typescript // Client request (MCP format) { "jsonrpc": "2.0", "id": 1, "method": "tools/call", "params": { "name": "actual_transactions_create", "arguments": { "accountId": "uuid-123", "date": "2025-11-11", "amount": -5000, "payee": "Amazon" } } } // Server processing ActualMCPConnection.handleToolCall() └─> ActualToolsManager.callTool("actual_transactions_create", args) └─> transactionsCreate(args) in src/tools/transactions_create.ts └─> actualAdapter.addTransaction(args) in src/lib/actual-adapter.ts └─> api.addTransaction(args) from @actual-app/api └─> REST POST to Actual Budget Server // Server response { "jsonrpc": "2.0", "id": 1, "result": { "content": [{ "type": "text", "text": "Transaction ID: uuid-456" }] } } ``` --- ## Module Structure ### Project Layout ``` actual-mcp-server/ ├── src/ # TypeScript source code │ ├── index.ts # Main entry point │ ├── config.ts # Environment validation (Zod) │ ├── logger.ts # Winston logger singleton │ ├── observability.ts # Prometheus metrics │ ├── actualConnection.ts # Actual API connection manager │ ├── actualToolsManager.ts # Tool registry singleton │ ├── utils.ts # Utility functions │ ├── tests_adapter_runner.ts # Adapter test executor │ │ │ ├── lib/ # Core libraries │ │ ├── actual-adapter.ts # withActualApi wrapper (retry, concurrency, pool cooperation) │ │ ├── actual-adapter/ # Leaf modules: normalize, query, auth-retry, concurrency (#166) │ │ ├── actual-schema.ts # Actual DB schema for SQL validation │ │ ├── ActualConnectionPool.ts # Per-session connection pool (default 15, idle timeouts) │ │ ├── ActualMCPConnection.ts # MCP protocol handler (EventEmitter-based) │ │ ├── apiState.ts # Shared @actual-app/api singleton live-state flag │ │ ├── requestContext.ts # AsyncLocalStorage carrying sessionId / principal │ │ ├── budget-registry.ts # Parses BUDGET_N_* env vars into budget configs │ │ ├── budget-preference-store.ts # Per-principal preferred-budget store (#189) │ │ ├── constants.ts # Configuration constants and limits │ │ ├── errors.ts # notFoundMsg / constraintErrorMsg helpers │ │ ├── loggerFactory.ts # Module-scoped logger factory │ │ ├── query-validator.ts # ActualQL query validation │ │ ├── retry.ts # Exponential backoff retry logic │ │ ├── toolFactory.ts # createTool() factory helper │ │ └── schemas/ # Per-domain Zod schemas (CommonSchemas) │ │ │ ├── server/ # Transport implementations │ │ ├── httpServer.ts # HTTP transport (Express, StreamableHTTP, auth) │ │ └── stdioServer.ts # stdio transport (Claude Desktop/Code) │ │ │ ├── auth/ # Authentication │ │ ├── setup.ts # OIDC/JWKS factory (AUTH_PROVIDER=oidc) │ │ └── budget-acl.ts # Per-user budget ACL (email/sub/group) │ │ │ ├── tools/ # MCP tool definitions (84 tools + index.ts) │ │ ├── server_info.ts # Server info │ │ ├── session_*.ts # Session management │ │ ├── accounts_*.ts # Accounts │ │ ├── transactions_*.ts # Transactions (incl. search/summary) │ │ ├── budgets_*.ts # Budgets │ │ ├── budget_updates_batch.ts # Batch budget operations │ │ ├── categories_*.ts # Categories │ │ ├── category_groups_*.ts # Category groups │ │ ├── payees_*.ts # Payees │ │ ├── payee_rules_get.ts # Payee rules │ │ ├── rules_*.ts # Rules │ │ ├── tags_*.ts # Tags │ │ ├── notes_*.ts # Notes │ │ ├── query_run.ts # Advanced ActualQL queries │ │ ├── bank_sync.ts # Bank synchronization │ │ └── index.ts # Tool exports │ │ │ ├── types/ # TypeScript type definitions │ │ └── tool.d.ts # MCP tool types │ │ │ ├── prompts/ # MCP prompt templates │ │ └── showLargeTransactions.ts │ │ │ └── resources/ # MCP resources │ └── accountsSummary.ts │ ├── tests/ # Tests │ ├── e2e/ # End-to-end tests (Playwright) │ │ ├── mcp-client.playwright.spec.ts # Protocol compliance tests │ │ ├── docker.e2e.spec.ts # Docker smoke tests │ │ ├── docker-all-tools.e2e.spec.ts # All-tools Docker E2E (~80 named tests, all 84 tools) │ │ ├── run-docker-e2e.sh # Docker test orchestrator │ │ └── (#366: the suites/ tree was removed. It never executed, and every doc that │ │ named it now points at docker-all-tools.e2e.spec.ts instead) │ ├── unit/ # Unit tests (offline, stub adapter) │ │ ├── transactions_create.test.js │ │ ├── generated_tools.smoke.test.js │ │ └── schema_validation.test.js │ ├── shared/ # Shared test utilities │ │ ├── e2e-helpers.ts # TS helpers for E2E: waitForMCPHealth, retryRequest, callTool, extractResult │ │ └── mcp-protocol.js # JS mirror of extractResult (used by manual integration suite) │ └── manual/ # Live integration tests (real Actual Budget) │ ├── scripts/ # Build and utility scripts (see scripts/README.md) │ ├── verify-tools.js # Tool coverage verification │ ├── bootstrap-and-init.sh # Docker: bootstrap Actual server + import test budget │ ├── import-test-budget.sh # Upload test-data/*.zip to Actual server (inside the container; host copy in tests/fixtures/budgets/) │ ├── register-tsconfig-paths.js # Path alias resolver for dist/ runtime │ ├── list-actual-api-methods.mjs # API method coverage checker │ └── version-bump.js / version-check.js / version-dev.js # Versioning │ ├── docs/ # Documentation (this folder) ├── generated/ # Generated TypeScript types ├── actual-data/ # Budget data cache (gitignored) ├── logs/ # Application logs (gitignored) │ ├── Dockerfile # Production container ├── docker-compose.yaml # Docker Compose (dev/production profiles) ├── package.json # Dependencies and scripts ├── tsconfig.json # TypeScript configuration └── .env.example # Environment variable template ``` --- ## Execution Lifecycle ### Startup Sequence ``` 1. CLI Argument Parsing └─┾ src/index.ts parses --help, --debug, --http └─> --help exits early (before loading environment) 2. Environment Loading └─> dotenv loads .env file └─> src/config.ts validates with Zod schema └─> Exits with error if validation fails 3. Dynamic Imports └─> Lazy load all dependencies (winston, @actual-app/api, etc.) └─> Improves cold start performance 4. Actual Budget Connection └─> src/actualConnection.ts::connectToActual() └─> api.init({ dataDir, serverURL, password }) └─> api.downloadBudget(syncId, { password }) └─> Budget data cached to MCP_BRIDGE_DATA_DIR 5. Tool Registry Initialization └─> src/actualToolsManager.ts loads all tools └─> Validates tool schemas └─> Registers 84 tools with MCP capabilities 6. MCP Connection Setup └─> Create ActualMCPConnection instance └─> Build capabilities object (tools, resources, prompts) 7. Transport Server Startup └─┾ Start HTTP server └─> Bind to MCP_BRIDGE_PORT └─> Register health endpoints 8. Ready State └─> Log current server version and transport mode └─> Accept MCP requests ``` ### Shutdown Sequence ``` 1. SIGINT / SIGTERM received │ 2. Graceful shutdown initiated ├─┾ Close transport server (HTTP) ├─> Stop accepting new requests ├─> Wait for pending requests (timeout: 10s) │ 3. Actual Budget disconnection └─> src/actualConnection.ts::shutdownActual() └─> api.shutdown() - closes DB connections │ 4. Logger flush └─> Winston flushes remaining log entries │ 5. Process exit └─> Exit code 0 (clean shutdown) ``` ### Test Modes The server supports special test modes: ```bash # Test Actual Budget connection only npm run dev -- --test-actual-connection └─> Connects, downloads budget, disconnects, exits # Test MCP client interaction npm run dev -- --http --test-mcp-client └─> Starts server, sends test requests, verifies responses ``` --- ## Configuration ### Environment Variables All configuration via environment variables. The canonical inventory of every variable (type, default, source, read site) is [docs/reference/configuration.md](../reference/configuration.md); `.env.example` carries the same set with inline comments. A variable is canonical if it is a Zod schema key in `src/config.ts` OR an entry in `RAW_ENV_ALLOWLIST` in `src/lib/config-registry.ts`. The drift guard `scripts/config-drift.mjs` (run in CI via `tests/unit/config_drift.test.js`) fails the build if the schema/allowlist, `.env.example`, and that reference disagree, or when a documented default differs from the Zod default. The README carries only a short summary of the common variables. #### Required Variables ```bash # Actual Budget connection ACTUAL_SERVER_URL=http://localhost:5006 ACTUAL_PASSWORD=your_password ACTUAL_BUDGET_SYNC_ID=your_sync_id ``` #### Server Configuration ```bash # Server settings MCP_BRIDGE_PORT=3600 # Server port MCP_BRIDGE_DATA_DIR=./actual-data # Budget cache directory MCP_BRIDGE_BIND_HOST=0.0.0.0 # Bind address # Transport mode (Docker only) MCP_TRANSPORT_MODE=--http # Only --http is supported # Tool surface (#483), all optional; the defaults publish every tool MCP_TOOLSETS=all # groups and presets, e.g. chat MCP_TOOLS= # extra tools by full name MCP_READ_ONLY=false # true hides and refuses write-capable tools ``` `actualToolsManager.initialize()` resolves these once into a frozen policy (`src/lib/toolsets.ts`): the policy object, the published list and the settings are frozen, while the per-tool hide map is a `ReadonlyMap` by type only (`Object.freeze` does not reach Map contents). It holds, for every hidden tool, the setting that hid it (`MCP_READ_ONLY` first when both apply). `tools/list` on every path reads that list, and `callTool` refuses any tool not on the frozen published list with a `ToolUnavailableError` built from the stored reason, so altering the hide map cannot unhide a tool. Dispatch never reads the environment. A configuration that publishes zero tools fails startup. #### Security ```bash # Authentication mode (default: none = static Bearer) AUTH_PROVIDER=none # 'none' or 'oidc' # Static Bearer token (AUTH_PROVIDER=none) MCP_SSE_AUTHORIZATION=your_bearer_token # Bearer token (optional) # Several static tokens, one per client, each with its own principal and budgets (#572) MCP_STATIC_TOKENS={"alice":{"tokenEnv":"MCP_TOKEN_ALICE","budgets":["Home"]}} MCP_TOKEN_ALICE=at_least_32_chars # The token value; each MCP_TOKEN_* value is redacted from logs # OIDC / JWT authentication (AUTH_PROVIDER=oidc) OIDC_ISSUER=https://sso.yourdomain.com # OIDC issuer URL OIDC_RESOURCE=https://actual-mcp.example.com/http # This server's public MCP URL (RFC 9728 identifier, expected 'aud'; #461) OIDC_ACCEPTED_AUDIENCES= # Extra accepted 'aud' values (#245, optional) OIDC_JWKS_TRUSTED_HOSTS= # Cross-host JWKS allowlist, e.g. Google (#254, optional) OIDC_JWKS_URI= # Direct JWKS URL, bypasses discovery; Cloudflare Access certs (#462, optional) OIDC_TOKEN_SOURCE=authorization # or cf-access-jwt-assertion behind Cloudflare Access Managed OAuth (#462) OIDC_ALLOW_INSECURE_ISSUER=false # #244 opt-out for a trusted-LAN http issuer OIDC_SCOPES= # Required (enforced) scopes; also advertised OIDC_SCOPES_SUPPORTED= # Advertised discovery scopes not enforced (#472) AUTH_BUDGET_ACL=user@example.com:sync-id # Per-user budget ACL (optional) # HTTPS MCP_ENABLE_HTTPS=true # Enable TLS MCP_HTTPS_CERT=/app/certs/cert.pem # Certificate path MCP_HTTPS_KEY=/app/certs/key.pem # Private key path ``` #### Logging ```bash # Log configuration MCP_BRIDGE_STORE_LOGS=false # Write to disk MCP_BRIDGE_LOG_DIR=./logs # Log directory MCP_BRIDGE_LOG_LEVEL=info # error, warn, info, debug ``` #### Testing ```bash # Test flags SKIP_BUDGET_DOWNLOAD=false # Skip budget sync on startup DEBUG=true # Enable debug logging ``` ### Configuration Schema Validated by a Zod schema in `src/config.ts`. The snippet below is illustrative, not exhaustive: `.env.example` and `src/config.ts` are authoritative. Notably, multi-budget mode adds `BUDGET_N_NAME`, `BUDGET_N_SYNC_ID`, `BUDGET_N_SERVER_URL`, `BUDGET_N_PASSWORD`, and `BUDGET_N_ENCRYPTION_PASSWORD` (N = 1, 2, 3...), and concurrency is tunable via `ACTUAL_API_CONCURRENCY`. ```typescript export const configSchema = z.object({ ACTUAL_SERVER_URL: z.string().url(), ACTUAL_PASSWORD: z.string().min(1), ACTUAL_BUDGET_SYNC_ID: z.string().min(1), MCP_BRIDGE_DATA_DIR: z.string().default('./actual-data'), MCP_BRIDGE_PORT: z.string().default('3600'), MCP_TRANSPORT_MODE: z.enum(['--http']).default('--http'), MCP_SSE_AUTHORIZATION: z.string().optional(), MCP_STATIC_TOKENS: z.string().optional(), // shape-checked by parseStaticTokensConfig (#572) AUTH_PROVIDER: z.enum(['none', 'oidc']).default('none'), OIDC_ISSUER: z.string().optional(), OIDC_RESOURCE: z.string().optional(), OIDC_SCOPES: z.string().optional(), OIDC_SCOPES_SUPPORTED: z.string().optional(), AUTH_BUDGET_ACL: z.string().optional(), MCP_ENABLE_HTTPS: z.string().optional().transform(val => val === 'true'), MCP_HTTPS_CERT: z.string().optional(), MCP_HTTPS_KEY: z.string().optional(), }); ``` --- ## Transport Protocols ### HTTP Transport (Recommended) **Type**: `streamable-http` from `@modelcontextprotocol/sdk` **Endpoints**: - `POST /http` - MCP requests - `GET /health` - Health check - `GET /metrics` - Prometheus metrics **Authentication**: Bearer token OR OIDC/JWT in `Authorization` header **LibreChat Status**: ✅ Fully supported and verified **Configuration**: ```bash # Start HTTP server npm run dev -- --http # Docker (default) docker run -e MCP_TRANSPORT_MODE=--http ... ``` **LibreChat Config**: ```yaml mcpServers: actual-mcp: type: "streamable-http" url: "https://your-server:3600/http" headers: Authorization: "Bearer your_token" serverInstructions: true ``` > ⚠️ **Removed**: WebSocket transport (`wsServer.ts`) has been removed. Use HTTP transport instead. > ⚠️ **Removed**: SSE transport (`sseServer.ts`) has been removed. Use HTTP transport instead. --- ## Error Handling ### Error Flow ``` Tool Error └─> Caught by tool implementation └─> Adapter layer retry logic (3 attempts) └─> If all retries fail → Error response └─> ActualMCPConnection formats error └─> Transport sends JSON-RPC error Error Response Format: { "jsonrpc": "2.0", "id": 1, "error": { "code": -32000, "message": "Tool execution failed: ..." } } ``` ### Retry Logic Implemented in `src/lib/actual-adapter.ts`: ```typescript // Exponential backoff retry (src/lib/constants.ts) DEFAULT_RETRY_ATTEMPTS = 3 DEFAULT_RETRY_BACKOFF_MS = 200 // 200ms base delay MAX_RETRY_DELAY_MS = 10000 // cap at 10s // backoff sequence: 200ms → 400ms → 800ms (capped at 10s) ``` ### Concurrency Control ```typescript // Prevent overwhelming Actual Budget server (src/lib/constants.ts) DEFAULT_CONCURRENCY_LIMIT = 5 // max simultaneous API calls // Overflow requests are queued and drained FIFO // Configurable via ACTUAL_API_CONCURRENCY env var ``` --- ## Performance & Reliability ### Optimization Strategies 1. **Per-session connection pool**: `ActualConnectionPool` holds one connection per MCP session (up to `MAX_CONCURRENT_SESSIONS`, default 15), with idle timeouts. While a session is active the `withActualApi` wrapper runs operations against the existing connection instead of re-running `api.init()` / `api.shutdown()` per call (pooled mode, #134), which removed the per-op upstream-login burst. Non-MCP callers fall back to the legacy init-to-shutdown cycle. 2. **Local Caching**: Budget data cached to SQLite (MCP_BRIDGE_DATA_DIR) 3. **Lazy Loading**: Dynamic imports for faster cold starts 4. **Retry Logic**: Automatic recovery from transient failures (the pool entry is dropped only on infrastructure errors, not on user/domain errors) ### Monitoring - **Health Endpoint**: `/health` returns `{"status":"ok","initialized":true}` plus pool counts (never session ids) - **Metrics Endpoint**: `/metrics` exposes Prometheus metrics - **Structured Logging**: Winston with daily rotation ### Reliability Features - **Graceful Shutdown**: SIGTERM/SIGINT handlers - **Error Boundaries**: All tool calls wrapped in try/catch - **Input Validation**: Zod schemas for all tool inputs - **Type Safety**: Full TypeScript with strict mode ### Session Liveness Ownership (#167) `ActualConnectionPool` is the single source of truth for session liveness and idle timing. There is one idle timer and one `SESSION_IDLE_TIMEOUT_MINUTES` value for the whole process, consumed only by the pool. - **Ownership split**: the pool owns the `connections` map, per-session `lastActivity`, the idle sweep timer, and the idle threshold. `httpServer` owns only the `transports` map (one StreamableHTTP transport object per session) plus `sessionInitPromises` (transport-init coordination). Since #530 `httpServer` also registers every session it serves with the pool (`registerSession`, beside `transports.set`, only when pooling is on), so the pool times a session even while it has no entry for it. - **Per-request activity**: on every request `httpServer` calls `connectionPool.touch(sessionId)` so the pool's idle clock reflects real usage. Liveness for serving is decided purely by transport presence: the eviction listener (below) removes the transport the moment a session is genuinely evicted, so a missing transport means expired. `httpServer` deliberately does NOT additionally gate on `connectionPool.isLive()`, because a pool entry can be legitimately absent while the MCP session is still usable (after a transient infra error the adapter drops the pool entry without evicting the transport; later calls run through the legacy fallback, and only a `switchBudget` slow path recreates the entry). `isLive()` remains a pool query method for diagnostics. An entry-less session is not timeless, though (#530): `touch` also stamps the session's registration, and the sweep reaps a registered session that has no entry and was not served for one idle window (listener reason `orphaned`, one `Transport torn down for orphaned session` line per session). A session with a POST still in flight is never reaped, however long the request runs: `httpServer` brackets each POST with `beginRequest` and, when the response finishes or is torn down (`stream.finished`, which also fires for a response the client closed during the init wait), `endRequest`, and the idle window restarts when the request ends. An open GET event stream does not count: it is idle by nature. - **Eager teardown via eviction callback**: when the pool removes a session (idle sweep or explicit `session_close` / server shutdown) it fires a registered eviction listener. `httpServer` registers one that closes the transport and deletes its `transports` / `sessionInitPromises` entries, so both tables change in the same window. The mechanism is callback-based eager teardown, not lazy query-on-demand: a lazily-cleaned table would leak transport objects for sessions a client abandons without reconnecting. - **Client-initiated end (#563)**: a client may end its session with HTTP `DELETE` on the MCP path and its `Mcp-Session-Id` (MCP spec 2025-11-25, Session Management). The route applies the same auth gate and ownership rule as POST (a non-owner gets the unknown-id 404 byte for byte), lets the SDK validate `MCP-Protocol-Version`, and shuts the pool entry down (without evicting) in the transport's `onsessionclosed` callback, which the SDK awaits before it answers 200, so the slot is free when the client hears the session ended. That shutdown runs under the api mutex (#392), so another session's call in flight finishes first. Only after the 200 does the route tear the session down itself, logged as `Transport torn down for closed session`. The teardown is direct rather than through the eviction listener because the listener never fires with pooling off or for an entry already dropped. A write still queued for the session when it ends is refused rather than run, because its budget selection is already released (#564) and it would otherwise land on the default budget. Any other method on the path answers 405 with `Allow: GET, POST, DELETE`. - **Session owner**: `httpServer` records the principal that initialized each session (`src/lib/session-owners.ts`) when it registers the transport, and forgets it in the same eviction listener. The owner is kept beside the transport, not on the pool entry, because a pool entry is recycled during one MCP session (see the next bullet). POST, GET and DELETE refuse a session id whose owner is not the request's principal, and the session tools list and close only the caller's own sessions. - **Paths that do NOT evict immediately**: `switchBudget`'s slow path and infra-error drops call `connectionPool.shutdownConnection(sessionId)` without `{ evict: true }`, so the MCP session's transport survives a budget switch or a transient network error while the pool entry is recycled. If the client never comes back, the orphan sweep above tears the transport down after one idle window; before #530 such a session lived until the process exited. - **Behavior change**: the effective idle window is now exactly the single configured value, not `min(httpServer, pool)`. Before this consolidation the httpServer table defaulted to 2 minutes and the pool to 5, so the two clocks could disagree and a session could be alive in one table and dead in the other. --- ## Technology Stack & Dependencies The dependency list lives in `package.json` (the source of truth) and is summarised in CLAUDE.md's "Tech Stack" line, so it is not duplicated here. A hand-maintained copy drifts: run `npm ls --depth=0` for the installed tree and `npm audit` for the current security posture. Highlights: TypeScript (NodeNext/ESM) on Node 22+, `@actual-app/api` v26, `@modelcontextprotocol/sdk`, Express 5, Zod v4, Winston for logging, and Playwright for E2E. Dependency updates are automated (Dependabot weekly plus the scheduled Dependency Update workflow): patches auto-merge after CI, minor and major bumps get review. --- ## Next Steps For more details: - [Testing & Reliability](../development/testing-and-reliability.md) - Testing strategy - [Security & Privacy](./security-and-privacy.md) - Security policies - Planned and future work: tracked as [GitHub issues](https://github.com/agigante80/actual-mcp-server/issues)