actual-mcp-server 0.25.2 → 0.26.1

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.
@@ -25,6 +25,44 @@
25
25
  * The AI uses `actual_budgets_list_available` to see all configured budgets,
26
26
  * then `actual_budgets_switch` with the budget name to switch between them.
27
27
  */
28
+ /**
29
+ * #621: a server URL as it may appear in a log line: scheme, host and port only. URL userinfo
30
+ * (a reverse-proxy credential), path and query are dropped. Never throws; an unparseable URL
31
+ * or one with a `null` origin (a non-special scheme such as a schemeless `user:pass@host`
32
+ * typo) becomes `<invalid url>`. Display text only, for log lines and tool results (#628):
33
+ * its output is never used to connect, and the login itself must keep the full URL.
34
+ */
35
+ export function logSafeOrigin(url) {
36
+ try {
37
+ const origin = new URL(url).origin;
38
+ return origin === 'null' ? '<invalid url>' : origin;
39
+ }
40
+ catch {
41
+ return '<invalid url>';
42
+ }
43
+ }
44
+ /**
45
+ * #621: the startup registry line, counts and schemes only, so no budget name, host or
46
+ * credential reaches the log. Servers are counted by the same [serverUrl, password] key
47
+ * setLoginsSpanServers uses, so the two can never disagree. Never throws.
48
+ */
49
+ export function formatBudgetRegistrySummary(registry) {
50
+ const budgets = [...registry.values()];
51
+ const servers = new Set(budgets.map((b) => JSON.stringify([b.serverUrl, b.password || '']))).size;
52
+ const schemes = new Set();
53
+ for (const b of budgets) {
54
+ let scheme;
55
+ try {
56
+ const protocol = new URL(b.serverUrl).protocol;
57
+ scheme = protocol === 'http:' ? 'http' : protocol === 'https:' ? 'https' : 'other';
58
+ }
59
+ catch {
60
+ scheme = 'invalid';
61
+ }
62
+ schemes.add(scheme);
63
+ }
64
+ return `Budget registry: ${budgets.length} budget(s) across ${servers} server(s) (${[...schemes].sort().join(', ')})`;
65
+ }
28
66
  /**
29
67
  * Parse the budget registry from environment variables.
30
68
  * Always includes the default budget from the provided defaults (ACTUAL_* vars).
@@ -62,7 +100,7 @@ export function parseBudgetRegistry(env, defaults) {
62
100
  // Mirror the default-budget check (#161): never send an E2E encryption
63
101
  // password over an http:// upstream unless ALLOW_INSECURE_UPSTREAM is set.
64
102
  if (encryptionPassword && env.ALLOW_INSECURE_UPSTREAM !== 'true' && /^http:\/\//i.test(serverUrl)) {
65
- console.error(`[CONFIG] BUDGET_${i}_ENCRYPTION_PASSWORD is set but the upstream URL is http:// (${serverUrl}). ` +
103
+ console.error(`[CONFIG] BUDGET_${i}_ENCRYPTION_PASSWORD is set but the upstream URL is http:// (${logSafeOrigin(serverUrl)}). ` +
66
104
  `Refusing to send the encryption password over plaintext (#161). ` +
67
105
  `Use https:// or set ALLOW_INSECURE_UPSTREAM=true to override.`);
68
106
  process.exit(1);
@@ -31,15 +31,11 @@ export const DEFAULT_CONCURRENCY_LIMIT = 5;
31
31
  // TIMEOUTS
32
32
  // ============================================================================
33
33
  /**
34
- * How long (ms) to wait after calling rawRunBankSync for the SDK's background
35
- * promise to surface a BankSyncError as an unhandledRejection.
36
- *
37
- * Bank provider errors (GoCardless RATE_LIMIT_EXCEEDED, auth failures, etc.)
38
- * arrive as HTTP responses. Fast banks respond within 1-3 seconds; slower
39
- * institutions can take considerably longer. 30 seconds gives a comfortable
40
- * margin while keeping the tool's wall-clock time acceptable for MCP clients.
34
+ * How many ids each list of an `actual_bank_sync` report carries (#548). The counts are
35
+ * always the full lengths; only the id lists are cut, so a first sync of a large account
36
+ * cannot flood the client's context. The tool sets `truncated` when any list was cut.
41
37
  */
42
- export const BANK_SYNC_SETTLE_MS = 30_000;
38
+ export const BANK_SYNC_IDS_CAP = 100;
43
39
  /**
44
40
  * How long (ms) to wait for additional queued writes before closing the
45
41
  * shared budget session. Increasing this value batches more writes per
@@ -80,6 +76,10 @@ export const DEFAULT_HTTP_PORT = 3600;
80
76
  // ============================================================================
81
77
  // VALIDATION & LIMITS
82
78
  // ============================================================================
79
+ /**
80
+ * #479: the most dates actual_schedules_dates returns, so a daily schedule cannot flood the context.
81
+ */
82
+ export const SCHEDULE_DATES_CAP = 366;
83
83
  /**
84
84
  * Maximum length for name fields (accounts, categories, payees)
85
85
  */
@@ -3,6 +3,12 @@ import { existsSync } from 'node:fs';
3
3
  import { dirname, join } from 'node:path';
4
4
  import { fileURLToPath } from 'node:url';
5
5
  import { findRootPackageJson } from './node-version-guard.js';
6
+ /**
7
+ * #624: the MCP initialize `serverInfo.name` on both transports. It equals the package.json
8
+ * name (tests/unit/server_identity.test.js pins that), so a client shows the server under the
9
+ * name it was installed as, never a description sentence.
10
+ */
11
+ export const SERVER_NAME = 'actual-mcp-server';
6
12
  /**
7
13
  * #541: the single source of this server's own version, shared by the MCP initialize
8
14
  * `serverInfo.version` (src/index.ts) and the `actual_server_info` tool.
@@ -0,0 +1,183 @@
1
+ /**
2
+ * #622: the process-level unhandledRejection and uncaughtException handlers, as a pure
3
+ * factory so unit tests can drive them without exiting. src/lib/processHandlersInstall.ts
4
+ * registers them on `process`.
5
+ *
6
+ * Records are built from gated fields only (logging convention v1, agreed with
7
+ * actual-sync): `{ type, code?, httpStatus?, category, safeMessage }`. The reason itself,
8
+ * its message, its stack and any upstream `.type` never reach a record or winston, because
9
+ * a rejection reason can carry a request object with an Authorization header, and upstream
10
+ * text can quote account or payee names (#559).
11
+ *
12
+ * INVARIANT: imports only node: builtins, ./errors.js and ./rejection-allowlist.js, never
13
+ * logger.ts or loggerFactory.ts. This module loads before MCP_STDIO_MODE is set, and the
14
+ * logger reads that variable when winston is built.
15
+ */
16
+ import { errorLogFields } from './errors.js';
17
+ import { isKnownBenignRejection } from './rejection-allowlist.js';
18
+ /** Fixed sentence per category, used when the throw site set no log-safe text. */
19
+ const SAFE_MESSAGES = {
20
+ not_found: 'A requested item was not found.',
21
+ validation: 'A request was rejected as invalid.',
22
+ auth: 'An upstream request was not authorized.',
23
+ timeout: 'An operation timed out.',
24
+ conflict: 'An operation conflicted with existing state.',
25
+ rate_limited: 'An upstream service rate limited the request.',
26
+ upstream_unavailable: 'An upstream service was unavailable.',
27
+ cancelled: 'An operation was cancelled.',
28
+ internal: 'An internal error occurred.',
29
+ };
30
+ const UNAVAILABLE_CODES = new Set(['ECONNREFUSED', 'ECONNRESET', 'ENOTFOUND', 'EAI_AGAIN', 'EPIPE']);
31
+ /** Closed mapping, first match wins. Never reads message text. */
32
+ export function categorizeError({ errorClass, refusalKind, code, httpStatus: s }) {
33
+ if (refusalKind === 'not-found')
34
+ return 'not_found';
35
+ if (refusalKind === 'out-of-range')
36
+ return 'validation';
37
+ if (s === 401 || s === 403)
38
+ return 'auth';
39
+ if (s === 404)
40
+ return 'not_found';
41
+ if (s === 408 || s === 504 || code === 'ETIMEDOUT')
42
+ return 'timeout';
43
+ if (s === 409)
44
+ return 'conflict';
45
+ if (s === 429)
46
+ return 'rate_limited';
47
+ if (s === 400 || s === 422)
48
+ return 'validation';
49
+ if ((s !== undefined && s >= 500 && s <= 599) || (code !== undefined && UNAVAILABLE_CODES.has(code))) {
50
+ return 'upstream_unavailable';
51
+ }
52
+ // The Node AbortError class (its ABORT_ERR code fails the E-shape gate). A fetch abort is a
53
+ // DOMException and falls to internal by design: this keys on the class, never on `name`.
54
+ if (errorClass === 'AbortError')
55
+ return 'cancelled';
56
+ return 'internal';
57
+ }
58
+ function isHttpStatus(v) {
59
+ return typeof v === 'number' && Number.isInteger(v) && v >= 100 && v <= 599;
60
+ }
61
+ function readHttpStatus(reason) {
62
+ if (typeof reason !== 'object' || reason === null)
63
+ return undefined;
64
+ const r = reason;
65
+ if (isHttpStatus(r.status))
66
+ return r.status;
67
+ if (isHttpStatus(r.statusCode))
68
+ return r.statusCode;
69
+ const response = r.response;
70
+ if (typeof response === 'object' && response !== null && isHttpStatus(response.status))
71
+ return response.status;
72
+ return undefined;
73
+ }
74
+ /**
75
+ * The gated error object of a record. `type` is the prototype class name, never the
76
+ * reason's own `.type` property (that can carry CR/LF from upstream). Can throw when the
77
+ * reason has a hostile getter; the handlers catch that and write the fixed fallback.
78
+ */
79
+ export function errorRecordFields(reason) {
80
+ const { errorClass, refusalKind, code, logMessage } = errorLogFields(reason);
81
+ const httpStatus = readHttpStatus(reason);
82
+ const category = categorizeError({ errorClass, refusalKind, code, httpStatus });
83
+ const record = { type: errorClass, category, safeMessage: logMessage ?? SAFE_MESSAGES[category] };
84
+ if (code !== undefined)
85
+ record.code = code;
86
+ if (httpStatus !== undefined)
87
+ record.httpStatus = httpStatus;
88
+ return record;
89
+ }
90
+ const NODE_FLOOR_HINT = [
91
+ `This almost certainly means Node ${process.version} is too old.`,
92
+ 'actual-mcp-server requires the Node version in package.json "engines".',
93
+ 'Import attributes (`with { type: "json" }`) need Node 22 or newer.',
94
+ ];
95
+ /**
96
+ * Upstream raises bank sync failures as plain Errors with no code, so message text is the
97
+ * only signal. This is a documented exception to the no-text-matching rule: the text only
98
+ * decides survive versus exit, and is never logged.
99
+ */
100
+ function isBankSyncError(error) {
101
+ const errMsg = String(error?.message || error);
102
+ const errType = error?.type;
103
+ return (errType === 'BankSyncError' ||
104
+ errMsg.includes('BankSyncError') ||
105
+ errMsg.includes('NORDIGEN_ERROR') ||
106
+ errMsg.includes('RATE_LIMIT_EXCEEDED') ||
107
+ errMsg.includes('Rate limit exceeded') ||
108
+ errMsg.includes('Failed syncing account') ||
109
+ errMsg.includes('GoCardless') ||
110
+ errMsg.includes('SimpleFIN'));
111
+ }
112
+ export function createProcessHandlers(deps) {
113
+ const { exit, writeSync, getLogger } = deps;
114
+ // Fatal: the one synchronous record, then exit 1 even when the write fails. Never also
115
+ // through winston, which under stdio already writes to stderr (one record per event).
116
+ function crash(source, error) {
117
+ try {
118
+ writeSync(2, JSON.stringify({ level: 'error', event: 'sys_crash', source, error }) + '\n');
119
+ }
120
+ catch {
121
+ // stderr is a full or closed pipe: nothing more can be written.
122
+ }
123
+ finally {
124
+ exit(1);
125
+ }
126
+ }
127
+ function fallback(source) {
128
+ crash(source, { type: 'unknown', category: 'internal', safeMessage: SAFE_MESSAGES.internal });
129
+ }
130
+ function survive(source, event, message, error) {
131
+ const logger = getLogger();
132
+ if (logger) {
133
+ logger.warn(message, { event, source, error });
134
+ }
135
+ else {
136
+ writeSync(2, JSON.stringify({ level: 'warn', event, source, error }) + '\n');
137
+ }
138
+ }
139
+ function writeNodeFloorHint(error) {
140
+ if (error.code !== 'ERR_IMPORT_ASSERTION_TYPE_MISSING')
141
+ return;
142
+ for (const line of NODE_FLOOR_HINT)
143
+ writeSync(2, line + '\n');
144
+ }
145
+ function onUnhandledRejection(reason) {
146
+ try {
147
+ const error = errorRecordFields(reason);
148
+ // #275 backstop: a library consumer reaches neither entry point's guard, and import
149
+ // attributes fail asynchronously on an under-floor Node, so name the real cause.
150
+ writeNodeFloorHint(error);
151
+ if (isKnownBenignRejection(reason)) {
152
+ survive('unhandledRejection', 'process.rejection_benign', 'Process-level rejection survived', error);
153
+ return;
154
+ }
155
+ crash('unhandledRejection', error);
156
+ }
157
+ catch {
158
+ fallback('unhandledRejection');
159
+ }
160
+ }
161
+ function onUncaughtException(reason) {
162
+ try {
163
+ const error = errorRecordFields(reason);
164
+ writeNodeFloorHint(error);
165
+ // GoCardless and SimpleFIN failures can surface here from inside @actual-app/api when
166
+ // the provider answers asynchronously (RATE_LIMIT_EXCEEDED, for example). Non-fatal.
167
+ if (isBankSyncError(reason)) {
168
+ if (error.category === 'internal') {
169
+ error.category = 'upstream_unavailable';
170
+ if (error.safeMessage === SAFE_MESSAGES.internal)
171
+ error.safeMessage = SAFE_MESSAGES.upstream_unavailable;
172
+ }
173
+ survive('uncaughtException', 'bank_sync.exception_benign', 'Bank sync error survived', error);
174
+ return;
175
+ }
176
+ crash('uncaughtException', error);
177
+ }
178
+ catch {
179
+ fallback('uncaughtException');
180
+ }
181
+ }
182
+ return { onUnhandledRejection, onUncaughtException };
183
+ }
@@ -0,0 +1,25 @@
1
+ /**
2
+ * #622: side-effect module that registers the process-level handlers. src/index.ts imports
3
+ * it SECOND, right after ./node-version-guard.js (#275), so the handlers are armed before
4
+ * any other module evaluates. The logger arrives later through attachProcessLogger, once
5
+ * src/index.ts has set MCP_STDIO_MODE and loaded src/logger.ts; until then the handlers
6
+ * write one fixed JSON line to stderr.
7
+ *
8
+ * INVARIANT: no import of logger.ts or loggerFactory.ts (see processHandlers.ts).
9
+ */
10
+ import { writeSync } from 'node:fs';
11
+ import { createProcessHandlers } from './processHandlers.js';
12
+ let attached;
13
+ /** Route the survivable records through the logger from now on. */
14
+ export function attachProcessLogger(logger) {
15
+ attached = logger;
16
+ }
17
+ const handlers = createProcessHandlers({
18
+ exit: (code) => process.exit(code),
19
+ writeSync: (fd, text) => {
20
+ writeSync(fd, text);
21
+ },
22
+ getLogger: () => attached,
23
+ });
24
+ process.on('unhandledRejection', handlers.onUnhandledRejection);
25
+ process.on('uncaughtException', handlers.onUncaughtException);
@@ -7,9 +7,10 @@
7
7
  *
8
8
  * INVARIANT: this module MUST remain side-effect-free.
9
9
  *
10
- * src/index.ts registers the process.on('unhandledRejection', ...) handler
11
- * AFTER importing this module (ESM static imports are evaluated before the
12
- * top-level body runs). Any side effect introduced here, e.g. importing a
10
+ * src/lib/processHandlersInstall.ts (the second import of src/index.ts, #622)
11
+ * registers the process.on('unhandledRejection', ...) handler AFTER importing
12
+ * this module through processHandlers.ts (ESM static imports are evaluated
13
+ * before the top-level body runs). Any side effect introduced here, e.g. importing a
13
14
  * logger that performs winston/file-handle init, importing dotenv, or any
14
15
  * code that runs at module load, opens a window during ESM evaluation in
15
16
  * which an unhandled rejection bypasses this allow-list and crashes the
@@ -3,7 +3,7 @@
3
3
  *
4
4
  * Common validation schemas used across MCP tools for Actual Budget.
5
5
  * These schemas provide consistent validation, better error messages,
6
- * and reduce duplication across the 84 tool definitions.
6
+ * and reduce duplication across the 85 tool definitions.
7
7
  */
8
8
  import { z } from 'zod';
9
9
  import { MAX_NAME_LENGTH, MAX_NOTES_LENGTH, DATE_PATTERN, MONTH_PATTERN, UUID_PATTERN } from '../constants.js';
@@ -44,13 +44,13 @@ const OPEN_WORLD = new Set(['actual_bank_sync']);
44
44
  * `tests/unit/tool_annotations.test.js`, which resolves each tool's `adapter.*` calls and
45
45
  * fails if one of these reaches `queueWriteOperation`.
46
46
  *
47
- * Claimed only where it is certain. Three read-path tools are deliberately absent because
47
+ * Claimed only where it is certain. Three tools are deliberately absent because
48
48
  * "does not modify its environment" is false for them, and a wrong `true` here is worse
49
49
  * than no annotation at all:
50
50
  *
51
- * actual_bank_sync runs through `withActualApi` (the read path) but IMPORTS
52
- * transactions, so the call graph says read and reality says
53
- * write. Reality wins.
51
+ * actual_bank_sync IMPORTS transactions through `withActualApiWrite` (one
52
+ * upstream call, not the write queue, #548), so a queue-based
53
+ * reading of the call graph misses it.
54
54
  * actual_budgets_export writes a zip into the server's data directory.
55
55
  * actual_budgets_switch changes the session's active budget and persists a preference.
56
56
  * actual_session_close closes a pooled connection: server state, not budget data.
@@ -77,6 +77,7 @@ const READ_ONLY = new Set([
77
77
  'actual_preferences_get',
78
78
  'actual_query_run',
79
79
  'actual_rules_get',
80
+ 'actual_schedules_dates',
80
81
  'actual_schedules_get',
81
82
  'actual_server_get_version',
82
83
  'actual_server_info',
@@ -94,6 +94,7 @@ export const TOOLSETS = {
94
94
  ],
95
95
  schedules: [
96
96
  'actual_schedules_get',
97
+ 'actual_schedules_dates',
97
98
  'actual_schedules_create',
98
99
  'actual_schedules_update',
99
100
  'actual_schedules_delete',
@@ -170,7 +171,7 @@ export const PRESET_NAMES = Object.keys(PRESETS);
170
171
  *
171
172
  * Derived by hand from the adapter call graph: the 43 tools whose adapter path reaches
172
173
  * queueWriteOperation, plus the four that mutate WITHOUT the write queue (bank_sync imports
173
- * transactions through the read path, budgets_export writes a zip to ACTUAL_EXPORT_DIR,
174
+ * transactions through withActualApiWrite, budgets_export writes a zip to ACTUAL_EXPORT_DIR,
174
175
  * budgets_switch changes the active budget, session_close closes a pooled connection).
175
176
  * Never derive this from tool annotations.
176
177
  *
@@ -5,6 +5,8 @@ import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/
5
5
  import { ListToolsRequestSchema, CallToolRequestSchema, } from '@modelcontextprotocol/sdk/types.js';
6
6
  import logger, { resolveRequestId } from '../logger.js';
7
7
  import { attachTransportErrorLogger } from '../lib/transportErrorLogger.js';
8
+ import { SERVER_NAME } from '../lib/mcp-version.js';
9
+ import { createModuleLogger } from '../lib/loggerFactory.js';
8
10
  import { getLocalIp } from '../utils.js';
9
11
  import actualToolsManager from '../actualToolsManager.js';
10
12
  import { getConnectionState, connectToActualForSession, shutdownActualForSession, shutdownActual, admitSession, releaseAdmission, isConnectionPoolEnabled } from '../actualConnection.js';
@@ -43,6 +45,7 @@ import { buildToolListEntries } from '../lib/tool-list-entry.js';
43
45
  import { classifyInitFailure } from '../lib/init-failure.js';
44
46
  import { createHttpErrorHandler } from './httpErrorHandler.js';
45
47
  export { classifyInitFailure };
48
+ const httpLog = createModuleLogger('HTTP');
46
49
  export { requestContext };
47
50
  // Resolve the authenticated principal for the per-principal budget preference
48
51
  // (#189). OIDC: the verified JWT subject. A MCP_STATIC_TOKENS token (#572): its
@@ -486,7 +489,7 @@ advertisedUrl) {
486
489
  capabilities: capabilitiesObj,
487
490
  };
488
491
  const server = new Server({
489
- name: serverDescription || "actual-mcp-server",
492
+ name: SERVER_NAME,
490
493
  version: version || "0.1.0",
491
494
  }, serverOptions);
492
495
  attachTransportErrorLogger(server, 'http');
@@ -1124,9 +1127,9 @@ advertisedUrl) {
1124
1127
  // #239: when bound to a specific (non all-interfaces) host the server is NOT on
1125
1128
  // loopback, so point the health hint at the actual bind host instead of localhost.
1126
1129
  const healthHost = (!bindHost || bindHost === '0.0.0.0' || bindHost === '::') ? 'localhost' : bindHost;
1127
- console.info(`MCP Streamable HTTP Server listening on ${port}`);
1128
- console.info(`📨 MCP endpoint: ${advertised}`);
1129
- console.info(`❤️ Health check: ${scheme}://${healthHost}:${port}/health`);
1130
+ const health = `${scheme}://${healthHost}:${port}/health`;
1131
+ // #625: one structured record through the module logger instead of three console.info lines.
1132
+ httpLog.info(`MCP Streamable HTTP Server listening on ${port}; MCP endpoint: ${advertised} (health: ${health})`, { port, endpoint: advertised, health });
1130
1133
  if (config.AUTH_PROVIDER === 'oidc') {
1131
1134
  logger.info(`🔒 OIDC authentication enabled (JWT Bearer token required; issuer: ${config.OIDC_ISSUER})`);
1132
1135
  // #345: name the userNames the ACL can match, once, at boot. Deliberately
@@ -5,6 +5,7 @@ import { ListToolsRequestSchema, CallToolRequestSchema } from '@modelcontextprot
5
5
  import { randomUUID } from 'node:crypto';
6
6
  import logger from '../logger.js';
7
7
  import { attachTransportErrorLogger } from '../lib/transportErrorLogger.js';
8
+ import { SERVER_NAME } from '../lib/mcp-version.js';
8
9
  import actualToolsManager from '../actualToolsManager.js';
9
10
  import { requestContext } from '../lib/requestContext.js';
10
11
  import { buildToolListEntries } from '../lib/tool-list-entry.js';
@@ -85,7 +86,9 @@ export function createStdioCallToolHandler(mcp, stdioSessionId) {
85
86
  }
86
87
  };
87
88
  }
88
- export async function startStdioServer(mcp, capabilities, implementedTools, serverDescription, instructions, toolSchemas, version) {
89
+ export async function startStdioServer(mcp, capabilities, implementedTools,
90
+ // #624: no longer the serverInfo name; kept so the positional signature matches HTTP.
91
+ _serverDescription, instructions, toolSchemas, version) {
89
92
  const toolsList = Array.isArray(implementedTools) ? implementedTools : [];
90
93
  // #348: a synthetic session identity for this stdio process.
91
94
  //
@@ -108,7 +111,7 @@ export async function startStdioServer(mcp, capabilities, implementedTools, serv
108
111
  // restarts and can never collide with an HTTP session id.
109
112
  const stdioSessionId = `stdio-${randomUUID()}`;
110
113
  logger.debug(`[STDIO] session id ${stdioSessionId}`);
111
- const server = new Server({ name: serverDescription || 'actual-mcp-server', version: version || '0.1.0' }, { capabilities, instructions });
114
+ const server = new Server({ name: SERVER_NAME, version: version || '0.1.0' }, { capabilities, instructions });
112
115
  attachTransportErrorLogger(server, 'stdio');
113
116
  // List tools handler — mirrors createServerInstance() in httpServer.ts
114
117
  server.setRequestHandler(ListToolsRequestSchema, async () => {
@@ -1,11 +1,17 @@
1
1
  import { z } from 'zod';
2
2
  import adapter from '../lib/actual-adapter.js';
3
+ import { BANK_SYNC_IDS_CAP } from '../lib/constants.js';
3
4
  const InputSchema = z.object({
4
5
  accountId: z.string().nullable().optional().describe('Optional account ID to sync a specific account. If omitted, syncs all linked accounts.'),
5
6
  });
6
7
  const tool = {
7
8
  name: 'actual_bank_sync',
8
- description: 'Trigger 3rd party bank sync (GoCardless, SimpleFIN) for linked bank accounts. Returns immediately with an error if no bank-linked accounts are found. When bank-linked accounts exist, waits up to 30 seconds for the provider to confirm the operation and surfaces errors such as rate limits or auth failures that occur within that window. Successful syncs may take a few additional moments for transactions to appear in Actual Budget.',
9
+ description: 'Run 3rd party bank sync (GoCardless, SimpleFIN) for one linked account, or for every linked open account when accountId is omitted. ' +
10
+ 'Refuses at once for a local, closed, half-linked or unknown account, or when no open account is linked. ' +
11
+ 'Reports what Actual reconciled: newTransactions (imported), matchedTransactions (existing ones updated) and updatedAccounts (accounts that received new transactions), ' +
12
+ `each as { count, ids } with up to ${BANK_SYNC_IDS_CAP} ids (count is always the full total; truncated is true when any list was cut). ` +
13
+ 'Never retried. A sync that runs past the server operation timeout reports "timed out" and may still have imported, so re-list transactions before retrying. ' +
14
+ 'An all-accounts sync that fails on one account may still have imported the others; those imports reach the Actual server with the next write in this session or when it closes, so other clients may not see them yet.',
9
15
  inputSchema: InputSchema,
10
16
  call: async (args, _meta) => {
11
17
  const input = InputSchema.parse(args || {});
@@ -17,14 +23,21 @@ const tool = {
17
23
  if (input.accountId) {
18
24
  await adapter.resolveFilterId('account', input.accountId, { verifyExists: true });
19
25
  }
20
- try {
21
- await adapter.runBankSync(input.accountId ?? undefined);
22
- return { result: 'Bank sync initiated successfully' };
23
- }
24
- catch (error) {
25
- const msg = error instanceof Error ? error.message : String(error);
26
- throw new Error(msg);
27
- }
26
+ // #548: rethrow the adapter's error as is, so a typed refusal keeps its brand.
27
+ const report = await adapter.runBankSync(input.accountId ?? undefined);
28
+ let truncated = false;
29
+ const capped = (ids) => {
30
+ if (ids.length > BANK_SYNC_IDS_CAP)
31
+ truncated = true;
32
+ return { count: ids.length, ids: ids.slice(0, BANK_SYNC_IDS_CAP) };
33
+ };
34
+ return {
35
+ result: 'Bank sync completed',
36
+ newTransactions: capped(report.newTransactions),
37
+ matchedTransactions: capped(report.matchedTransactions),
38
+ updatedAccounts: capped(report.updatedAccounts),
39
+ truncated,
40
+ };
28
41
  },
29
42
  };
30
43
  export default tool;
@@ -4,7 +4,7 @@ const InputSchema = z.object({});
4
4
  const tool = {
5
5
  name: 'actual_budgets_list_available',
6
6
  description: 'List the pre-configured budgets this session may switch to (a session limited to some budgets sees only those). ' +
7
- 'Each entry shows the budget name, sync ID, server URL, and whether it uses E2E encryption. ' +
7
+ 'Each entry shows the budget name, sync ID, server origin, and whether it uses E2E encryption. ' +
8
8
  'Budgets are configured via BUDGET_n_NAME / BUDGET_n_SYNC_ID / BUDGET_n_SERVER_URL env vars. ' +
9
9
  'Pass the budget name to actual_budgets_switch to change the active budget.',
10
10
  inputSchema: InputSchema,
@@ -52,6 +52,7 @@ export { default as rules_delete } from './rules_delete.js';
52
52
  export { default as rules_get } from './rules_get.js';
53
53
  export { default as rules_update } from './rules_update.js';
54
54
  export { default as schedules_create } from './schedules_create.js';
55
+ export { default as schedules_dates } from './schedules_dates.js';
55
56
  export { default as schedules_delete } from './schedules_delete.js';
56
57
  export { default as schedules_get } from './schedules_get.js';
57
58
  export { default as schedules_update } from './schedules_update.js';
@@ -0,0 +1,35 @@
1
+ import { z } from 'zod';
2
+ import { createTool } from '../lib/toolFactory.js';
3
+ import { CommonSchemas } from '../lib/schemas/common.js';
4
+ import adapter from '../lib/actual-adapter.js';
5
+ import { SCHEDULE_DATES_CAP } from '../lib/constants.js';
6
+ export default createTool({
7
+ name: 'actual_schedules_dates',
8
+ description: 'List the dates one schedule falls on, computed by Actual itself, so month ends, patterns ' +
9
+ '("last Friday"), intervals and the weekend move match the Actual UI. Use this instead of ' +
10
+ 'expanding a schedule\'s recurrence yourself. Get schedule ids from actual_schedules_get. ' +
11
+ '`start` defaults to today; pass it whenever you pass `end`. With `end` and `count` both set, the list stops at whichever ' +
12
+ `comes first. At most ${SCHEDULE_DATES_CAP} dates are returned. \`truncated\` is true when more dates exist ` +
13
+ 'after the last one returned (within `end`, if given): ask again with a later `start` to continue. ' +
14
+ 'Returns { schedule_id, name, dates (ascending YYYY-MM-DD), count, truncated }.',
15
+ schema: z.object({
16
+ schedule_id: CommonSchemas.scheduleId,
17
+ start: CommonSchemas.date.optional().describe('First date to consider (YYYY-MM-DD); defaults to today'),
18
+ end: CommonSchemas.date.optional().describe('Last date to consider (YYYY-MM-DD), inclusive'),
19
+ count: z.number().int().min(1).max(SCHEDULE_DATES_CAP).optional()
20
+ .describe(`How many dates to return, 1 to ${SCHEDULE_DATES_CAP}`),
21
+ }),
22
+ handler: async ({ schedule_id, start, end, count }) => {
23
+ return await adapter.getScheduleDates(schedule_id, { start, end, count });
24
+ },
25
+ examples: [
26
+ {
27
+ description: 'Next three dates of a schedule',
28
+ input: { schedule_id: '00000000-0000-0000-0000-000000000001', count: 3 },
29
+ },
30
+ {
31
+ description: 'Every date of a schedule in Q4 2026',
32
+ input: { schedule_id: '00000000-0000-0000-0000-000000000001', start: '2026-10-01', end: '2026-12-31' },
33
+ },
34
+ ],
35
+ });
@@ -3,7 +3,7 @@ import adapter from '../lib/actual-adapter.js';
3
3
  import { CommonSchemas } from '../lib/schemas/common.js';
4
4
  import { RecurConfigSchema } from '../lib/schemas/recur.js';
5
5
  import { ScheduleAmountSchema, amountOpMismatch } from '../lib/schemas/schedule-amount.js';
6
- const InputSchema = z.object({
6
+ const InputSchema = z.strictObject({
7
7
  id: CommonSchemas.scheduleId.describe('Schedule UUID'),
8
8
  name: z.string().optional()
9
9
  .describe('New display name'),
@@ -24,10 +24,12 @@ const InputSchema = z.object({
24
24
  .describe('New YYYY-MM-DD date or RecurConfig object'),
25
25
  posts_transaction: z.boolean().optional()
26
26
  .describe('Auto-post a transaction on each occurrence'),
27
- completed: z.boolean().optional()
28
- .describe('true marks it completed, false reactivates it'),
29
27
  resetNextDate: z.boolean().optional().default(false)
30
28
  .describe('Recalculate next_date'),
29
+ // #627: strict, because upstream updateSchedule refuses `completed` and `next_date` as
30
+ // system-managed. A stripped key would return success while changing nothing; an advertised
31
+ // one failed upstream after the write queue. Either is refused here, before any write, as
32
+ // "unexpected field(s): completed" (formatZodError sanitises the key); the description says why.
31
33
  }).superRefine((input, ctx) => {
32
34
  // #527: with both fields supplied the input alone decides. With only one, the answer
33
35
  // depends on the stored schedule, so adapter.updateSchedule checks it inside the write queue.
@@ -39,7 +41,7 @@ const InputSchema = z.object({
39
41
  });
40
42
  const tool = {
41
43
  name: 'actual_schedules_update',
42
- description: `Update a schedule; supply only the fields to change. A new date replaces the stored recurrence, patterns included. Set resetNextDate: true when changing the date or recurrence.`,
44
+ description: `Update a schedule; supply only the fields to change. A new date replaces the stored recurrence, patterns included. Set resetNextDate: true when changing the date or recurrence. Completion and next_date are managed by Actual and cannot be set.`,
43
45
  inputSchema: InputSchema,
44
46
  call: async (args, _meta) => {
45
47
  const input = InputSchema.parse(args || {});