actual-mcp-server 0.11.0 → 0.11.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -505,7 +505,7 @@ All configuration is via environment variables. Copy `.env.example` to `.env` to
505
505
  | `OIDC_SCOPES` | _(none)_ | No | Comma-separated required scopes; leave empty for Casdoor |
506
506
  | `AUTH_BUDGET_ACL` | _(none)_ | No | Per-user budget ACL; see [AI Client Setup](docs/guides/AI_CLIENT_SETUP.md#oidc-authentication-multi-user) |
507
507
  | `AUTH_BUDGET_ACL_SOURCE` | `static` | No | `static` uses the `AUTH_BUDGET_ACL` map above. `actual` derives the ACL from the Actual server's own per-file access list, so revoking someone in Actual takes effect here without a config edit. Requires a multi-user (OpenID) Actual server that was password-bootstrapped first |
508
- | `AUTH_BUDGET_ACL_CLAIM` | `sub` | No | Which OIDC claim is matched against Actual's user identity when the source is `actual`. `sub` matches Actual's `userId` and is strongly preferred; matching a name claim is riskier |
508
+ | `AUTH_BUDGET_ACL_CLAIM` | `auto` | No | Which token claim identifies the principal when the source is `actual`. `auto` mirrors Actual's own precedence (`preferred_username`, `login`, `email`, `id`, `sub`) and matches the result against Actual's `userName`. Set a single claim name to pin one instead |
509
509
  | `MCP_ENABLE_HTTPS` | `false` | No | Enable native TLS. Requires `MCP_HTTPS_CERT` and `MCP_HTTPS_KEY` |
510
510
  | `MCP_HTTPS_CERT` | _(none)_ | No | Path to PEM certificate file (required when `MCP_ENABLE_HTTPS=true`) |
511
511
  | `MCP_HTTPS_KEY` | _(none)_ | No | Path to PEM private key file (required when `MCP_ENABLE_HTTPS=true`) |
@@ -664,14 +664,16 @@ OIDC_SCOPES= # leave empty for Casdoor
664
664
 
665
665
  ```bash
666
666
  AUTH_BUDGET_ACL_SOURCE=actual # default: static (the AUTH_BUDGET_ACL map)
667
- AUTH_BUDGET_ACL_CLAIM=sub # default: sub, matched against Actual's userId
667
+ AUTH_BUDGET_ACL_CLAIM=auto # default: mirrors Actual's own claim precedence
668
668
  ```
669
669
 
670
+ **How the match works.** Actual does not store your IdP's `sub`. When a user logs in, Actual resolves their identity as the first non-empty of `preferred_username`, `login`, `email`, `id`, `sub`, saves that string as the user's `userName`, and generates its own unrelated UUID as `userId`. The ACL therefore matches your token against `userName`, using that same precedence. `AUTH_BUDGET_ACL_CLAIM=auto` (the default) does exactly this; set it to a single claim name to pin one instead.
671
+
670
672
  Three things to know before enabling it:
671
673
 
672
674
  - **It requires a multi-user Actual server**, meaning one configured with `ACTUAL_OPENID_*`. On a password-only server no named users exist and every principal resolves to nothing.
673
675
  - **Your Actual server must have been bootstrapped with a PASSWORD before OpenID was added.** This is a hard prerequisite of this MCP server generally, not just of this feature: `@actual-app/api` authenticates with a password, and an Actual server set up with OpenID from scratch refuses password login, so this server cannot connect to it at all. A password cannot be added afterwards.
674
- - **Keep `AUTH_BUDGET_ACL_CLAIM=sub` unless you have a specific reason not to.** `sub` is matched against Actual's stable `userId`. Matching a name-like claim instead is riskier: this server's own service account appears in every file's access list as the owner with a blank `userName`, so a principal whose name claim resolves to empty would match it.
676
+ - **The identity is only as trustworthy as the claim behind it.** OIDC guarantees that `sub` is unique and never reassigned; `preferred_username` and `email` carry no such guarantee and are often editable by the user. This exposure is inherited from Actual, which keys its own accounts the same way, not introduced here: anyone who can set their `preferred_username` to yours can impersonate you in Actual's login too. If that matters to you, pin `AUTH_BUDGET_ACL_CLAIM` to a claim your IdP does not let users edit, and make sure that is the claim your IdP uses to populate Actual's `user_name`.
675
677
 
676
678
  It is opt-in and fails closed: if the budget list cannot be read, or the principal matches no file, access is denied rather than granted.
677
679
 
@@ -769,4 +771,4 @@ The software is provided **as-is**, without warranty of any kind. The author acc
769
771
 
770
772
  ---
771
773
 
772
- **Version:** 0.11.0 | **Tool Count:** 74 (verified LibreChat-compatible)
774
+ **Version:** 0.11.2 | **Tool Count:** 74 (verified LibreChat-compatible)
@@ -4,7 +4,7 @@
4
4
  // instead of hand-maintained JSON, so revoking someone in Actual takes effect
5
5
  // here without a config edit and a restart.
6
6
  //
7
- // WHY userId AND NOT userName, verified against a live multi-user server.
7
+ // WHAT THE ACL JOINS ON, and the mistake this replaced (#343).
8
8
  //
9
9
  // `getBudgets()` returns, per remote file:
10
10
  //
@@ -14,18 +14,25 @@
14
14
  // { "userId": "a6067f04-...", "displayName": "", "userName": "", "owner": true }
15
15
  // ]
16
16
  //
17
- // That second row is THIS SERVER'S OWN service account. `@actual-app/api`
18
- // authenticates with a password, that password identity owns every file the API
19
- // creates, and it has no userName because it is not an OpenID user. So a blank
20
- // `userName` is present in every file's access list on every deployment.
17
+ // v0.11.0 matched the OIDC `sub` against `userId`, on the reasoning that `sub` is
18
+ // the only claim OIDC guarantees stable and unique, and that a UUID cannot collide
19
+ // with the blank `userName` on the service-account row. Both halves of that are
20
+ // true and the conclusion was still wrong: `userId` is a UUID Actual mints for
21
+ // ITSELF at account creation (openid.ts) and corresponds to nothing any IdP
22
+ // issues. Nothing ever matched, and every user was denied every budget. It failed
23
+ // closed, so it locked people out rather than letting them in, but the feature was
24
+ // inert. Reported by the requester on #317, who supplied the token dump that made
25
+ // it obvious.
21
26
  //
22
- // A name-based match therefore has a live escalation path: a principal whose
23
- // configured claim is missing or empty would match the blank row, which carries
24
- // `owner: true`, and be granted access to every file on the server. Matching
25
- // UUIDs makes that unrepresentable, because no IdP subject equals the service
26
- // account's UUID. AUTH_BUDGET_ACL_CLAIM still allows name matching for
27
- // deployments that need it, and the empty-principal guard below is what keeps
28
- // that option from reintroducing the hole.
27
+ // The join is therefore on `userName`, resolved via Actual's own claim precedence
28
+ // (see ACTUAL_IDENTITY_PRECEDENCE below).
29
+ //
30
+ // THE BLANK-ROW GUARD IS WHAT MAKES NAME MATCHING SAFE. That second row above is
31
+ // this server's own password/service account: it owns every file the API creates
32
+ // and has no `userName`, because it is not an OpenID user. A principal whose claim
33
+ // resolved to empty would match it and inherit `owner: true` on every file. So a
34
+ // blank or missing claim yields null, never '', and the matcher skips blank
35
+ // candidates. Those two guards are load bearing; do not relax either.
29
36
  //
30
37
  // WHY THE RESOLUTION RUNS UNDER AN EXPLICIT WILDCARD CONTEXT.
31
38
  //
@@ -60,18 +67,104 @@ export function _resetDynamicAclCache() {
60
67
  _cache.clear();
61
68
  }
62
69
  /**
63
- * Extract the identity value to match on, from the claim named by
64
- * AUTH_BUDGET_ACL_CLAIM. Returns null when the claim is absent or blank.
70
+ * Actual's OWN identity precedence, mirrored exactly (#343).
71
+ *
72
+ * From `packages/sync-server/src/accounts/openid.ts`:
73
+ *
74
+ * const identity = userInfo.preferred_username ?? userInfo.login ??
75
+ * userInfo.email ?? userInfo.id ?? userInfo.sub;
76
+ * userId = uuidv4();
77
+ * INSERT INTO users (id, user_name, ...) VALUES (userId, identity, ...)
78
+ *
79
+ * So `user_name` holds whichever of these the IdP supplied FIRST, and `userId`
80
+ * is a UUID Actual generated for itself. This list is not a preference of ours;
81
+ * it is the only sequence that reproduces the value Actual actually stored, and
82
+ * the ORDER must track upstream's or the join silently stops matching.
83
+ *
84
+ * ONE DELIBERATE DIVERGENCE. Upstream uses `??`, which stops at the first
85
+ * non-nullish value, including `''` and non-strings. We SKIP blank and non-string
86
+ * entries and keep walking. That is deliberately safer here: an IdP that emits
87
+ * `preferred_username: ""` makes Actual store `user_name = ''`, and stopping there
88
+ * would hand us an empty principal, which is precisely the value that must never
89
+ * be matched against the service account's blank `userName`. The cost is that in
90
+ * that edge case our principal and Actual's `user_name` disagree, so the user is
91
+ * DENIED rather than matched. Denial is the correct side to err on.
92
+ *
93
+ * SOURCE OF CLAIMS, and a real limitation. Upstream evaluates this over the
94
+ * UserInfo endpoint response; we evaluate it over the verified ACCESS TOKEN
95
+ * payload (`req.auth.claims`, set in httpServer.ts), because that is what this
96
+ * server has. Those two sets are not always equal: several IdPs omit
97
+ * `preferred_username` from access tokens unless a mapper is configured. If the
98
+ * winning claim here differs from the one Actual used, the join fails and the user
99
+ * is denied. That is why the no-match path logs WHICH claim won, and why the
100
+ * documentation tells operators to ensure the claim is present in the access token.
101
+ */
102
+ export const ACTUAL_IDENTITY_PRECEDENCE = ['preferred_username', 'login', 'email', 'id', 'sub'];
103
+ /** Sentinel for AUTH_BUDGET_ACL_CLAIM meaning "walk the precedence above". */
104
+ export const AUTO_CLAIM = 'auto';
105
+ /**
106
+ * Extract the identity value to match against Actual's `userName`.
107
+ *
108
+ * WHY THIS IS NOT `sub`, despite `sub` being the right answer in general.
109
+ *
110
+ * OIDC Core guarantees only `sub` is "locally unique and never reassigned";
111
+ * `preferred_username` carries no such guarantee and is mutable at the IdP. Under
112
+ * normal circumstances keying on `sub` is simply correct, and that is what this
113
+ * shipped with in v0.11.0.
114
+ *
115
+ * It could never work. Actual does not store `sub` anywhere we can see: it
116
+ * derives the identity by the precedence above and stores THAT, alongside a UUID
117
+ * of its own invention. There is no field in `usersWithAccess` that a `sub` can
118
+ * be compared against. Matching the best-practice identifier resolved every
119
+ * principal to zero budgets and locked every user out (#343).
120
+ *
121
+ * So we mirror Actual. The security consequence is inherited, not introduced: an
122
+ * IdP that lets a user set `preferred_username` (or an unverified `email`) to
123
+ * another user's value can impersonate them HERE exactly as it can in Actual's own
124
+ * login. An operator who wants a stronger guarantee should pin a single trusted
125
+ * claim with AUTH_BUDGET_ACL_CLAIM and ensure their IdP populates Actual's
126
+ * `user_name` from that same claim.
65
127
  *
66
128
  * Blank is deliberately null and not '': an empty string would compare equal to
67
- * the service account's empty userName. See the header note.
129
+ * the service account's blank `userName`, which carries `owner: true`. That guard
130
+ * is what makes name matching safe, and it is why the precedence SKIPS blank
131
+ * entries rather than stopping at the first one that merely exists.
68
132
  */
69
133
  export function extractPrincipalValue(claims, subject, claimName) {
70
- const raw = claimName === 'sub' ? (subject ?? claims['sub']) : claims[claimName];
71
- if (typeof raw !== 'string')
72
- return null;
73
- const trimmed = raw.trim();
74
- return trimmed.length > 0 ? trimmed : null;
134
+ const read = (name) => {
135
+ // `sub` is normally surfaced as the token subject rather than a claim entry.
136
+ const raw = name === 'sub' ? (subject ?? claims['sub']) : claims[name];
137
+ if (typeof raw !== 'string')
138
+ return null;
139
+ // Blank-ONLY rejection. The value itself is returned UNTRIMMED, because
140
+ // Actual's user_name uniqueness is exact: " alice" and "alice" are two
141
+ // different accounts. Trimming here (and again on the candidate) would let a
142
+ // user who can register " alice" at the IdP match the real alice's access
143
+ // rows and read her budgets. Trim to decide emptiness, never to compare.
144
+ return raw.trim().length > 0 ? raw : null;
145
+ };
146
+ if (claimName !== AUTO_CLAIM)
147
+ return read(claimName);
148
+ for (const name of ACTUAL_IDENTITY_PRECEDENCE) {
149
+ const value = read(name);
150
+ if (value !== null)
151
+ return value;
152
+ }
153
+ return null;
154
+ }
155
+ /**
156
+ * Which claim the precedence actually selected, for logging. Returns null when
157
+ * nothing resolved. Kept separate so the hot path stays a plain string.
158
+ */
159
+ export function resolvedClaimName(claims, subject, claimName) {
160
+ if (claimName !== AUTO_CLAIM) {
161
+ return extractPrincipalValue(claims, subject, claimName) === null ? null : claimName;
162
+ }
163
+ for (const name of ACTUAL_IDENTITY_PRECEDENCE) {
164
+ if (extractPrincipalValue(claims, subject, name) !== null)
165
+ return name;
166
+ }
167
+ return null;
75
168
  }
76
169
  /**
77
170
  * Map a getBudgets() payload to the sync IDs the given principal may access.
@@ -79,7 +172,7 @@ export function extractPrincipalValue(claims, subject, claimName) {
79
172
  * Pure, so the matching rules are unit-testable against the captured live
80
173
  * fixture without a server. Exported for that reason.
81
174
  */
82
- export function matchAllowedFiles(files, principalValue, claimName) {
175
+ export function matchAllowedFiles(files, principalValue) {
83
176
  if (!Array.isArray(files))
84
177
  return [];
85
178
  // An empty principal can never match. Belt and braces: extractPrincipalValue
@@ -87,7 +180,6 @@ export function matchAllowedFiles(files, principalValue, claimName) {
87
180
  // depend on its callers for that guarantee.
88
181
  if (!principalValue)
89
182
  return [];
90
- const matchesName = claimName !== 'sub';
91
183
  const allowed = new Set();
92
184
  for (const entry of files) {
93
185
  if (!entry || typeof entry !== 'object')
@@ -104,13 +196,21 @@ export function matchAllowedFiles(files, principalValue, claimName) {
104
196
  // Read the whole list, not only `owner: true`. A file shared with a
105
197
  // household member appears with owner: false, and that is exactly the
106
198
  // case this feature exists to honour.
107
- const candidate = matchesName ? u.userName : u.userId;
199
+ //
200
+ // ALWAYS `userName`, never `userId` (#343). `userId` is a UUID Actual
201
+ // generates for itself at account creation and it corresponds to nothing
202
+ // any IdP issues, so comparing a token value against it can only ever
203
+ // return zero matches. `userName` is the field that holds the identity
204
+ // Actual resolved from the token.
205
+ const candidate = u.userName;
108
206
  if (typeof candidate !== 'string')
109
207
  continue;
110
- const trimmed = candidate.trim();
111
- if (trimmed.length === 0)
208
+ // Blank check trims; the COMPARISON does not. Actual's user_name uniqueness
209
+ // is exact, so trimming both sides would collapse " alice" and "alice" into
210
+ // one identity and hand the second one's budgets to the first.
211
+ if (candidate.trim().length === 0)
112
212
  continue; // never match the blank service-account row
113
- if (trimmed === principalValue) {
213
+ if (candidate === principalValue) {
114
214
  allowed.add(syncId);
115
215
  break;
116
216
  }
@@ -125,7 +225,7 @@ export function matchAllowedFiles(files, principalValue, claimName) {
125
225
  * An authorization source that opens up when the thing it queries is unreachable
126
226
  * is worse than no authorization source at all.
127
227
  */
128
- export async function resolveAllowedBudgetsFromActual(principalValue) {
228
+ export async function resolveAllowedBudgetsFromActual(principalValue, lastResolvedClaim) {
129
229
  if (!principalValue) {
130
230
  log.warn('dynamic ACL: no usable principal value; denying', { claim: config.AUTH_BUDGET_ACL_CLAIM });
131
231
  return [];
@@ -143,12 +243,29 @@ export async function resolveAllowedBudgetsFromActual(principalValue) {
143
243
  log.error('dynamic ACL: could not read the budget list; denying access', err);
144
244
  return [];
145
245
  }
146
- const allowed = matchAllowedFiles(files, principalValue, config.AUTH_BUDGET_ACL_CLAIM);
246
+ const allowed = matchAllowedFiles(files, principalValue);
147
247
  if (allowed.length === 0) {
248
+ // #343 was a TOTAL silent denial that survived two releases because the log
249
+ // said nothing useful. Name the claim that actually won and the userNames on
250
+ // offer, so the next mismatch is diagnosable from one log line instead of a
251
+ // debugging session. The principal itself is not logged at warn level (it is
252
+ // an identity); it is available at debug.
253
+ const offered = Array.isArray(files)
254
+ ? [...new Set(files
255
+ .flatMap((f) => (Array.isArray(f?.usersWithAccess) ? f.usersWithAccess : []))
256
+ .map((u) => u?.userName)
257
+ .filter((n) => typeof n === 'string' && n.trim().length > 0))]
258
+ : [];
148
259
  log.warn('dynamic ACL: principal matched no budget file', {
149
- claim: config.AUTH_BUDGET_ACL_CLAIM,
260
+ configuredClaim: config.AUTH_BUDGET_ACL_CLAIM,
261
+ resolvedFromClaim: lastResolvedClaim ?? '(none)',
150
262
  fileCount: Array.isArray(files) ? files.length : 0,
263
+ knownUserNames: offered,
264
+ hint: 'The value from the token did not equal any userName above. Actual derives userName from the ' +
265
+ 'UserInfo response; this server reads the ACCESS TOKEN. If they differ, add a claim mapper at your ' +
266
+ 'IdP or pin AUTH_BUDGET_ACL_CLAIM to a claim present in the access token.',
151
267
  });
268
+ log.debug('dynamic ACL: unmatched principal', { principal: principalValue });
152
269
  }
153
270
  _cache.set(principalValue, { at: Date.now(), allowed });
154
271
  return allowed;
@@ -24,7 +24,7 @@
24
24
  // to filter results by permitted sync IDs (forward-looking multi-budget support).
25
25
  import config from '../config.js';
26
26
  import logger from '../logger.js';
27
- import { extractPrincipalValue, resolveAllowedBudgetsFromActual } from './budget-acl-dynamic.js';
27
+ import { extractPrincipalValue, resolveAllowedBudgetsFromActual, resolvedClaimName } from './budget-acl-dynamic.js';
28
28
  // ---------------------------------------------------------------------------
29
29
  // ACL map (parsed once from env, cached)
30
30
  // ---------------------------------------------------------------------------
@@ -157,7 +157,8 @@ async function resolveDynamicAcl(req, res, next) {
157
157
  const subject = auth?.subject;
158
158
  try {
159
159
  const principalValue = extractPrincipalValue(claims, subject, config.AUTH_BUDGET_ACL_CLAIM);
160
- const allowed = await resolveAllowedBudgetsFromActual(principalValue);
160
+ const winningClaim = resolvedClaimName(claims, subject, config.AUTH_BUDGET_ACL_CLAIM);
161
+ const allowed = await resolveAllowedBudgetsFromActual(principalValue, winningClaim);
161
162
  if (allowed.length > 0) {
162
163
  req.allowedBudgets = allowed;
163
164
  next();
@@ -104,18 +104,45 @@ export const configSchema = z.object({
104
104
  // functions when that server runs in multi-user (OpenID) mode. Defaulting to
105
105
  // 'static' means no existing deployment changes posture on upgrade.
106
106
  AUTH_BUDGET_ACL_SOURCE: z.enum(['static', 'actual']).default('static'),
107
- // #338: which OIDC claim is matched against Actual's user identity when
108
- // AUTH_BUDGET_ACL_SOURCE=actual. Default 'sub', matched against `userId`.
107
+ // #338/#343: which token claim identifies the principal when
108
+ // AUTH_BUDGET_ACL_SOURCE=actual. Default 'auto' walks Actual's OWN precedence
109
+ // (preferred_username, login, email, id, sub) and matches the result against
110
+ // Actual's `userName`.
109
111
  //
110
- // Do NOT casually change this to a name-like claim. Verified against a live
111
- // multi-user server: the MCP server's own service account appears in EVERY
112
- // file's usersWithAccess with `owner: true` and a BLANK `userName`. A
113
- // name-based match whose claim resolves to empty would match that row and gain
114
- // owner access to every file. Matching UUIDs makes that unrepresentable.
112
+ // 'auto' is the default because it is the only setting that works out of the
113
+ // box. v0.11.0 defaulted to 'sub', which is the correct identifier by OIDC
114
+ // (the spec guarantees only `sub` is unique and never reassigned) and which
115
+ // could nonetheless never match: Actual stores no `sub`, it stores the
116
+ // precedence result in `user_name` plus a UUID of its own in `id`. See #343.
117
+ //
118
+ // Set a specific claim name to pin one trusted claim instead, which is worth
119
+ // doing if your IdP lets users edit `preferred_username` or does not verify
120
+ // `email`. Whatever you pin must be the same claim your IdP used to populate
121
+ // Actual's `user_name`, or nothing will match.
115
122
  AUTH_BUDGET_ACL_CLAIM: z
116
123
  .string()
117
- .default('sub')
118
- .refine((v) => /^[A-Za-z][A-Za-z0-9_.-]{0,63}$/.test(v), 'AUTH_BUDGET_ACL_CLAIM must be a simple claim name'),
124
+ .default('auto')
125
+ .refine((v) => v === 'auto' || /^[A-Za-z][A-Za-z0-9_.-]{0,63}$/.test(v), 'AUTH_BUDGET_ACL_CLAIM must be "auto" or a simple claim name'),
126
+ })
127
+ // #343 UPGRADE GUARD. v0.11.0 and v0.11.1 shipped AUTH_BUDGET_ACL_CLAIM=sub as
128
+ // the default AND the README told operators to keep it. That combination cannot
129
+ // match anything: Actual stores no `sub`, so every principal resolves to zero
130
+ // budgets and every user gets a 403 saying only "no budget access configured".
131
+ //
132
+ // Anyone who followed that advice has `sub` pinned in their .env, where it would
133
+ // survive this upgrade and keep them locked out with the new default never
134
+ // applying. Refusing to start is deliberately louder than a warning: a config
135
+ // error names the problem and the fix at the moment of the upgrade, whereas the
136
+ // alternative is a silent, total denial that already survived two releases
137
+ // undetected. Scoped narrowly to the exact broken pair, so no other deployment
138
+ // is affected.
139
+ .refine((cfg) => !(cfg.AUTH_BUDGET_ACL_SOURCE === 'actual' && cfg.AUTH_BUDGET_ACL_CLAIM === 'sub'), {
140
+ path: ['AUTH_BUDGET_ACL_CLAIM'],
141
+ message: 'AUTH_BUDGET_ACL_CLAIM=sub cannot work with AUTH_BUDGET_ACL_SOURCE=actual: Actual does not store the ' +
142
+ 'OIDC sub. It derives a user_name from preferred_username/login/email/id/sub and generates its own ' +
143
+ 'unrelated userId, so matching sub denies every user. Use AUTH_BUDGET_ACL_CLAIM=auto (the default, ' +
144
+ 'which mirrors that precedence), or pin a claim that your IdP also used to populate Actual user_name. ' +
145
+ 'See #343.',
119
146
  })
120
147
  // When native TLS is enabled, both the cert and key paths must be provided.
121
148
  // MCP_ENABLE_HTTPS is transformed to a boolean above, so this object-level
@@ -130,7 +157,17 @@ export const configSchema = z.object({
130
157
  function getConfig() {
131
158
  const result = configSchema.safeParse(process.env);
132
159
  if (!result.success) {
133
- const missing = result.error.issues.map(i => ` • ${i.path.join('.')}`).join('\n');
160
+ // Print the MESSAGE, not just the variable name. Every refine in this file
161
+ // writes an explanation of what is wrong and how to fix it, and all of them
162
+ // were being discarded here, so a validation failure read as "this var is
163
+ // missing" even when it was present and merely invalid. Found while adding
164
+ // the #343 upgrade guard, whose whole purpose is to explain itself.
165
+ const missing = result.error.issues
166
+ .map((i) => {
167
+ const name = i.path.join('.') || '(config)';
168
+ return i.message && i.message !== 'Required' ? ` • ${name}: ${i.message}` : ` • ${name}`;
169
+ })
170
+ .join('\n');
134
171
  console.error(`\n❌ Missing or invalid environment variables:\n${missing}\n\n` +
135
172
  `Set them in a .env file in the current directory, or export them before running.\n` +
136
173
  `Required: ACTUAL_SERVER_URL, ACTUAL_PASSWORD, ACTUAL_BUDGET_SYNC_ID\n` +
@@ -1302,7 +1302,20 @@ export async function updateRule(id, fields) {
1302
1302
  const fieldsObj = fields;
1303
1303
  const rule = {
1304
1304
  id,
1305
- stage: fieldsObj.stage ?? existingRule.stage,
1305
+ // #342: `??` is WRONG for stage, and only for stage. null is a MEANINGFUL
1306
+ // value here (Actual's normal stage), not an absent one, so
1307
+ // `fieldsObj.stage ?? existingRule.stage` silently discards an explicit
1308
+ // `stage: null` and leaves the rule where it was. That made it impossible
1309
+ // to move a rule OUT of 'pre' or 'post' back to the normal stage, and it
1310
+ // failed silently: the call still returned success.
1311
+ //
1312
+ // Decide on PRESENCE of the key instead. The tool clones its input with
1313
+ // JSON.parse(JSON.stringify(...)), which preserves an explicit null key, so
1314
+ // `in` distinguishes "not supplied" from "supplied as null" correctly.
1315
+ //
1316
+ // The other three fields keep `??` deliberately: none of them treats null
1317
+ // as a distinct legal value, so nullish-coalescing is the right merge there.
1318
+ stage: 'stage' in fieldsObj ? fieldsObj.stage : existingRule.stage,
1306
1319
  conditionsOp: fieldsObj.conditionsOp ?? existingRule.conditionsOp,
1307
1320
  conditions: fieldsObj.conditions ?? existingRule.conditions ?? [],
1308
1321
  actions: fieldsObj.actions ?? existingRule.actions ?? [],
@@ -33,7 +33,29 @@ const FIELD_OPERATORS = {
33
33
  'date': { type: 'date', operators: ['is', 'gte', 'lte', 'gt', 'lt'] },
34
34
  };
35
35
  const InputSchema = z.object({
36
- stage: z.enum(['pre', 'post']).optional().default('pre').describe('When to apply the rule - "pre" (before transactions sync) or "post" (after transactions sync)'),
36
+ // #342: three stages, and the default one is `null`, not a string.
37
+ //
38
+ // Verified against Actual's validator (transaction-rules.ts) and a live server:
39
+ // it accepts exactly 'pre', 'post' and null, and REJECTS the literal "default"
40
+ // with `Invalid rule stage: default`. The published API reference says
41
+ // pre/default/post, which is wrong in both directions (reported upstream as
42
+ // actualbudget/actual#8682).
43
+ //
44
+ // The default here MUST be null and must be SENT. Two traps:
45
+ // - Defaulting to 'pre' (what this did until #342) silently puts every
46
+ // MCP-created rule ahead of every rule the user made in the UI, because
47
+ // Actual runs stages in the order pre, default, post. No error, no warning.
48
+ // - Simply making it .optional() and omitting the key does NOT work: on a
49
+ // create the validator always runs and rejects `undefined` with
50
+ // `Invalid rule stage: undefined`.
51
+ stage: z
52
+ .enum(['pre', 'post'])
53
+ .nullable()
54
+ .optional()
55
+ .default(null)
56
+ .describe('When to apply the rule. null (the default) is Actual\'s normal stage, the one a rule gets in the UI ' +
57
+ 'when no stage is chosen. "pre" runs before the default stage, "post" after. Leave unset unless you ' +
58
+ 'specifically need the rule to out-rank or defer to the user\'s existing rules.'),
37
59
  conditionsOp: z.enum(['and', 'or']).optional().default('and').describe('How to combine multiple conditions'),
38
60
  conditions: z.array(ConditionSchema).describe('Array of conditions that must be met for the rule to apply'),
39
61
  actions: z.array(ActionSchema).describe('Array of actions to perform when conditions are met'),
@@ -49,10 +71,11 @@ IMPORTANT Field Types:
49
71
  - "notes", "description" (string) - for text matching. Supports: contains, matches, doesNotContain, is, isNot
50
72
  - "amount", "date" (number/date) - supports: is, gte, lte, gt, lt
51
73
 
52
- Stage options: 'pre' or 'post'.
74
+ Stage: omit it (the normal stage, recommended), or 'pre' to run before the user's own rules, or 'post' to run after.
75
+ Do NOT pass "default" as a string; Actual rejects it. The normal stage is null, which is what omitting gives you.
53
76
  Action operators: 'set', 'set-split-amount', 'link-schedule', 'append-notes'.
54
77
 
55
- Example: {stage: "post", conditionsOp: "and", conditions: [{field: "imported_payee", op: "contains", value: "Amazon"}], actions: [{op: "set", field: "category", value: "category-uuid"}]}`,
78
+ Example (no stage, so the rule lands in the normal stage alongside the user's own rules): {conditionsOp: "and", conditions: [{field: "imported_payee", op: "contains", value: "Amazon"}], actions: [{op: "set", field: "category", value: "category-uuid"}]}`,
56
79
  inputSchema: InputSchema,
57
80
  call: async (args, _meta) => {
58
81
  // Zod validation errors are formatted centrally by actualToolsManager (#206).
@@ -55,7 +55,17 @@ const FIELD_OPERATORS = {
55
55
  'date': { type: 'date', operators: ['is', 'gte', 'lte', 'gt', 'lt'] },
56
56
  };
57
57
  const InputSchema = z.object({
58
- stage: z.enum(['pre', 'post']).optional().default('pre').describe('When to apply the rule — "pre" or "post"'),
58
+ // #342: see the long note in rules_create.ts. null is Actual's DEFAULT stage;
59
+ // the literal "default" is rejected by the validator. No .default() here on
60
+ // purpose: this tool upserts, so "not supplied" must mean "leave the matched
61
+ // rule's stage alone", which is decided at the merge below. The create path
62
+ // supplies null explicitly.
63
+ stage: z
64
+ .enum(['pre', 'post'])
65
+ .nullable()
66
+ .optional()
67
+ .describe('When to apply the rule. null is Actual\'s normal stage (what the UI gives a rule with no stage chosen); ' +
68
+ '"pre" runs before it, "post" after. On an update, omitting this leaves the existing stage unchanged.'),
59
69
  conditionsOp: z.enum(['and', 'or']).optional().default('and').describe('How to combine multiple conditions'),
60
70
  conditions: z.array(ConditionSchema).describe('Array of conditions that must be met'),
61
71
  actions: z.array(ActionSchema).describe('Array of actions to perform when conditions match'),
@@ -102,7 +112,9 @@ const tool = {
102
112
 
103
113
  Matching logic: a rule is considered a "match" when it has the same set of conditions (field + op + value triples) and the same conditionsOp ("and"/"or"). Condition order is irrelevant.
104
114
 
105
- When a match is found: the rule's actions (and stage) are REPLACED with the new values.
115
+ When a match is found: the rule's actions are REPLACED with the new values. The stage is replaced ONLY if you
116
+ supply one; omit stage to leave the matched rule in whatever stage it is already in. On a newly created rule,
117
+ an omitted stage means the normal stage (null), the same one the UI assigns.
106
118
  When no match exists: a new rule is created.
107
119
 
108
120
  IMPORTANT Field Types:
@@ -176,7 +188,13 @@ Returns: { id, created: boolean } — created=true if new rule was created, fals
176
188
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
177
189
  const merged = {
178
190
  id: matchedRule.id,
179
- stage: ruleData.stage ?? matchedRule.stage,
191
+ // #342: `??` is WRONG for stage. null is a MEANINGFUL value here (Actual's
192
+ // default stage), not an absent one, so `ruleData.stage ?? existing` would
193
+ // silently discard an explicit `stage: null` and keep the old stage,
194
+ // making it impossible to move a rule back to the default stage. Decide on
195
+ // PRESENCE of the key instead. Every other field below is fine with `??`
196
+ // because none of them treats null as a distinct legal value.
197
+ stage: 'stage' in ruleData ? ruleData.stage : matchedRule.stage,
180
198
  conditionsOp: ruleData.conditionsOp ?? matchedRule.conditionsOp,
181
199
  conditions: ruleData.conditions ?? matchedRule.conditions ?? [],
182
200
  actions: ruleData.actions ?? matchedRule.actions ?? [],
@@ -185,7 +203,13 @@ Returns: { id, created: boolean } — created=true if new rule was created, fals
185
203
  return { id: matchedRule.id, created: false };
186
204
  }
187
205
  else {
188
- // CREATE new rule
206
+ // CREATE new rule.
207
+ // #342: stage has no Zod default on this tool (see the schema note), so an
208
+ // omitted stage arrives as undefined. Actual's validator ALWAYS runs on a
209
+ // create and rejects undefined with `Invalid rule stage: undefined`, so the
210
+ // key must be present. null is the correct value: Actual's default stage.
211
+ if (ruleData.stage === undefined)
212
+ ruleData.stage = null;
189
213
  const rawId = await rawCreateRule(ruleData);
190
214
  return { id: normalizeToId(rawId), created: true };
191
215
  }
@@ -28,7 +28,17 @@ const FIELD_OPERATORS = {
28
28
  const InputSchema = z.object({
29
29
  id: z.string().describe('Rule ID to update'),
30
30
  fields: z.object({
31
- stage: z.enum(['pre', 'post']).optional().describe('When to apply the rule - "pre" (before transactions sync) or "post" (after transactions sync)'),
31
+ // #342: null is Actual's DEFAULT stage; the literal "default" is rejected by
32
+ // its validator. Deliberately NO .default() here: this is a partial update, so
33
+ // omitting stage must leave the existing one alone. Actual only validates
34
+ // stage on an update when the key is present, so omitting it is safe (unlike
35
+ // on a create, where undefined is rejected).
36
+ stage: z
37
+ .enum(['pre', 'post'])
38
+ .nullable()
39
+ .optional()
40
+ .describe('When to apply the rule. null is Actual\'s normal stage (what the UI gives a rule with no stage chosen); ' +
41
+ '"pre" runs before it, "post" after. Omit to leave the rule\'s current stage unchanged.'),
32
42
  conditionsOp: z.enum(['and', 'or']).optional().describe('How to combine multiple conditions'),
33
43
  conditions: z.array(ConditionSchema).optional().describe('New array of conditions'),
34
44
  actions: z.array(ActionSchema).optional().describe('New array of actions'),
@@ -45,7 +55,9 @@ IMPORTANT Field Types:
45
55
  - "notes", "description" (string) - for text matching. Supports: contains, matches, doesNotContain, is, isNot
46
56
  - "amount", "date" (number/date) - supports: is, gte, lte, gt, lt
47
57
 
48
- Stage options: 'pre' or 'post'.
58
+ Stage: omit it to leave the rule where it is. Pass null to move it to the normal stage (where UI-created
59
+ rules live), 'pre' to run before that stage, or 'post' to run after. Do NOT pass "default" as a string;
60
+ Actual rejects it.
49
61
  Action operators: 'set', 'set-split-amount', 'link-schedule', 'append-notes'.
50
62
 
51
63
  Do not include the rule ID in the fields object - it is provided separately.`,
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "actual-mcp-server",
3
3
  "displayName": "Actual MCP Server",
4
- "version": "0.11.0",
4
+ "version": "0.11.2",
5
5
  "engines": {
6
6
  "node": ">=22.0.0",
7
7
  "npm": ">=10.0.0"
@@ -30,7 +30,7 @@
30
30
  "verify-tools": "npm run build && node scripts/verify-tools.js",
31
31
  "check:coverage": "node scripts/list-actual-api-methods.mjs",
32
32
  "direct-sync": "node scripts/direct-sync/bank-sync-direct.mjs",
33
- "test:unit-js": "node tests/unit/transactions_create.test.js && node tests/unit/generated_tools.smoke.test.js && node tests/unit/schema_validation.test.js && node tests/unit/schema_json_openai_compat.test.js && node tests/unit/config_https_validation.test.js && node tests/unit/config_data_dir.test.js && node tests/unit/config_drift.test.js && node tests/unit/port_alignment.test.js && node tests/unit/dockerfile_data_dir_alignment.test.js && node tests/unit/unraid_template_alignment.test.js && node tests/unit/entrypoint_invariants.test.js && node tests/unit/compose_profile_sync.test.js && node tests/unit/advertised_tools_sync.test.js && node tests/unit/knip_config.test.js && node tests/unit/readme_stats_sync.test.js && node tests/unit/config_insecure_upstream.test.js && node tests/unit/workflow_release_guards.test.js && node tests/unit/report_train_failure.test.js && node tests/unit/unit_chain_membership.test.js && node tests/unit/api_surface_drift.test.js && node tests/unit/api_coverage_baseline.test.js && node tests/unit/train_preflight.test.js && node tests/unit/verify_published_artifacts.test.js && node tests/unit/report_train_stale.test.js && node tests/unit/stdio_framing_check.test.js && node tests/unit/server_info.test.js && node tests/unit/transfers_create.test.js && node tests/unit/bot_target_branch.test.js && node tests/unit/auth_posture.test.js && node tests/unit/auth_posture_wiring.test.js && node tests/unit/auth-acl.test.js && node tests/unit/bug76.test.js && node tests/unit/budgets_setAmount.test.js && node tests/unit/budgets_transfer.test.js && node tests/unit/transactions_uncategorized.test.js && node tests/unit/httpServer_session_init.test.js && node tests/unit/httpServer_session_not_found.test.js && node tests/unit/manual_mcp_client_retry.test.js && node tests/unit/manual_mcp_client_session.test.js && node tests/unit/manual_mcp_client_circuit.test.js && node tests/unit/manual_runner_killswitch.test.js && node tests/unit/adapter_auth_rate_limit.test.js && node tests/unit/adapter_session_reuse.test.js && node tests/unit/retry_classifier.test.js && node tests/unit/adapter_module_surface.test.js && node tests/unit/adapter_nonidempotent_no_retry.test.js && node tests/unit/pool_liveness.test.js && node tests/unit/pool_shutdown_all.test.js && node tests/unit/query_where_operators.test.js && node tests/unit/query_run_validation.test.js && node tests/unit/adapter_with_write_session.test.js && node tests/unit/category_groups_delete.test.js && node tests/unit/rules_delete.test.js && node tests/unit/schedules_delete.test.js && node tests/unit/payees_delete.test.js && node tests/unit/rules_create_or_update.test.js && node tests/unit/unhandled-rejection.test.js && node tests/unit/rejection-allowlist-purity.test.js && node tests/unit/httpServer_bearer_auth.test.js && node tests/unit/httpServer_oidc_audience.test.js && node tests/unit/httpServer_oidc_auth_verification.test.js && node tests/unit/httpServer_oidc_jwks_discovery.test.js && node tests/unit/httpServer_body_limit.test.js && node tests/unit/httpServer_bind_host.test.js && node tests/unit/adapter_write_pool_cooperation.test.js && node tests/unit/budget_acl_enforcement.test.js && node tests/unit/budget_preference_store.test.js && node tests/unit/budget_preference_restore.test.js && node tests/unit/tags_list.test.js && node tests/unit/tags_create.test.js && node tests/unit/tags_update.test.js && node tests/unit/tags_delete.test.js && node tests/unit/notes_get.test.js && node tests/unit/notes_update.test.js && node tests/unit/payees_common_list.test.js && node tests/unit/transactions_update_guard.test.js && node tests/unit/transactions_update_splits.test.js && node tests/unit/entities_search.test.js && node tests/unit/transactions_import_schema.test.js && node tests/unit/tool_schema_shape.test.js && node tests/unit/zod_error_format.test.js && node tests/unit/tool_count_sync.test.js && node tests/unit/stdio_transport.test.js && node tests/unit/logger_structured.test.js && node tests/unit/logger_redaction.test.js && node tests/unit/logger_correlation.test.js && node tests/unit/check_coverage.test.js && node tests/unit/adapter_op_timeout.test.js && node tests/unit/adapter_write_queue_wakeup.test.js && node tests/unit/ci_workflow_guards.test.js && node tests/unit/node_version_guard.test.js && node tests/unit/node_version_drift.test.js && node tests/unit/observability_smoke.test.js && node tests/unit/residue_pattern.test.js && node tests/unit/dual_transport_gate.test.js && node tests/unit/manual_assertions_use_helper.test.js && node tests/unit/server_version_guard.test.js && node tests/unit/payee_rules_reference_filter.test.js && node tests/unit/oidc_metadata_discovery.test.js && node tests/unit/httpServer_oauth_metadata_route.test.js && node tests/unit/httpServer_mcp_info_route.test.js && node tests/unit/residue_sweep_balance_account.test.js && node tests/unit/tool_factory.test.js && node tests/unit/budget_registry_parse.test.js && node tests/unit/preferences_get.test.js && node tests/unit/budgets_export.test.js && node tests/unit/budgets_import.test.js && node tests/unit/budget_acl_dynamic.test.js && node tests/unit/budget_acl_middleware.test.js",
33
+ "test:unit-js": "node tests/unit/transactions_create.test.js && node tests/unit/generated_tools.smoke.test.js && node tests/unit/schema_validation.test.js && node tests/unit/schema_json_openai_compat.test.js && node tests/unit/config_https_validation.test.js && node tests/unit/config_data_dir.test.js && node tests/unit/config_drift.test.js && node tests/unit/port_alignment.test.js && node tests/unit/dockerfile_data_dir_alignment.test.js && node tests/unit/unraid_template_alignment.test.js && node tests/unit/entrypoint_invariants.test.js && node tests/unit/compose_profile_sync.test.js && node tests/unit/advertised_tools_sync.test.js && node tests/unit/knip_config.test.js && node tests/unit/readme_stats_sync.test.js && node tests/unit/config_insecure_upstream.test.js && node tests/unit/workflow_release_guards.test.js && node tests/unit/report_train_failure.test.js && node tests/unit/unit_chain_membership.test.js && node tests/unit/api_surface_drift.test.js && node tests/unit/api_coverage_baseline.test.js && node tests/unit/train_preflight.test.js && node tests/unit/verify_published_artifacts.test.js && node tests/unit/report_train_stale.test.js && node tests/unit/stdio_framing_check.test.js && node tests/unit/server_info.test.js && node tests/unit/transfers_create.test.js && node tests/unit/bot_target_branch.test.js && node tests/unit/auth_posture.test.js && node tests/unit/auth_posture_wiring.test.js && node tests/unit/auth-acl.test.js && node tests/unit/bug76.test.js && node tests/unit/budgets_setAmount.test.js && node tests/unit/budgets_transfer.test.js && node tests/unit/transactions_uncategorized.test.js && node tests/unit/httpServer_session_init.test.js && node tests/unit/httpServer_session_not_found.test.js && node tests/unit/manual_mcp_client_retry.test.js && node tests/unit/manual_mcp_client_session.test.js && node tests/unit/manual_mcp_client_circuit.test.js && node tests/unit/manual_runner_killswitch.test.js && node tests/unit/adapter_auth_rate_limit.test.js && node tests/unit/adapter_session_reuse.test.js && node tests/unit/retry_classifier.test.js && node tests/unit/adapter_module_surface.test.js && node tests/unit/adapter_nonidempotent_no_retry.test.js && node tests/unit/pool_liveness.test.js && node tests/unit/pool_shutdown_all.test.js && node tests/unit/query_where_operators.test.js && node tests/unit/query_run_validation.test.js && node tests/unit/adapter_with_write_session.test.js && node tests/unit/category_groups_delete.test.js && node tests/unit/rules_delete.test.js && node tests/unit/schedules_delete.test.js && node tests/unit/payees_delete.test.js && node tests/unit/rules_create_or_update.test.js && node tests/unit/unhandled-rejection.test.js && node tests/unit/rejection-allowlist-purity.test.js && node tests/unit/httpServer_bearer_auth.test.js && node tests/unit/httpServer_oidc_audience.test.js && node tests/unit/httpServer_oidc_auth_verification.test.js && node tests/unit/httpServer_oidc_jwks_discovery.test.js && node tests/unit/httpServer_body_limit.test.js && node tests/unit/httpServer_bind_host.test.js && node tests/unit/adapter_write_pool_cooperation.test.js && node tests/unit/budget_acl_enforcement.test.js && node tests/unit/budget_preference_store.test.js && node tests/unit/budget_preference_restore.test.js && node tests/unit/tags_list.test.js && node tests/unit/tags_create.test.js && node tests/unit/tags_update.test.js && node tests/unit/tags_delete.test.js && node tests/unit/notes_get.test.js && node tests/unit/notes_update.test.js && node tests/unit/payees_common_list.test.js && node tests/unit/transactions_update_guard.test.js && node tests/unit/transactions_update_splits.test.js && node tests/unit/entities_search.test.js && node tests/unit/transactions_import_schema.test.js && node tests/unit/tool_schema_shape.test.js && node tests/unit/zod_error_format.test.js && node tests/unit/tool_count_sync.test.js && node tests/unit/stdio_transport.test.js && node tests/unit/logger_structured.test.js && node tests/unit/logger_redaction.test.js && node tests/unit/logger_correlation.test.js && node tests/unit/check_coverage.test.js && node tests/unit/adapter_op_timeout.test.js && node tests/unit/adapter_write_queue_wakeup.test.js && node tests/unit/ci_workflow_guards.test.js && node tests/unit/node_version_guard.test.js && node tests/unit/node_version_drift.test.js && node tests/unit/observability_smoke.test.js && node tests/unit/residue_pattern.test.js && node tests/unit/dual_transport_gate.test.js && node tests/unit/manual_assertions_use_helper.test.js && node tests/unit/server_version_guard.test.js && node tests/unit/payee_rules_reference_filter.test.js && node tests/unit/oidc_metadata_discovery.test.js && node tests/unit/httpServer_oauth_metadata_route.test.js && node tests/unit/httpServer_mcp_info_route.test.js && node tests/unit/residue_sweep_balance_account.test.js && node tests/unit/tool_factory.test.js && node tests/unit/budget_registry_parse.test.js && node tests/unit/preferences_get.test.js && node tests/unit/budgets_export.test.js && node tests/unit/budgets_import.test.js && node tests/unit/budget_acl_dynamic.test.js && node tests/unit/budget_acl_middleware.test.js && node tests/unit/rules_stage.test.js",
34
34
  "test:adapter": "npm run build && node dist/src/tests_adapter_runner.js",
35
35
  "test:e2e": "npx playwright test",
36
36
  "test:e2e:docker": "./tests/e2e/run-docker-e2e.sh",