actual-mcp-server 0.20.0 → 0.22.0

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.
@@ -5,12 +5,92 @@
5
5
  * to prevent server crashes from invalid table/field references.
6
6
  */
7
7
  import { getTableFields, getTableNames, isValidTable, isValidField, isValidJoinPath, } from './actual-schema.js';
8
+ /**
9
+ * Blank the CONTENTS of quoted literals, keeping the quotes.
10
+ *
11
+ * #450 round 2: every extractor below scans for keywords (`FROM`, `JOIN`, `GROUP BY`,
12
+ * `ORDER BY`, `LIMIT`) and must not see one that lives inside a string value. Word boundaries
13
+ * fixed the column-name instance of that; they cannot fix the literal instance, which is both
14
+ * the same defect and far more reachable. Measured on v0.21.0, before this:
15
+ *
16
+ * WHERE notes LIKE '%limit%' AND catgeory = 'groceries' ACCEPTED, typo never validated
17
+ * WHERE notes = 'order by z' AND made_up_col = 'x' ACCEPTED
18
+ * SELECT 'a from b', made_up_col FROM transactions rejected as Table "b" does not exist
19
+ * WHERE notes = 'join secrets' rejected as Table "secrets" does not exist
20
+ *
21
+ * The last two are FALSE REJECTIONS of legitimate queries, so this is not only a bypass fix.
22
+ *
23
+ * `validateQueryShape` has done exactly this since #162 for the same reason; extracting it
24
+ * here means the shape gate and the schema validator can never disagree about what is a
25
+ * literal. Lengths change, so never map an index from the blanked copy back onto the raw SQL.
26
+ * Nothing here does: the extractors read identifiers, which never live inside a literal.
27
+ */
28
+ function blankStringLiterals(sql) {
29
+ // ONE left-to-right pass. The two obvious shortcuts are both wrong, and both were shipped
30
+ // and caught in review of the first attempt at this:
31
+ //
32
+ // 1. TWO INDEPENDENT REGEX PASSES (single quotes, then double quotes) let one pass match
33
+ // ACROSS the other's literals. `WHERE notes = "it's" AND made_up_col = "don't"` had its
34
+ // middle blanked by the single-quote pass, deleting `made_up_col` before the extractor
35
+ // could see it. An apostrophe in a note search is ordinary input, not an edge case.
36
+ // 2. TREATING `\` AS AN ESCAPE. SQL escapes a quote by DOUBLING it, not with a backslash,
37
+ // so in `'a\'` the literal ENDS at the second quote. Reading it as escaped swallowed the
38
+ // rest of the clause, which both hid an unknown column from this validator and let
39
+ // `'a\' ; DELETE FROM accounts; --'` past the #162 stacked-statement gate.
40
+ //
41
+ // So: whichever quote opens first wins, and it closes on the next matching quote unless that
42
+ // quote is doubled. Quotes are kept, contents are dropped.
43
+ let out = '';
44
+ let i = 0;
45
+ while (i < sql.length) {
46
+ const ch = sql[i];
47
+ if (ch !== "'" && ch !== '"') {
48
+ out += ch;
49
+ i++;
50
+ continue;
51
+ }
52
+ const quote = ch;
53
+ const quoteStart = i;
54
+ out += quote;
55
+ i++;
56
+ while (i < sql.length) {
57
+ if (sql[i] === quote) {
58
+ if (sql[i + 1] === quote) {
59
+ i += 2; // a doubled quote is an escaped quote: still inside the literal
60
+ continue;
61
+ }
62
+ break; // closing quote
63
+ }
64
+ i++; // literal content: dropped
65
+ }
66
+ if (i < sql.length) {
67
+ out += quote;
68
+ i++;
69
+ }
70
+ else {
71
+ // UNTERMINATED literal (no closing quote). The tail must stay VISIBLE, and this branch is
72
+ // the one that matters most: dropping it made `validateQueryShape` fail OPEN. Verified on
73
+ // the built module while it did:
74
+ // WHERE notes = 'x ; DROP TABLE accounts was rejected, became ACCEPTED
75
+ // WHERE notes = 'x AND DELETE FROM accounts was rejected, became ACCEPTED
76
+ // The regex this scanner replaced never matched an unterminated literal, so the tail
77
+ // stayed visible and the #162 gate still saw the stacked statement and the write keyword.
78
+ // Moving a security gate in the fail-open direction to tidy up a scanner is a bad trade,
79
+ // so the remainder is emitted RAW, exactly as the old behaviour left it.
80
+ out += sql.slice(quoteStart + 1);
81
+ }
82
+ }
83
+ return out;
84
+ }
8
85
  /**
9
86
  * Extract table name from SQL query
10
87
  * Handles: FROM table, FROM table1, table2, JOIN table
11
88
  */
12
89
  function extractTableNames(sql) {
13
90
  const tables = new Set();
91
+ // #450: literals blanked first, so `WHERE notes = 'join secrets'` no longer reports
92
+ // `Table "secrets" does not exist` for a perfectly legitimate query.
93
+ sql = blankStringLiterals(sql);
14
94
  const normalized = sql.toUpperCase();
15
95
  // Extract FROM clause tables - handle queries without WHERE/ORDER/LIMIT
16
96
  const fromMatch = normalized.match(/FROM\s+(\w+)/i);
@@ -31,11 +111,19 @@ function extractTableNames(sql) {
31
111
  function extractSelectFields(sql) {
32
112
  const fields = [];
33
113
  // Handle SELECT *
114
+ // #450: see blankStringLiterals. A literal beginning with `from` truncated the clause and
115
+ // reported the literal's own words as a missing table.
116
+ sql = blankStringLiterals(sql);
34
117
  if (/SELECT\s+\*/i.test(sql)) {
35
118
  return [{ field: '*' }];
36
119
  }
37
- // Extract SELECT clause
38
- const selectMatch = sql.match(/SELECT\s+(.*?)\s+FROM/is);
120
+ // Extract SELECT clause.
121
+ // #450: `FROM` carries a trailing word boundary. Without it the lazy match terminates at the
122
+ // letters `from` inside any identifier, so `SELECT made_up_col AS from_x FROM accounts`
123
+ // truncated the clause to `made_up_col AS`, which fails the `^\w+$` test below and therefore
124
+ // extracted ZERO fields, skipping the allowlist loop entirely. Same defect class as the WHERE
125
+ // terminator below, reachable through an alias rather than a column name.
126
+ const selectMatch = sql.match(/SELECT\s+(.*?)\s+FROM\b/is);
39
127
  if (!selectMatch)
40
128
  return fields;
41
129
  const selectClause = selectMatch[1];
@@ -63,7 +151,22 @@ function extractSelectFields(sql) {
63
151
  */
64
152
  function extractWhereFields(sql) {
65
153
  const fields = [];
66
- const whereMatch = sql.match(/WHERE\s+(.*?)(?:GROUP|ORDER|LIMIT|;|$)/is);
154
+ // #450: every keyword terminator carries word boundaries. Without them the lazy match cuts the
155
+ // clause at the first occurrence of the LETTERS `GROUP`, `ORDER` or `LIMIT` anywhere, including
156
+ // inside a column name, and every field after that point escaped schema validation silently.
157
+ // Reachable with columns this project's own schema defines: `sort_order` and `balance_limit`
158
+ // (accounts), `account_group_id` (accounts), and `group` (categories), which IS the keyword.
159
+ // The terminator anchors on the FULL two-word clause (`GROUP BY`, `ORDER BY`) rather than the bare
160
+ // keyword, because `group` is itself a column on `categories`: with word boundaries alone,
161
+ // `WHERE group = "x" AND bogus = "y"` still truncated to the empty string, since the column name
162
+ // and the keyword are the same token. `LIMIT` stays a single word because it is one in SQL too,
163
+ // and it is safe: no schema column is named exactly `limit`, and `balance_limit` has no word
164
+ // boundary before its `limit` (the preceding `_` is a word character).
165
+ // #450: literals blanked BEFORE the terminator match. Word boundaries alone left the whole
166
+ // bypass reachable through a string value, which is the likelier route by far: a search for
167
+ // notes containing the word "limit" truncated the clause and let every later field through.
168
+ sql = blankStringLiterals(sql);
169
+ const whereMatch = sql.match(/WHERE\s+(.*?)(?:\bGROUP\s+BY\b|\bORDER\s+BY\b|\bLIMIT\b|;|$)/is);
67
170
  if (!whereMatch)
68
171
  return fields;
69
172
  const whereClause = whereMatch[1];
@@ -248,9 +351,7 @@ export function formatValidationErrors(result) {
248
351
  export function validateQueryShape(query) {
249
352
  // Blank out single- and double-quoted literals (keep the quotes so positions
250
353
  // are roughly preserved, but drop their contents).
251
- const stripped = query
252
- .replace(/'(?:[^'\\]|\\.)*'/g, "''")
253
- .replace(/"(?:[^"\\]|\\.)*"/g, '""');
354
+ const stripped = blankStringLiterals(query);
254
355
  // Stacked statements: a semicolon followed by more non-whitespace.
255
356
  if (/;\s*\S/.test(stripped)) {
256
357
  throw new Error('actual_query_run does not allow stacked statements (multiple statements separated by ";").');
@@ -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 77 tool definitions.
6
+ * and reduce duplication across the 81 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';
@@ -62,6 +62,14 @@ export const ruleIdSchema = z
62
62
  * Tag UUID validation
63
63
  * Used for: tag management operations (update, delete)
64
64
  */
65
+ /**
66
+ * Account group UUID validation (#429)
67
+ * Used for: account group management operations (update, delete)
68
+ */
69
+ export const accountGroupIdSchema = z
70
+ .string()
71
+ .regex(UUID_PATTERN, 'Invalid account group ID format (expected UUID)')
72
+ .describe('Account group UUID');
65
73
  export const tagIdSchema = z
66
74
  .string()
67
75
  .regex(UUID_PATTERN, 'Invalid tag ID format (expected UUID)')
@@ -225,6 +233,7 @@ export const CommonSchemas = {
225
233
  categoryGroupId: categoryGroupIdSchema,
226
234
  payeeId: payeeIdSchema,
227
235
  ruleId: ruleIdSchema,
236
+ accountGroupId: accountGroupIdSchema,
228
237
  tagId: tagIdSchema,
229
238
  scheduleId: scheduleIdSchema,
230
239
  // Dates
@@ -77,10 +77,12 @@ export function checkServerVersion(running, range = SUPPORTED_ACTUAL_SERVER_RANG
77
77
  // api patches, so comparing patches would be pure false-positive volume. It is
78
78
  // silent when equal and silent when the server is behind.
79
79
  //
80
- // Scope, stated because it is narrower than it looks: this fires only when ops
81
- // still SUCCEED, since the caller runs after a completed operation. A server so
82
- // far ahead that budget download already fails never reaches it, and that case
83
- // is #438's (surface the real session-init error) rather than this one's.
80
+ // Scope: this USED to fire only when ops still SUCCEEDED, because the only caller ran after a
81
+ // completed operation, so a server far enough ahead that the budget download already failed
82
+ // never reached it. #453 removed that limit by adding a call site BEFORE the download in
83
+ // loadBudgetTracked, which is the case this branch most wanted to reach. The verdict is
84
+ // unchanged; only WHEN it can be asked changed. #438 still owns surfacing the real error
85
+ // AFTER a failure; this warns before one.
84
86
  const bundled = parseVersion(bundledApi ?? '');
85
87
  if (bundled && (have[0] > bundled[0] || (have[0] === bundled[0] && have[1] > bundled[1]))) {
86
88
  return {
@@ -97,7 +99,11 @@ export function checkServerVersion(running, range = SUPPORTED_ACTUAL_SERVER_RANG
97
99
  // ---------------------------------------------------------------------------
98
100
  // Per-process once-guard.
99
101
  // ---------------------------------------------------------------------------
100
- let checked = false;
102
+ let checked = false; // a version WAS read and judged: never check again
103
+ let inFlight = false; // a check is running right now: a concurrent caller must not duplicate it
104
+ let attempts = 0; // failed attempts, so an unreachable /info cannot be probed forever
105
+ /** Failed probes tolerated before the check gives up for the process. */
106
+ const MAX_PROBE_ATTEMPTS = 3;
101
107
  /**
102
108
  * Run the compatibility check at most once per process. Call this from inside a successful
103
109
  * `withActualApi` operation (while the connection is live), passing a function that reads the
@@ -114,15 +120,28 @@ export async function checkServerVersionOnce(readVersion, log,
114
120
  // the process-global api mutex. Tests pass an explicit literal so no in-chain
115
121
  // assertion depends on what is installed (#321).
116
122
  bundledApi = INSTALLED_API_VERSION) {
117
- if (checked)
123
+ if (checked || inFlight || attempts >= MAX_PROBE_ATTEMPTS)
118
124
  return;
119
- checked = true; // set synchronously, before any await, so a concurrent op cannot double-fire
125
+ // #453 review: the latch used to be set here, synchronously, before the await. That was right
126
+ // while this ran only AFTER a successful operation. Once the probe moved BEFORE the first
127
+ // download it became the most failure-prone moment available (a slow or unreachable /info, or
128
+ // the op timeout firing), and a single failure then disabled the warning for the LIFETIME of
129
+ // the process, including #276's surviving post-op call site. The deployment that silenced it
130
+ // is precisely the one the guard exists for.
131
+ //
132
+ // So: `inFlight` keeps the concurrency property the synchronous latch was actually providing,
133
+ // `checked` is now set only once a version was really read and judged, and `attempts` bounds
134
+ // the retries so an unreachable server costs at most MAX_PROBE_ATTEMPTS extra /info calls per
135
+ // process rather than one per budget load.
136
+ inFlight = true;
120
137
  try {
121
138
  const result = await readVersion();
122
139
  if (!result || 'error' in result || typeof result.version !== 'string') {
140
+ attempts++;
123
141
  log.debug('[server-version] could not read the Actual server version; skipping the compatibility check');
124
142
  return;
125
143
  }
144
+ checked = true; // a real answer: this is the one and only judgement
126
145
  const verdict = checkServerVersion(result.version, SUPPORTED_ACTUAL_SERVER_RANGE, bundledApi);
127
146
  if (!verdict.ok && verdict.message) {
128
147
  log.warn(verdict.message);
@@ -130,10 +149,16 @@ bundledApi = INSTALLED_API_VERSION) {
130
149
  }
131
150
  catch {
132
151
  // Advisory only: a failure to check must never surface as an error or affect the op.
152
+ attempts++;
133
153
  log.debug('[server-version] compatibility check threw; ignored');
134
154
  }
155
+ finally {
156
+ inFlight = false;
157
+ }
135
158
  }
136
159
  /** Test-only: reset the once-guard between cases. */
137
160
  export function _resetForTests() {
138
161
  checked = false;
162
+ inFlight = false;
163
+ attempts = 0;
139
164
  }
@@ -55,6 +55,7 @@ const OPEN_WORLD = new Set(['actual_bank_sync']);
55
55
  * actual_session_close closes a pooled connection: server state, not budget data.
56
56
  */
57
57
  const READ_ONLY = new Set([
58
+ 'actual_account_groups_list',
58
59
  'actual_account_flow_summary',
59
60
  'actual_recurring_expenses_summary',
60
61
  'actual_accounts_get_balance',
@@ -99,6 +100,7 @@ const READ_ONLY = new Set([
99
100
  * and in #357), so a close can be a delete.
100
101
  */
101
102
  const DESTRUCTIVE = new Set([
103
+ 'actual_account_groups_delete',
102
104
  'actual_accounts_close',
103
105
  'actual_accounts_delete',
104
106
  // Upstream `reconcileTransactions` overwrites fields on transactions that ALREADY exist
@@ -151,6 +153,8 @@ const DESTRUCTIVE = new Set([
151
153
  * not, because on the split path it deletes and recreates child rows.
152
154
  */
153
155
  const ADDITIVE = new Set([
156
+ 'actual_account_groups_create',
157
+ 'actual_account_groups_update',
154
158
  'actual_accounts_create',
155
159
  'actual_accounts_reopen',
156
160
  'actual_accounts_update',
@@ -25,97 +25,11 @@ import * as fs from 'node:fs';
25
25
  // `requestContext` from this module.
26
26
  import { requestContext } from '../lib/requestContext.js';
27
27
  import { buildToolListEntries } from '../lib/tool-list-entry.js';
28
- const INIT_FAILURE_SENTENCES = {
29
- schema_too_new: "The Actual server's database schema is newer than the @actual-app/api this build bundles. Upgrade actual-mcp-server, or hold the server upgrade until its dependency update ships.",
30
- auth_failed: 'Authentication against the Actual server failed. Check ACTUAL_PASSWORD and the budget password.',
31
- // Deliberately not asserting permanence: ECONNRESET and ETIMEDOUT are classed
32
- // TRANSIENT by isRetryableError, so this can be a blip rather than misconfiguration.
33
- network_unreachable: 'The Actual server could not be reached, which may be transient. If it persists, check ACTUAL_SERVER_URL and that the server is running.',
34
- budget_not_found: 'The configured budget was not found on the Actual server. Check ACTUAL_BUDGET_SYNC_ID.',
35
- out_of_sync: 'The local budget copy is out of sync with the Actual server and could not be reconciled.',
36
- encryption_error: "The budget's end-to-end encryption password is wrong or missing.",
37
- clock_drift: "The host clock differs too far from the Actual server's. Fix the system time.",
38
- // A filesystem permission error, WITHOUT claiming which path: the classifier
39
- // sees only the errno, and EACCES can come from the data directory, TLS
40
- // material, or a socket during api.init.
41
- permission_denied: 'The server was denied filesystem access while initialising. Check the data directory mount and its ownership first, then any TLS material.',
42
- timeout: 'Initialising the Actual connection ran out of time, which may be transient.',
43
- unknown: 'The Actual connection for this session could not be initialised. See the server log for the cause.',
44
- };
45
- /** Actual's own reason strings, from `withErrorCode` and SyncError, to our enum.
46
- * `out-of-sync-migrations` maps to schema_too_new, NOT out_of_sync: `invalid-schema`
47
- * is absent from budgetLoader's KNOWN_LOAD_REASONS, so migrations is the only route
48
- * by which a too-new schema surfaces on the post-condition path, and upstream's own
49
- * sentence for it is "This budget cannot be loaded with this version of the app." */
50
- const REASON_TO_CAUSE = {
51
- 'invalid-schema': 'schema_too_new',
52
- 'out-of-sync-migrations': 'schema_too_new',
53
- 'out-of-sync': 'out_of_sync',
54
- 'out-of-sync-data': 'out_of_sync',
55
- 'budget-not-found': 'budget_not_found',
56
- 'clock-drift': 'clock_drift',
57
- 'encrypt-failure': 'encryption_error',
58
- 'decrypt-failure': 'encryption_error',
59
- 'missing-key': 'encryption_error',
60
- };
61
- /** Node fs/net codes. A DIFFERENT namespace from Actual's reasons, deliberately
62
- * kept in its own table so the two can never be conflated. */
63
- const SYSTEM_CODE_TO_CAUSE = {
64
- EACCES: 'permission_denied',
65
- EPERM: 'permission_denied',
66
- EROFS: 'permission_denied',
67
- ECONNREFUSED: 'network_unreachable',
68
- ENOTFOUND: 'network_unreachable',
69
- ECONNRESET: 'network_unreachable',
70
- EHOSTUNREACH: 'network_unreachable',
71
- ETIMEDOUT: 'timeout',
72
- };
73
- export function classifyInitFailure(err) {
74
- const done = (cause) => ({ cause, sentence: INIT_FAILURE_SENTENCES[cause] });
75
- try {
76
- const e = err;
77
- // `.code` FIRST: upstream's api/download-budget and api/load-budget never let a
78
- // SyncError escape, they throw a plain Error carrying .code via withErrorCode.
79
- const code = typeof e?.code === 'string' ? e.code : undefined;
80
- if (code && REASON_TO_CAUSE[code])
81
- return done(REASON_TO_CAUSE[code]);
82
- if (code && SYSTEM_CODE_TO_CAUSE[code])
83
- return done(SYSTEM_CODE_TO_CAUSE[code]);
84
- // `.reason` only as a defensive fallback, for a SyncError that reaches us by
85
- // some path that does not go through those two handlers.
86
- const reason = typeof e?.reason === 'string' ? e.reason : undefined;
87
- if (reason && REASON_TO_CAUSE[reason])
88
- return done(REASON_TO_CAUSE[reason]);
89
- const message = typeof e?.message === 'string' ? e.message : '';
90
- // Our OWN synthesized post-condition error (#396) embeds the upstream reason
91
- // in prose. On a resync of an existing local copy this is the shape that
92
- // actually arrives for the schema and migration classes, so a classifier that
93
- // handled only the upstream shapes would answer `unknown` for exactly the
94
- // failures this ticket is about.
95
- const embedded = /Upstream reason: \[([a-z-]+)\]/.exec(message)?.[1];
96
- if (embedded && REASON_TO_CAUSE[embedded])
97
- return done(REASON_TO_CAUSE[embedded]);
98
- // Last resort, message shapes. `network-failure` is tested BEFORE the auth
99
- // wording because upstream reports an unreachable server as
100
- // "Authentication failed: network-failure", where the actionable half is the
101
- // network, not the credentials.
102
- if (/network-failure|ECONNREFUSED|ENOTFOUND/i.test(message))
103
- return done('network_unreachable');
104
- if (/timed out|ETIMEDOUT/i.test(message))
105
- return done('timeout');
106
- if (/Authentication failed|invalid-password|Invalid password/i.test(message))
107
- return done('auth_failed');
108
- if (/invalid-schema/i.test(message))
109
- return done('schema_too_new');
110
- return done('unknown');
111
- }
112
- catch {
113
- // TOTAL by construction: a classifier that throws would land on the outer POST
114
- // catch and egress as raw String(err), defeating this contract through the one
115
- // line deliberately left to #446.
116
- return done('unknown');
117
- }
118
- }
28
+ // #438 / #452: the init-failure classifier now lives in src/lib/init-failure.ts so BOTH
29
+ // transports can reach it (stdio could not, which was the whole of #452). Re-exported here
30
+ // unchanged so existing importers and tests are unaffected.
31
+ import { classifyInitFailure } from '../lib/init-failure.js';
32
+ export { classifyInitFailure };
119
33
  export { requestContext };
120
34
  // Resolve the authenticated principal for the per-principal budget preference
121
35
  // (#189). OIDC: the verified JWT subject. Static-bearer: a single shared
@@ -7,6 +7,83 @@ import logger from '../logger.js';
7
7
  import actualToolsManager from '../actualToolsManager.js';
8
8
  import { requestContext } from '../lib/requestContext.js';
9
9
  import { buildToolListEntries } from '../lib/tool-list-entry.js';
10
+ import { classifyInitFailure, isInitFailure } from '../lib/init-failure.js';
11
+ /**
12
+ * The stdio `tools/call` handler, built as a named factory so #452's error mapping can be driven
13
+ * by a unit test with a stubbed connection, instead of only by starting a real process against a
14
+ * real server. `startStdioServer` registers exactly this.
15
+ */
16
+ export function createStdioCallToolHandler(mcp, stdioSessionId) {
17
+ return async (request) => {
18
+ const req = request;
19
+ const params = req?.params ?? {};
20
+ const rawName = params.name;
21
+ const args = params.arguments;
22
+ if (typeof rawName !== 'string') {
23
+ throw new Error('Tool name must be a string');
24
+ }
25
+ logger.debug(`[STDIO] tools/call ${rawName}`);
26
+ // Wrap the DISPATCH, not server.connect(). The transport invokes this handler
27
+ // from an I/O event, which is outside any scope established around connect(),
28
+ // so an AsyncLocalStorage context opened there would not be visible here.
29
+ // Everything downstream is awaited inside this callback, so it propagates.
30
+ //
31
+ // Only `sessionId` is placed in the store, deliberately:
32
+ // - no `principal`, or getActiveBudgetConfig() would attempt the #189
33
+ // preferred-budget restore for a caller that has no authenticated
34
+ // identity, and the env default would stop being authoritative.
35
+ // - `transport: 'stdio'` is what keeps stdio OFF the pooled path, and it
36
+ // stays that way after #419. switchBudget's slow path calls
37
+ // connectionPool.getConnection(), so WITHOUT this marker the first switch
38
+ // would silently move stdio onto the pooled branch, and the entry would
39
+ // never be touched (touch() is called only from httpServer.ts), so it
40
+ // would expire after the idle timeout and be torn down by the cleanup
41
+ // sweep without the api lock, possibly mid-operation. That is a far larger
42
+ // change than we want by accident.
43
+ // #419 solved the per-call upstream login WITHOUT pooling stdio: a stdio
44
+ // process keeps the api singleton alive between ops in shutdownActualApi
45
+ // (see _shouldKeepSingletonAlive), so the legacy branch's init no-ops
46
+ // after the first login. No pool entry is created, so there is nothing for
47
+ // the sweep to evict and this comment's hazard never arises.
48
+ // - `allowedBudgets` is absent, so under AUTH_PROVIDER=oidc switchBudget
49
+ // still denies. Fail closed: a local pipe must not gain budget access an
50
+ // HTTP caller would need an ACL for.
51
+ try {
52
+ const result = await requestContext.run({ sessionId: stdioSessionId, transport: 'stdio' }, () => mcp.executeTool(rawName, args ?? {}));
53
+ return {
54
+ content: [{ type: 'text', text: typeof result === 'string' ? result : JSON.stringify(result) }],
55
+ };
56
+ }
57
+ catch (err) {
58
+ // #452: give a FAILED CONNECTION the same named causes an HTTP client gets. Until this,
59
+ // stdio let the raw upstream sentence through, and for the #427 case that sentence
60
+ // ("Make sure you are using the latest version") names the wrong component: the user has
61
+ // already upgraded Actual, and the thing to upgrade is actual-mcp-server.
62
+ //
63
+ // GATED ON THE BRAND, never on the message. This catch sees EVERY tool error, and the
64
+ // classifier's last-resort branches match prose: a #270 op timeout would read as `timeout`
65
+ // and a #422 rate-limit as `auth_failed`, replacing a true message with a false one. Only an
66
+ // error marked by initActualApiForOperation is classified here. See src/lib/init-failure.ts.
67
+ if (isInitFailure(err)) {
68
+ const { cause, sentence } = classifyInitFailure(err);
69
+ if (cause !== 'unknown') {
70
+ // stderr only: stdout is JSON-RPC framing (scripts/stdio-framing-check.mjs).
71
+ logger.warn('[STDIO] tool call failed while establishing the Actual connection', {
72
+ tool: rawName,
73
+ cause,
74
+ });
75
+ // An MCP tool error, not a thrown exception, so the client renders the sentence as the
76
+ // tool's result. The upstream string is deliberately dropped rather than appended: a
77
+ // stdio tool error is handed to a cloud model, and #438's fixed-sentence contract exists
78
+ // so no upstream stack, SQL, filesystem path or server URL has to be scrubbed correctly.
79
+ // The raw error is still in the stderr log above the operator's own terminal.
80
+ return { isError: true, content: [{ type: 'text', text: sentence }] };
81
+ }
82
+ }
83
+ throw err;
84
+ }
85
+ };
86
+ }
10
87
  export async function startStdioServer(mcp, capabilities, implementedTools, serverDescription, serverInstructions, toolSchemas, version) {
11
88
  const toolsList = Array.isArray(implementedTools) ? implementedTools : [];
12
89
  // #348: a synthetic session identity for this stdio process.
@@ -45,45 +122,7 @@ export async function startStdioServer(mcp, capabilities, implementedTools, serv
45
122
  return { tools };
46
123
  });
47
124
  // Call tool handler — delegates to ActualMCPConnection.executeTool()
48
- server.setRequestHandler(CallToolRequestSchema, async (request) => {
49
- const req = request;
50
- const params = req?.params ?? {};
51
- const rawName = params.name;
52
- const args = params.arguments;
53
- if (typeof rawName !== 'string') {
54
- throw new Error('Tool name must be a string');
55
- }
56
- logger.debug(`[STDIO] tools/call ${rawName}`);
57
- // Wrap the DISPATCH, not server.connect(). The transport invokes this handler
58
- // from an I/O event, which is outside any scope established around connect(),
59
- // so an AsyncLocalStorage context opened there would not be visible here.
60
- // Everything downstream is awaited inside this callback, so it propagates.
61
- //
62
- // Only `sessionId` is placed in the store, deliberately:
63
- // - no `principal`, or getActiveBudgetConfig() would attempt the #189
64
- // preferred-budget restore for a caller that has no authenticated
65
- // identity, and the env default would stop being authoritative.
66
- // - `transport: 'stdio'` is what keeps stdio OFF the pooled path, and it
67
- // stays that way after #419. switchBudget's slow path calls
68
- // connectionPool.getConnection(), so WITHOUT this marker the first switch
69
- // would silently move stdio onto the pooled branch, and the entry would
70
- // never be touched (touch() is called only from httpServer.ts), so it
71
- // would expire after the idle timeout and be torn down by the cleanup
72
- // sweep without the api lock, possibly mid-operation. That is a far larger
73
- // change than we want by accident.
74
- // #419 solved the per-call upstream login WITHOUT pooling stdio: a stdio
75
- // process keeps the api singleton alive between ops in shutdownActualApi
76
- // (see _shouldKeepSingletonAlive), so the legacy branch's init no-ops
77
- // after the first login. No pool entry is created, so there is nothing for
78
- // the sweep to evict and this comment's hazard never arises.
79
- // - `allowedBudgets` is absent, so under AUTH_PROVIDER=oidc switchBudget
80
- // still denies. Fail closed: a local pipe must not gain budget access an
81
- // HTTP caller would need an ACL for.
82
- const result = await requestContext.run({ sessionId: stdioSessionId, transport: 'stdio' }, () => mcp.executeTool(rawName, args ?? {}));
83
- return {
84
- content: [{ type: 'text', text: typeof result === 'string' ? result : JSON.stringify(result) }],
85
- };
86
- });
125
+ server.setRequestHandler(CallToolRequestSchema, createStdioCallToolHandler(mcp, stdioSessionId));
87
126
  const transport = new StdioServerTransport();
88
127
  // server.connect() calls transport.start() internally — do NOT call transport.start() manually
89
128
  await server.connect(transport);
@@ -0,0 +1,26 @@
1
+ import { z } from 'zod';
2
+ import { createTool } from '../lib/toolFactory.js';
3
+ import adapter from '../lib/actual-adapter.js';
4
+ /** #429: create a custom account group (Actual 26.9.0+). */
5
+ export default createTool({
6
+ name: 'actual_account_groups_create',
7
+ description: 'Create a custom account group for organising accounts in the sidebar. ' +
8
+ 'Returns the id of the new group. Assigning accounts to it is a separate step: ' +
9
+ 'set the account\'s account_group_id with actual_accounts_update. ' +
10
+ 'Requires an Actual server on 26.9.0 or newer.',
11
+ schema: z.object({
12
+ name: z.string().min(1).describe('Display name for the group, for example "Savings" or "Joint accounts"'),
13
+ sort_order: z
14
+ .number()
15
+ .optional()
16
+ .describe('Optional ordering position in the sidebar. Lower sorts first; omit to let Actual place it.'),
17
+ }),
18
+ handler: async (input) => {
19
+ const id = await adapter.createAccountGroup(input);
20
+ return { id, name: input.name };
21
+ },
22
+ examples: [
23
+ { description: 'Create a group for savings accounts', input: { name: 'Savings' } },
24
+ { description: 'Create a group pinned to the top of the sidebar', input: { name: 'Daily spending', sort_order: 0 } },
25
+ ],
26
+ });
@@ -0,0 +1,23 @@
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
+ /** #429: delete a custom account group (Actual 26.9.0+). */
6
+ export default createTool({
7
+ name: 'actual_account_groups_delete',
8
+ description: 'Delete a custom account group. The accounts in it are NOT deleted: they become ungrouped. ' +
9
+ 'Note that upstream clears those member references best-effort under sync, so a concurrent ' +
10
+ 'assignment on another device can survive the delete; treat any account still pointing at a ' +
11
+ 'missing group as ungrouped. Refuses with a not-found error, writing nothing, when the group ' +
12
+ 'does not exist. Requires an Actual server on 26.9.0 or newer.',
13
+ schema: z.object({
14
+ id: CommonSchemas.accountGroupId.describe('Id of the account group to delete (actual_account_groups_list lists them)'),
15
+ }),
16
+ handler: async (input) => {
17
+ await adapter.deleteAccountGroup(input.id);
18
+ return { success: true, id: input.id };
19
+ },
20
+ examples: [
21
+ { description: 'Delete a group, leaving its accounts ungrouped', input: { id: '00000000-0000-0000-0000-000000000000' } },
22
+ ],
23
+ });
@@ -0,0 +1,23 @@
1
+ import { z } from 'zod';
2
+ import { createTool } from '../lib/toolFactory.js';
3
+ import adapter from '../lib/actual-adapter.js';
4
+ /**
5
+ * #429: list the custom account groups (Actual 26.9.0+).
6
+ *
7
+ * Read the grouping semantics before relying on a group id: upstream clears member
8
+ * references on a best-effort basis under CRDT sync, so an account whose
9
+ * `account_group_id` points at a group that is missing here is UNGROUPED, not an error.
10
+ */
11
+ export default createTool({
12
+ name: 'actual_account_groups_list',
13
+ description: 'List the custom account groups used to organise accounts in the sidebar. ' +
14
+ 'Returns each group with its id, name and sort order. An account whose account_group_id ' +
15
+ 'does not appear here is ungrouped: upstream clears member references best-effort under ' +
16
+ 'sync, so a dangling reference is expected and is not an error. Requires an Actual server ' +
17
+ 'on 26.9.0 or newer, which is where account groups were introduced.',
18
+ schema: z.object({}),
19
+ handler: async () => {
20
+ return await adapter.getAccountGroups();
21
+ },
22
+ examples: [{ description: 'List every account group', input: {} }],
23
+ });
@@ -0,0 +1,25 @@
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
+ /** #429: rename or reorder a custom account group (Actual 26.9.0+). */
6
+ export default createTool({
7
+ name: 'actual_account_groups_update',
8
+ description: 'Rename a custom account group or change its sidebar position. ' +
9
+ 'Does not change which accounts belong to it: use actual_accounts_update to move an ' +
10
+ 'account between groups. Refuses with a not-found error, writing nothing, when the group ' +
11
+ 'does not exist. Requires an Actual server on 26.9.0 or newer.',
12
+ schema: z.object({
13
+ id: CommonSchemas.accountGroupId.describe('Id of the account group to update (actual_account_groups_list lists them)'),
14
+ name: z.string().min(1).optional().describe('New display name'),
15
+ sort_order: z.number().optional().describe('New ordering position in the sidebar; lower sorts first'),
16
+ }),
17
+ handler: async (input) => {
18
+ const { id, ...fields } = input;
19
+ await adapter.updateAccountGroup(id, fields);
20
+ return { success: true, id, updated: Object.keys(fields) };
21
+ },
22
+ examples: [
23
+ { description: 'Rename a group', input: { id: '00000000-0000-0000-0000-000000000000', name: 'Long-term savings' } },
24
+ ],
25
+ });
@@ -6,6 +6,10 @@ export { default as accounts_close } from './accounts_close.js';
6
6
  export { default as accounts_create } from './accounts_create.js';
7
7
  export { default as accounts_delete } from './accounts_delete.js';
8
8
  export { default as accounts_get_balance } from './accounts_get_balance.js';
9
+ export { default as account_groups_list } from './account_groups_list.js';
10
+ export { default as account_groups_create } from './account_groups_create.js';
11
+ export { default as account_groups_update } from './account_groups_update.js';
12
+ export { default as account_groups_delete } from './account_groups_delete.js';
9
13
  export { default as accounts_list } from './accounts_list.js';
10
14
  export { default as accounts_reopen } from './accounts_reopen.js';
11
15
  export { default as accounts_update } from './accounts_update.js';