actual-mcp-server 0.22.18 → 0.22.19
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 +1 -1
- package/dist/src/actualToolsManager.js +0 -3
- package/dist/src/index.js +24 -1
- package/dist/src/lib/actual-adapter.js +142 -53
- package/dist/src/lib/config-registry.js +2 -2
- package/dist/src/lib/schemas/rules.js +1 -1
- package/dist/src/lib/toolsets.js +21 -9
- package/dist/src/lib/transportErrorLogger.js +116 -0
- package/dist/src/server/httpServer.js +2 -0
- package/dist/src/server/stdioServer.js +2 -0
- package/dist/src/tools/budget_updates_batch.js +5 -3
- package/dist/src/tools/query_run.js +2 -2
- package/dist/src/tools/rules_create_batch.js +1 -1
- package/dist/src/tools/rules_create_or_update.js +1 -1
- package/dist/src/tools/rules_update.js +1 -1
- package/dist/src/tools/transactions_update_batch.js +1 -1
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -835,7 +835,7 @@ The software is provided **as-is**, without warranty of any kind. The author acc
|
|
|
835
835
|
|
|
836
836
|
---
|
|
837
837
|
|
|
838
|
-
**Version:** 0.22.
|
|
838
|
+
**Version:** 0.22.19 | **Tool Count:** 83 (verified LibreChat-compatible)
|
|
839
839
|
|
|
840
840
|
## Sponsor
|
|
841
841
|
|
|
@@ -185,9 +185,6 @@ class ActualToolsManager {
|
|
|
185
185
|
getPublishedToolNames() {
|
|
186
186
|
return [...this.requirePolicy().published];
|
|
187
187
|
}
|
|
188
|
-
isPublished(name) {
|
|
189
|
-
return !this.requirePolicy().hidden.has(name) && this.tools.has(name);
|
|
190
|
-
}
|
|
191
188
|
/** The resolved MCP_TOOLSETS / MCP_TOOLS / MCP_READ_ONLY, for server_info. */
|
|
192
189
|
getToolPolicy() {
|
|
193
190
|
const { toolsets, tools, readOnly } = this.requirePolicy().settings;
|
package/dist/src/index.js
CHANGED
|
@@ -9,6 +9,7 @@ import path, { dirname } from 'node:path';
|
|
|
9
9
|
import { fileURLToPath } from 'node:url';
|
|
10
10
|
import { z } from 'zod';
|
|
11
11
|
import { isKnownBenignRejection } from './lib/rejection-allowlist.js';
|
|
12
|
+
import { format } from 'node:util';
|
|
12
13
|
/**
|
|
13
14
|
* #277: the single version reader for this module.
|
|
14
15
|
*
|
|
@@ -89,6 +90,26 @@ const argsEarly = process.argv.slice(2);
|
|
|
89
90
|
if (argsEarly.includes('--stdio')) {
|
|
90
91
|
process.env.MCP_STDIO_MODE = 'true';
|
|
91
92
|
}
|
|
93
|
+
// #502: the pre-logger stdio chokepoint. Under --stdio, stdout is the JSON-RPC channel, but
|
|
94
|
+
// anything that calls console.log before src/logger.ts loads reaches the real stdout:
|
|
95
|
+
// @dabh/diagnostics (during winston.createLogger, when DEBUG/DIAGNOSTICS is set and NODE_ENV
|
|
96
|
+
// is not production) and potentially dotenv. src/logger.ts wireConsoleAndDebug() replaces
|
|
97
|
+
// these methods once the logger loads, exactly as before. This is installed under --stdio
|
|
98
|
+
// only, so HTTP stdout behaviour is unchanged. It writes straight to process.stderr, so
|
|
99
|
+
// there is no recursion risk. Output in this window bypasses the #220 redaction (as it
|
|
100
|
+
// already did), which is acceptable only because everything printed here is static (the
|
|
101
|
+
// debug notice, winston level names; dotenv is pinned quiet and debug false). A future
|
|
102
|
+
// pre-logger print of dynamic data is a redaction concern.
|
|
103
|
+
function installPreLoggerStderrConsole() {
|
|
104
|
+
const toStderr = (...args) => {
|
|
105
|
+
process.stderr.write(format(...args) + '\n');
|
|
106
|
+
};
|
|
107
|
+
console.log = toStderr;
|
|
108
|
+
console.info = toStderr;
|
|
109
|
+
console.debug = toStderr;
|
|
110
|
+
console.warn = toStderr;
|
|
111
|
+
console.error = toStderr;
|
|
112
|
+
}
|
|
92
113
|
// Only load dotenv if we're not just showing help
|
|
93
114
|
// dotenv will be loaded inside the async IIFE below via dynamic import
|
|
94
115
|
// to avoid using require() in ESM and to keep the early --help fast exit.
|
|
@@ -141,6 +162,8 @@ Docs & source: https://github.com/agigante80/actual-mcp-server`);
|
|
|
141
162
|
process.exit(0);
|
|
142
163
|
}
|
|
143
164
|
(async () => {
|
|
165
|
+
if (argsEarly.includes('--stdio'))
|
|
166
|
+
installPreLoggerStderrConsole();
|
|
144
167
|
// Load dotenv here (dynamic import) only when not running with --help
|
|
145
168
|
if (!argsEarly.includes('--help')) {
|
|
146
169
|
const dotenv = await import('dotenv');
|
|
@@ -171,7 +194,7 @@ Docs & source: https://github.com/agigante80/actual-mcp-server`);
|
|
|
171
194
|
process.env.LOG_LEVEL = process.env.LOG_LEVEL || 'debug';
|
|
172
195
|
// optional flag your code can check for even more verbose transport logging
|
|
173
196
|
process.env.MCP_BRIDGE_DEBUG_TRANSPORT = 'true';
|
|
174
|
-
|
|
197
|
+
process.stderr.write('Debug mode enabled: DEBUG=* LOG_LEVEL=debug\n');
|
|
175
198
|
}
|
|
176
199
|
// dynamic imports to avoid running side effects on module import
|
|
177
200
|
const [{ connectToActual }, { ActualMCPConnection }] = await Promise.all([
|
|
@@ -307,6 +307,32 @@ export function _shouldKeepSingletonAlive(activeSessions, forceFullShutdown) {
|
|
|
307
307
|
function _shouldDropPoolOnError(err) {
|
|
308
308
|
return isRetryableError(err) && !isRateLimitError(err);
|
|
309
309
|
}
|
|
310
|
+
/**
|
|
311
|
+
* The ONE rule for batch paths that catch per item (updateTransactionBatch, createRulesBatch,
|
|
312
|
+
* setBudgetBatch). INFRASTRUCTURE ERRORS STOP THE BATCH: when `_shouldDropPoolOnError` matches,
|
|
313
|
+
* this throws and the remaining items are never sent; otherwise it returns and the caller
|
|
314
|
+
* records the per-item failure and continues. Rate limits are not pool-drop errors, so they
|
|
315
|
+
* stay per item.
|
|
316
|
+
*
|
|
317
|
+
* The original error text is kept inside the message so `isRetryableError` (message-only) still
|
|
318
|
+
* classifies the wrapper, and `{ cause }` keeps the original error. The throw surfaces as a
|
|
319
|
+
* whole-call failure out of queueWriteOperation, which on the LEGACY path sets
|
|
320
|
+
* drainForceFullShutdown (full teardown). On the POOLED path the entry is dropped only if the
|
|
321
|
+
* drain's following `api.sync()` also fails with a pool-drop error. The trailing sync still runs
|
|
322
|
+
* on both paths and pushes the applied items, which is why the message tells the caller to read
|
|
323
|
+
* back instead of claiming a committed state.
|
|
324
|
+
*
|
|
325
|
+
* Per item this is realistically reached only by `out of memory` / `ENOMEM`: the raw update
|
|
326
|
+
* calls are LOCAL writes and the upstream sync is debounced, so a network error surfaces at the
|
|
327
|
+
* trailing sync rather than here (#521 premise). Do not re-litigate that; the guard is a
|
|
328
|
+
* defensive consistency rule shared by all three batch paths.
|
|
329
|
+
*/
|
|
330
|
+
function abortBatchOnInfrastructureError(error, ctx) {
|
|
331
|
+
if (!_shouldDropPoolOnError(error))
|
|
332
|
+
return;
|
|
333
|
+
throw new Error(`${ctx.label} batch aborted after ${ctx.appliedCount} of ${ctx.total} items (applied: ${ctx.applied || 'none'}${ctx.extra ? '; ' + ctx.extra : ''}): ${ctx.message}. ` +
|
|
334
|
+
`Read back with ${ctx.readBackTool} before retrying.`, { cause: error });
|
|
335
|
+
}
|
|
310
336
|
/**
|
|
311
337
|
* Enforce per-request budget ACL before any pool branching or lock acquisition.
|
|
312
338
|
*
|
|
@@ -2064,7 +2090,7 @@ export async function updateTransactionBatch(updates) {
|
|
|
2064
2090
|
}, { retries: 2, backoffMs: 200 }));
|
|
2065
2091
|
const succeeded = [];
|
|
2066
2092
|
const failed = [];
|
|
2067
|
-
for (const { id, fields } of updates) {
|
|
2093
|
+
for (const [position, { id, fields }] of updates.entries()) {
|
|
2068
2094
|
if (!existing.has(id)) {
|
|
2069
2095
|
failed.push({ id, error: `Transaction "${id}" not found. Use actual_transactions_get to list transactions.` });
|
|
2070
2096
|
continue;
|
|
@@ -2075,6 +2101,18 @@ export async function updateTransactionBatch(updates) {
|
|
|
2075
2101
|
}
|
|
2076
2102
|
catch (err) {
|
|
2077
2103
|
const message = err instanceof Error ? err.message : String(err);
|
|
2104
|
+
// Infrastructure errors stop the batch (see abortBatchOnInfrastructureError). The
|
|
2105
|
+
// failing item's state is UNKNOWN (an ENOMEM can land after the local write ran);
|
|
2106
|
+
// items after it were never sent.
|
|
2107
|
+
abortBatchOnInfrastructureError(err, {
|
|
2108
|
+
label: 'Transaction',
|
|
2109
|
+
appliedCount: succeeded.length,
|
|
2110
|
+
total: updates.length,
|
|
2111
|
+
applied: succeeded.map((x) => x.id).join(', '),
|
|
2112
|
+
extra: `failed before the abort: ${failed.map((x) => x.id).join(', ') || 'none'}; outcome unknown: ${id}; not attempted: ${updates.length - position - 1}`,
|
|
2113
|
+
message,
|
|
2114
|
+
readBackTool: 'actual_transactions_get',
|
|
2115
|
+
});
|
|
2078
2116
|
failed.push({ id, error: message });
|
|
2079
2117
|
}
|
|
2080
2118
|
}
|
|
@@ -2168,6 +2206,14 @@ export async function updatePayee(id, fields) {
|
|
|
2168
2206
|
const { category: _stripped, ...rest } = fieldsObj;
|
|
2169
2207
|
directFields = rest;
|
|
2170
2208
|
}
|
|
2209
|
+
// #522: an explicitly set category is checked BEFORE the direct-field write, so a refused
|
|
2210
|
+
// category writes nothing at all (not even the name). null clears the rule and an absent key
|
|
2211
|
+
// skips this. A hidden category passes (the listing includes it); a category GROUP id is
|
|
2212
|
+
// refused as a missing Category. Same pipeline as the rule writes.
|
|
2213
|
+
if (typeof categoryValue === 'string') {
|
|
2214
|
+
const categoryRefs = [{ side: 'action', field: 'category', id: categoryValue }];
|
|
2215
|
+
assertRuleReferencesExist(categoryRefs, await loadRuleReferenceListings(categoryRefs), RULE_REFUSAL_CHANGED);
|
|
2216
|
+
}
|
|
2171
2217
|
// Update the payee's direct fields (name, transfer_acct, etc.)
|
|
2172
2218
|
if (Object.keys(directFields).length > 0) {
|
|
2173
2219
|
await withConcurrency(() => retry(() => rawUpdatePayee(id, directFields), { retries: 2, backoffMs: 200, isRetryable: isRetryableError }));
|
|
@@ -2250,43 +2296,74 @@ const RULE_REFERENCE_FIELDS = {
|
|
|
2250
2296
|
payee: { entity: 'Payee', listTool: 'actual_payees_get' },
|
|
2251
2297
|
account: { entity: 'Account', listTool: 'actual_accounts_get' },
|
|
2252
2298
|
};
|
|
2253
|
-
/**
|
|
2299
|
+
/**
|
|
2300
|
+
* Condition operators whose value is an entity id (or an array of ids). `and` is upstream's
|
|
2301
|
+
* INTERNAL op for `category` and `category_group` (shared/rules.ts `internalOps`), parsed like
|
|
2302
|
+
* `oneOf`, so leaving it out would narrow what #485 already guarded. The text ops (`contains`,
|
|
2303
|
+
* `matches`, `doesNotContain`) compare text and `onBudget` / `offBudget` carry no id: they are
|
|
2304
|
+
* skipped and must never be refused.
|
|
2305
|
+
*/
|
|
2306
|
+
const RULE_ID_CONDITION_OPS = new Set(['is', 'isNot', 'oneOf', 'notOneOf', 'and']);
|
|
2307
|
+
/** Refusal suffixes, one per write path, so no call site can inherit text that is false for it (#524 item 6). */
|
|
2308
|
+
const RULE_REFUSAL_CREATED = 'Nothing was created.';
|
|
2309
|
+
const RULE_REFUSAL_CHANGED = 'Nothing was changed.';
|
|
2310
|
+
const RULE_REFUSAL_BATCH_ITEM = 'This rule was not created.';
|
|
2311
|
+
/**
|
|
2312
|
+
* Every id a rule points at. Null, undefined and non-string values are skipped (a null
|
|
2313
|
+
* `set category` clears it). Conditions count only for an id operator (RULE_ID_CONDITION_OPS).
|
|
2314
|
+
* Actions count only for `set`, and not when `options.template` is TRUTHY: upstream compiles a
|
|
2315
|
+
* truthy template as Handlebars and does not use the value as an id (server/rules/action.ts,
|
|
2316
|
+
* `if (options?.template)`), while an empty or absent template applies the plain value as the
|
|
2317
|
+
* id. Do NOT test with `!== undefined` or `'template' in options`.
|
|
2318
|
+
* The field filter uses `Object.hasOwn`, not `in`: a field named `toString` is on the prototype.
|
|
2319
|
+
*/
|
|
2254
2320
|
function ruleReferences(rule) {
|
|
2255
2321
|
const out = [];
|
|
2256
2322
|
const r = (rule ?? {});
|
|
2257
|
-
const add = (field, value) => {
|
|
2258
|
-
if (typeof field !== 'string' || !(field
|
|
2323
|
+
const add = (side, field, value) => {
|
|
2324
|
+
if (typeof field !== 'string' || !Object.hasOwn(RULE_REFERENCE_FIELDS, field))
|
|
2259
2325
|
return;
|
|
2260
2326
|
const values = Array.isArray(value) ? value : [value];
|
|
2261
2327
|
for (const v of values)
|
|
2262
2328
|
if (typeof v === 'string')
|
|
2263
|
-
out.push({ field: field, id: v });
|
|
2329
|
+
out.push({ side, field: field, id: v });
|
|
2264
2330
|
};
|
|
2265
2331
|
if (Array.isArray(r.conditions)) {
|
|
2266
|
-
for (const c of r.conditions)
|
|
2267
|
-
|
|
2332
|
+
for (const c of r.conditions) {
|
|
2333
|
+
if (typeof c?.op === 'string' && RULE_ID_CONDITION_OPS.has(c.op))
|
|
2334
|
+
add('condition', c?.field, c?.value);
|
|
2335
|
+
}
|
|
2268
2336
|
}
|
|
2269
2337
|
if (Array.isArray(r.actions)) {
|
|
2270
2338
|
for (const a of r.actions) {
|
|
2271
|
-
if (a?.op === 'set')
|
|
2272
|
-
add(a?.field, a?.value);
|
|
2339
|
+
if (a?.op === 'set' && !a?.options?.template)
|
|
2340
|
+
add('action', a?.field, a?.value);
|
|
2273
2341
|
}
|
|
2274
2342
|
}
|
|
2275
2343
|
return out;
|
|
2276
2344
|
}
|
|
2345
|
+
const ruleRefKey = (ref) => `${ref.side}|${ref.field}|${ref.id}`;
|
|
2277
2346
|
/**
|
|
2278
|
-
*
|
|
2279
|
-
*
|
|
2280
|
-
*
|
|
2347
|
+
* Only the references `next` ADDS over `stored`. A dangling id already in the stored rule must
|
|
2348
|
+
* not make an unrelated edit fail. The key includes the side: the same id newly added on the
|
|
2349
|
+
* other side (condition to action) counts as new.
|
|
2350
|
+
*/
|
|
2351
|
+
function newRuleReferences(next, stored) {
|
|
2352
|
+
const had = new Set(ruleReferences(stored).map(ruleRefKey));
|
|
2353
|
+
return ruleReferences(next).filter((ref) => !had.has(ruleRefKey(ref)));
|
|
2354
|
+
}
|
|
2355
|
+
/**
|
|
2356
|
+
* #485: read the listings the given references need, once per kind, through the drain cache.
|
|
2357
|
+
* Call INSIDE a queued op and BEFORE the first raw write. Only kinds that some reference needs
|
|
2358
|
+
* are read, so an edit introducing no new reference reads no listing. The three listings are what upstream shows a user: categories (hidden included,
|
|
2281
2359
|
* group ids are not in it), payees (transfer payees included, tombstoned ones excluded here
|
|
2282
2360
|
* even if the listing returns them) and accounts (closed and off-budget accounts included, and
|
|
2283
2361
|
* both count as existing: a closed account is not a missing reference).
|
|
2284
2362
|
*/
|
|
2285
|
-
async function loadRuleReferenceListings(
|
|
2363
|
+
async function loadRuleReferenceListings(refs) {
|
|
2286
2364
|
const need = new Set();
|
|
2287
|
-
for (const
|
|
2288
|
-
|
|
2289
|
-
need.add(ref.field);
|
|
2365
|
+
for (const ref of refs)
|
|
2366
|
+
need.add(ref.field);
|
|
2290
2367
|
const listings = {};
|
|
2291
2368
|
if (need.has('category')) {
|
|
2292
2369
|
const rows = await withConcurrency(() => retry(() => readDrainListing('categories', () => rawGetCategories()), { retries: 2, backoffMs: 200 }));
|
|
@@ -2306,19 +2383,22 @@ async function loadRuleReferenceListings(rules) {
|
|
|
2306
2383
|
* #485: refuse a rule that points at a category, payee or account that does not exist.
|
|
2307
2384
|
* Upstream `createRule` checks field, operator and nullability only, with no existence lookup
|
|
2308
2385
|
* of id values, so it stores a rule pointing at nothing. NOT exported: tests drive it through
|
|
2309
|
-
*
|
|
2310
|
-
*
|
|
2311
|
-
*
|
|
2312
|
-
*
|
|
2313
|
-
* and `updatePayee`'s
|
|
2314
|
-
* values
|
|
2386
|
+
* the adapter methods, and it lives in the adapter so no call site can skip it. `suffix` is
|
|
2387
|
+
* REQUIRED (no default) so a new call site cannot inherit text that is false for it.
|
|
2388
|
+
*
|
|
2389
|
+
* The guard now covers every rule write: `createRule`, `createRulesBatch`, `updateRule`,
|
|
2390
|
+
* `upsertRule` and `updatePayee`'s category. KNOWN GAPS that remain: `link-schedule` action
|
|
2391
|
+
* values; `category_group` condition values (upstream `FIELD_INFO` types it `id`, but its value
|
|
2392
|
+
* is a group id that appears in none of the three listings, #524 item 5); and, deliberately,
|
|
2393
|
+
* text-pattern ops (`contains`, `matches`, `doesNotContain`) and actions with a truthy
|
|
2394
|
+
* `options.template`.
|
|
2315
2395
|
*/
|
|
2316
|
-
function assertRuleReferencesExist(
|
|
2317
|
-
for (const ref of
|
|
2396
|
+
function assertRuleReferencesExist(refs, listings, suffix) {
|
|
2397
|
+
for (const ref of refs) {
|
|
2318
2398
|
const known = listings[ref.field];
|
|
2319
2399
|
if (known && !known.has(ref.id)) {
|
|
2320
2400
|
const meta = RULE_REFERENCE_FIELDS[ref.field];
|
|
2321
|
-
throw new NotFoundRefusal(meta.entity, ref.id, meta.listTool,
|
|
2401
|
+
throw new NotFoundRefusal(meta.entity, ref.id, meta.listTool, suffix);
|
|
2322
2402
|
}
|
|
2323
2403
|
}
|
|
2324
2404
|
}
|
|
@@ -2356,12 +2436,8 @@ function ruleCreateErrorText(error) {
|
|
|
2356
2436
|
* the same rule twice if the first attempt landed), each caught per item. Only raw calls
|
|
2357
2437
|
* run here: an `adapter.*` call would nest the api lock and deadlock.
|
|
2358
2438
|
*
|
|
2359
|
-
* INFRASTRUCTURE ERRORS STOP THE BATCH
|
|
2360
|
-
*
|
|
2361
|
-
* and the indices already created. The rethrow surfaces as a whole-call failure; on the legacy
|
|
2362
|
-
* path of queueWriteOperation it also forces a full shutdown. (In pooled mode the pooled entry
|
|
2363
|
-
* is dropped only if the following sync fails.) Rate limits and upstream rejections are per
|
|
2364
|
-
* item and the loop continues.
|
|
2439
|
+
* INFRASTRUCTURE ERRORS STOP THE BATCH: see `abortBatchOnInfrastructureError`. Rate limits and
|
|
2440
|
+
* upstream rejections are per item and the loop continues.
|
|
2365
2441
|
*
|
|
2366
2442
|
* COOPERATIVE DEADLINE: same 0.75 fraction of ACTUAL_OP_TIMEOUT_MS as `setBudgetBatch`,
|
|
2367
2443
|
* because `withOpTimeout` cannot cancel this callback. Items not reached go to `failed` with
|
|
@@ -2373,11 +2449,12 @@ export async function createRulesBatch(items, total) {
|
|
|
2373
2449
|
const startedAt = Date.now();
|
|
2374
2450
|
const succeeded = [];
|
|
2375
2451
|
const failed = [];
|
|
2376
|
-
const listings = await loadRuleReferenceListings(items.
|
|
2452
|
+
const listings = await loadRuleReferenceListings(items.flatMap((i) => ruleReferences(i.rule)));
|
|
2377
2453
|
const toWrite = [];
|
|
2378
2454
|
for (const item of items) {
|
|
2379
2455
|
try {
|
|
2380
|
-
|
|
2456
|
+
// The other items may well be created, so the single-rule suffix would be false here.
|
|
2457
|
+
assertRuleReferencesExist(ruleReferences(item.rule), listings, RULE_REFUSAL_BATCH_ITEM);
|
|
2381
2458
|
toWrite.push(item);
|
|
2382
2459
|
}
|
|
2383
2460
|
catch (error) {
|
|
@@ -2396,11 +2473,14 @@ export async function createRulesBatch(items, total) {
|
|
|
2396
2473
|
}
|
|
2397
2474
|
catch (error) {
|
|
2398
2475
|
const msg = ruleCreateErrorText(error);
|
|
2399
|
-
|
|
2400
|
-
|
|
2401
|
-
|
|
2402
|
-
|
|
2403
|
-
|
|
2476
|
+
abortBatchOnInfrastructureError(error, {
|
|
2477
|
+
label: 'Rules',
|
|
2478
|
+
appliedCount: succeeded.length,
|
|
2479
|
+
total,
|
|
2480
|
+
applied: succeeded.map((x) => x.index).join(', '),
|
|
2481
|
+
message: msg,
|
|
2482
|
+
readBackTool: 'actual_rules_get',
|
|
2483
|
+
});
|
|
2404
2484
|
failed.push({ index, error: msg });
|
|
2405
2485
|
}
|
|
2406
2486
|
}
|
|
@@ -2413,8 +2493,10 @@ export async function createRule(rule) {
|
|
|
2413
2493
|
observability.incrementToolCall('actual.rules.create').catch(() => { });
|
|
2414
2494
|
return queueWriteOperation(async () => {
|
|
2415
2495
|
// #485: the reference guard shares this drain's cached listings with the write.
|
|
2416
|
-
|
|
2417
|
-
|
|
2496
|
+
const refs = ruleReferences(rule);
|
|
2497
|
+
assertRuleReferencesExist(refs, await loadRuleReferenceListings(refs), RULE_REFUSAL_CREATED);
|
|
2498
|
+
// retries 0: a slow first attempt that lands followed by a retry stores a duplicate rule.
|
|
2499
|
+
const raw = await withConcurrency(() => retry(() => rawCreateRule(rule), { retries: 0, backoffMs: 200, isRetryable: isRetryableError }));
|
|
2418
2500
|
const id = normalizeToId(raw);
|
|
2419
2501
|
return id;
|
|
2420
2502
|
}, { preservesListings: PRESERVES_ALL_ENTITY_LISTINGS });
|
|
@@ -2427,7 +2509,7 @@ export async function updateRule(id, fields) {
|
|
|
2427
2509
|
const rules = await withConcurrency(() => retry(() => rawGetRules(), { retries: 2, backoffMs: 200 }));
|
|
2428
2510
|
const existingRule = rules.find((r) => r.id === id);
|
|
2429
2511
|
if (!existingRule) {
|
|
2430
|
-
throw new
|
|
2512
|
+
throw new NotFoundRefusal('Rule', id, 'actual_rules_get');
|
|
2431
2513
|
}
|
|
2432
2514
|
const fieldsObj = fields;
|
|
2433
2515
|
const rule = {
|
|
@@ -2450,6 +2532,11 @@ export async function updateRule(id, fields) {
|
|
|
2450
2532
|
conditions: fieldsObj.conditions ?? existingRule.conditions ?? [],
|
|
2451
2533
|
actions: fieldsObj.actions ?? existingRule.actions ?? [],
|
|
2452
2534
|
};
|
|
2535
|
+
// Only references this update ADDS are checked: a dangling id already stored does not make
|
|
2536
|
+
// an unrelated edit fail. Diffing the merged rule equals diffing the payload, because an
|
|
2537
|
+
// omitted conditions or actions takes the stored value, which contributes no new refs.
|
|
2538
|
+
const addedRefs = newRuleReferences(rule, existingRule);
|
|
2539
|
+
assertRuleReferencesExist(addedRefs, await loadRuleReferenceListings(addedRefs), RULE_REFUSAL_CHANGED);
|
|
2453
2540
|
logger.debug(`[UPDATE RULE] Updating rule ${id} with merged fields: ${JSON.stringify(rule)}`);
|
|
2454
2541
|
await withConcurrency(() => retry(() => rawUpdateRule(rule), { retries: 0, backoffMs: 200, isRetryable: isRetryableError }));
|
|
2455
2542
|
logger.debug(`[UPDATE RULE] Update completed for rule ${id}`);
|
|
@@ -2502,6 +2589,8 @@ export async function upsertRule(input, stageWasSupplied) {
|
|
|
2502
2589
|
conditions: ruleData.conditions ?? matchedRule.conditions ?? [],
|
|
2503
2590
|
actions: ruleData.actions ?? matchedRule.actions ?? [],
|
|
2504
2591
|
};
|
|
2592
|
+
const addedRefs = newRuleReferences(merged, matchedRule);
|
|
2593
|
+
assertRuleReferencesExist(addedRefs, await loadRuleReferenceListings(addedRefs), RULE_REFUSAL_CHANGED);
|
|
2505
2594
|
// Non-idempotent from upstream's point of view: do not retry the write.
|
|
2506
2595
|
await withConcurrency(() => retry(() => rawUpdateRule(merged), { retries: 0, backoffMs: 200 }));
|
|
2507
2596
|
return { id: matchedRule.id, created: false };
|
|
@@ -2512,6 +2601,8 @@ export async function upsertRule(input, stageWasSupplied) {
|
|
|
2512
2601
|
// value: Actual's default stage.
|
|
2513
2602
|
if (ruleData.stage === undefined)
|
|
2514
2603
|
ruleData.stage = null;
|
|
2604
|
+
const createRefs = ruleReferences(ruleData);
|
|
2605
|
+
assertRuleReferencesExist(createRefs, await loadRuleReferenceListings(createRefs), RULE_REFUSAL_CREATED);
|
|
2515
2606
|
const rawId = await withConcurrency(() => retry(() => rawCreateRule(ruleData), { retries: 0, backoffMs: 200 }));
|
|
2516
2607
|
return { id: normalizeToId(rawId), created: true };
|
|
2517
2608
|
}, { preservesListings: PRESERVES_ALL_ENTITY_LISTINGS });
|
|
@@ -2995,18 +3086,16 @@ export async function setBudgetBatch(items) {
|
|
|
2995
3086
|
}
|
|
2996
3087
|
catch (error) {
|
|
2997
3088
|
const msg = error instanceof Error ? error.message : String(error);
|
|
2998
|
-
//
|
|
2999
|
-
//
|
|
3000
|
-
|
|
3001
|
-
|
|
3002
|
-
|
|
3003
|
-
|
|
3004
|
-
|
|
3005
|
-
|
|
3006
|
-
|
|
3007
|
-
|
|
3008
|
-
'Read back with actual_budgets_getMonth before retrying.');
|
|
3009
|
-
}
|
|
3089
|
+
// Infrastructure errors stop the batch (see abortBatchOnInfrastructureError);
|
|
3090
|
+
// the throw leaves the bracket and upstream closes it in a `finally`.
|
|
3091
|
+
abortBatchOnInfrastructureError(error, {
|
|
3092
|
+
label: 'Budget',
|
|
3093
|
+
appliedCount: succeeded.length,
|
|
3094
|
+
total: items.length,
|
|
3095
|
+
applied: succeeded.map((x) => x.index).join(', '),
|
|
3096
|
+
message: msg,
|
|
3097
|
+
readBackTool: 'actual_budgets_getMonth',
|
|
3098
|
+
});
|
|
3010
3099
|
failed.push({ ...ref, error: amountWritten ? `amount was applied, then the carryover failed: ${msg}` : msg });
|
|
3011
3100
|
}
|
|
3012
3101
|
}
|
|
@@ -86,8 +86,8 @@ export function canonicalConfigVars(schemaKeys) {
|
|
|
86
86
|
/**
|
|
87
87
|
* The subset of canonical vars that MUST appear in the user-facing docs
|
|
88
88
|
* (.env.example and the README env table): every schema key, plus every allowlist
|
|
89
|
-
* entry not flagged `documented: false`. Internal vars (build arg,
|
|
90
|
-
*
|
|
89
|
+
* entry not flagged `documented: false`. Internal vars (build arg, CLI-flag mirror)
|
|
90
|
+
* are accounted for by the enumeration but exempt from the docs.
|
|
91
91
|
*/
|
|
92
92
|
export function documentedConfigVars(schemaKeys) {
|
|
93
93
|
return new Set([
|
|
@@ -106,7 +106,7 @@ export function validateRuleInput(input) {
|
|
|
106
106
|
}
|
|
107
107
|
// Validate field usage to guide users toward correct field selection
|
|
108
108
|
for (const condition of input.conditions) {
|
|
109
|
-
const fieldInfo = FIELD_OPERATORS[condition.field];
|
|
109
|
+
const fieldInfo = Object.hasOwn(FIELD_OPERATORS, condition.field) ? FIELD_OPERATORS[condition.field] : undefined;
|
|
110
110
|
// Validate operator is compatible with field type
|
|
111
111
|
if (fieldInfo && !fieldInfo.operators.includes(condition.op)) {
|
|
112
112
|
throw new Error(`Invalid operator "${condition.op}" for field "${condition.field}". ` +
|
package/dist/src/lib/toolsets.js
CHANGED
|
@@ -12,9 +12,10 @@
|
|
|
12
12
|
*
|
|
13
13
|
* Design rules, each the answer to a way this can go wrong:
|
|
14
14
|
* - ONE source. resolvePublishedToolNames is pure and runs once, in
|
|
15
|
-
* actualToolsManager.initialize(), from the validated config. It returns
|
|
16
|
-
* policy (the published set plus a per-tool hide reason
|
|
17
|
-
*
|
|
15
|
+
* actualToolsManager.initialize(), from the validated config. It returns a frozen
|
|
16
|
+
* policy (the published set plus a per-tool hide reason; the hide map is a ReadonlyMap
|
|
17
|
+
* by type only, since Object.freeze does not reach Map contents). Nothing reads
|
|
18
|
+
* process.env at dispatch time.
|
|
18
19
|
* - Hide AND refuse. Omitting a tool from tools/list is not access control, so callTool
|
|
19
20
|
* refuses an unpublished tool with ToolUnavailableError.
|
|
20
21
|
* - Fail loud. An unknown toolset, preset or tool name throws, and so does a
|
|
@@ -272,13 +273,19 @@ export function resolvePublishedToolNames(registeredNames, settings) {
|
|
|
272
273
|
const published = [];
|
|
273
274
|
const hidden = new Map();
|
|
274
275
|
for (const name of registeredNames) {
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
276
|
+
// Read-only is checked FIRST: while it is on, adding a writer to MCP_TOOLSETS or MCP_TOOLS
|
|
277
|
+
// cannot publish it, so MCP_READ_ONLY is the setting to change first when both hide a tool.
|
|
278
|
+
if (settings.readOnly && WRITE_CAPABLE.has(name))
|
|
278
279
|
hidden.set(name, 'MCP_READ_ONLY');
|
|
280
|
+
else if (!selected.has(name))
|
|
281
|
+
hidden.set(name, 'MCP_TOOLSETS');
|
|
279
282
|
else
|
|
280
283
|
published.push(name);
|
|
281
284
|
}
|
|
285
|
+
// Partition invariant: every registered name is exactly one of published or hidden.
|
|
286
|
+
if (published.length + hidden.size !== registeredNames.length || published.some((n) => hidden.has(n))) {
|
|
287
|
+
throw new Error('toolsets: published and hidden must partition the registry');
|
|
288
|
+
}
|
|
282
289
|
if (published.length === 0) {
|
|
283
290
|
throw new Error(`No tools would be published: MCP_TOOLSETS="${settings.toolsets}", MCP_TOOLS="${settings.tools}" and ` +
|
|
284
291
|
`MCP_READ_ONLY=${settings.readOnly} resolve to 0 tools. For example, a toolset made only of writers ` +
|
|
@@ -295,8 +302,13 @@ export function resolvePublishedToolNames(registeredNames, settings) {
|
|
|
295
302
|
}),
|
|
296
303
|
});
|
|
297
304
|
}
|
|
298
|
-
/**
|
|
305
|
+
/**
|
|
306
|
+
* The typed refusal for a registered but unpublished tool. Fails CLOSED: the decision is the
|
|
307
|
+
* frozen `published` list; `hidden` only supplies the reason. The fallback reason is
|
|
308
|
+
* unreachable in production because the resolver enforces the partition invariant.
|
|
309
|
+
*/
|
|
299
310
|
export function unavailableError(policy, tool) {
|
|
300
|
-
|
|
301
|
-
|
|
311
|
+
if (policy.published.includes(tool))
|
|
312
|
+
return undefined;
|
|
313
|
+
return new ToolUnavailableError(tool, policy.hidden.get(tool) ?? 'MCP_TOOLSETS');
|
|
302
314
|
}
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Transport-level error logging (#504).
|
|
3
|
+
*
|
|
4
|
+
* `@modelcontextprotocol/sdk` reports transport and protocol errors through
|
|
5
|
+
* `transport.onerror`, which `Protocol.connect` chains into the Server's `onerror`.
|
|
6
|
+
* Nothing set it, so refusals (batch cap, parse errors, unknown ids) were invisible.
|
|
7
|
+
*
|
|
8
|
+
* The error text can echo CLIENT content (payloads, headers), so it is sanitized before
|
|
9
|
+
* it is logged, the log message is a CONSTANT (the logger runs winston splat(), which
|
|
10
|
+
* interpolates `%o`/`%s` in the message), and the Error object, stack and cause are never
|
|
11
|
+
* passed to the logger (format.errors({ stack: true }) would re-emit the raw message).
|
|
12
|
+
* Volume is bounded by ONE per-process window limiter.
|
|
13
|
+
*/
|
|
14
|
+
import { createModuleLogger } from './loggerFactory.js';
|
|
15
|
+
const MAX_DETAIL_CODE_POINTS = 200;
|
|
16
|
+
const CUT_CHARS = /[{["'`]/;
|
|
17
|
+
// Control (C0, C1), line/paragraph separators and bidi format characters. Written with
|
|
18
|
+
// escapes, as src/logger.ts does: a raw U+2028 in source would terminate the literal.
|
|
19
|
+
// eslint-disable-next-line no-control-regex
|
|
20
|
+
const CONTROL_CHARS = /[\u0000-\u001f\u007f-\u009f\u2028\u2029\u202a-\u202e\u2066-\u2069]/g;
|
|
21
|
+
const OMITTED = '[payload omitted]';
|
|
22
|
+
/** Pure: turn an arbitrary thrown value into a bounded, payload-free, single-line string. */
|
|
23
|
+
export function sanitizeTransportErrorDetail(value) {
|
|
24
|
+
let raw = '';
|
|
25
|
+
try {
|
|
26
|
+
if (value === null || value === undefined)
|
|
27
|
+
raw = '';
|
|
28
|
+
else if (value instanceof Error)
|
|
29
|
+
raw = String(value.message);
|
|
30
|
+
else
|
|
31
|
+
raw = String(value);
|
|
32
|
+
}
|
|
33
|
+
catch {
|
|
34
|
+
raw = '';
|
|
35
|
+
}
|
|
36
|
+
let cut = false;
|
|
37
|
+
const cutAt = raw.search(CUT_CHARS);
|
|
38
|
+
if (cutAt >= 0) {
|
|
39
|
+
raw = raw.slice(0, cutAt);
|
|
40
|
+
cut = true;
|
|
41
|
+
}
|
|
42
|
+
let s = raw.replace(/%/g, ' ').replace(CONTROL_CHARS, ' ').replace(/\s+/g, ' ').trim();
|
|
43
|
+
s = Array.from(s).slice(0, MAX_DETAIL_CODE_POINTS).join('');
|
|
44
|
+
if (cut)
|
|
45
|
+
s = s ? `${s} ${OMITTED}` : OMITTED;
|
|
46
|
+
return s === '' ? '[no message]' : s;
|
|
47
|
+
}
|
|
48
|
+
/** Fixed-window limiter: first `max` calls per window pass, the rest are counted. */
|
|
49
|
+
export function createWindowLimiter(opts) {
|
|
50
|
+
let windowStart = 0;
|
|
51
|
+
let count = 0;
|
|
52
|
+
let suppressed = 0;
|
|
53
|
+
let started = false;
|
|
54
|
+
return {
|
|
55
|
+
take() {
|
|
56
|
+
const t = opts.now();
|
|
57
|
+
if (!started || t - windowStart >= opts.windowMs) {
|
|
58
|
+
started = true;
|
|
59
|
+
windowStart = t;
|
|
60
|
+
count = 0;
|
|
61
|
+
}
|
|
62
|
+
if (count < opts.max) {
|
|
63
|
+
count++;
|
|
64
|
+
const reported = suppressed;
|
|
65
|
+
suppressed = 0;
|
|
66
|
+
return { allowed: true, suppressed: reported };
|
|
67
|
+
}
|
|
68
|
+
suppressed++;
|
|
69
|
+
return { allowed: false, suppressed: 0 };
|
|
70
|
+
},
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
// ONE limiter per process so many HTTP sessions cannot multiply the budget.
|
|
74
|
+
const moduleLimiter = createWindowLimiter({ max: 50, windowMs: 60_000, now: Date.now });
|
|
75
|
+
function safeErrorName(error) {
|
|
76
|
+
try {
|
|
77
|
+
const name = error?.name;
|
|
78
|
+
return typeof name === 'string' && /^[A-Za-z]{1,40}$/.test(name) ? name : 'Error';
|
|
79
|
+
}
|
|
80
|
+
catch {
|
|
81
|
+
return 'Error';
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
/**
|
|
85
|
+
* Install a server.onerror that logs a sanitized, rate-limited record. Chains any prior
|
|
86
|
+
* handler. The handler never throws (stdio invokes it inside a stdin data handler).
|
|
87
|
+
*/
|
|
88
|
+
export function attachTransportErrorLogger(server, transport, deps = {}) {
|
|
89
|
+
const prev = server.onerror;
|
|
90
|
+
const limiter = deps.limiter ?? moduleLimiter;
|
|
91
|
+
let log = deps.logger;
|
|
92
|
+
server.onerror = (error) => {
|
|
93
|
+
try {
|
|
94
|
+
if (typeof prev === 'function')
|
|
95
|
+
prev(error);
|
|
96
|
+
}
|
|
97
|
+
catch {
|
|
98
|
+
// a broken prior handler must not stop ours or escape into the SDK
|
|
99
|
+
}
|
|
100
|
+
try {
|
|
101
|
+
const { allowed, suppressed } = limiter.take();
|
|
102
|
+
if (!allowed)
|
|
103
|
+
return;
|
|
104
|
+
log = log ?? createModuleLogger('TRANSPORT');
|
|
105
|
+
log.warn('MCP transport error', {
|
|
106
|
+
transport,
|
|
107
|
+
errorName: safeErrorName(error),
|
|
108
|
+
detail: sanitizeTransportErrorDetail(error),
|
|
109
|
+
...(suppressed > 0 ? { suppressed } : {}),
|
|
110
|
+
});
|
|
111
|
+
}
|
|
112
|
+
catch {
|
|
113
|
+
// never throw into the SDK
|
|
114
|
+
}
|
|
115
|
+
};
|
|
116
|
+
}
|
|
@@ -4,6 +4,7 @@ import { Server } from '@modelcontextprotocol/sdk/server/index.js';
|
|
|
4
4
|
import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';
|
|
5
5
|
import { ListToolsRequestSchema, CallToolRequestSchema, } from '@modelcontextprotocol/sdk/types.js';
|
|
6
6
|
import logger, { resolveRequestId } from '../logger.js';
|
|
7
|
+
import { attachTransportErrorLogger } from '../lib/transportErrorLogger.js';
|
|
7
8
|
import { getLocalIp } from '../utils.js';
|
|
8
9
|
import actualToolsManager from '../actualToolsManager.js';
|
|
9
10
|
import { getConnectionState, connectToActualForSession, shutdownActualForSession, shutdownActual, canAcceptNewSession } from '../actualConnection.js';
|
|
@@ -360,6 +361,7 @@ advertisedUrl) {
|
|
|
360
361
|
name: serverDescription || "actual-mcp-server",
|
|
361
362
|
version: version || "0.1.0",
|
|
362
363
|
}, serverOptions);
|
|
364
|
+
attachTransportErrorLogger(server, 'http');
|
|
363
365
|
// List tools handler
|
|
364
366
|
server.setRequestHandler(ListToolsRequestSchema, async () => {
|
|
365
367
|
logger.debug('[TOOLS LIST] Listing available tools');
|
|
@@ -4,6 +4,7 @@ import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'
|
|
|
4
4
|
import { ListToolsRequestSchema, CallToolRequestSchema } from '@modelcontextprotocol/sdk/types.js';
|
|
5
5
|
import { randomUUID } from 'node:crypto';
|
|
6
6
|
import logger from '../logger.js';
|
|
7
|
+
import { attachTransportErrorLogger } from '../lib/transportErrorLogger.js';
|
|
7
8
|
import actualToolsManager from '../actualToolsManager.js';
|
|
8
9
|
import { requestContext } from '../lib/requestContext.js';
|
|
9
10
|
import { buildToolListEntries } from '../lib/tool-list-entry.js';
|
|
@@ -108,6 +109,7 @@ export async function startStdioServer(mcp, capabilities, implementedTools, serv
|
|
|
108
109
|
const stdioSessionId = `stdio-${randomUUID()}`;
|
|
109
110
|
logger.debug(`[STDIO] session id ${stdioSessionId}`);
|
|
110
111
|
const server = new Server({ name: serverDescription || 'actual-mcp-server', version: version || '0.1.0' }, { capabilities, instructions: serverInstructions });
|
|
112
|
+
attachTransportErrorLogger(server, 'stdio');
|
|
111
113
|
// List tools handler — mirrors createServerInstance() in httpServer.ts
|
|
112
114
|
server.setRequestHandler(ListToolsRequestSchema, async () => {
|
|
113
115
|
// #379: the SAME builder the HTTP paths use. stdio is the transport Claude Desktop
|
|
@@ -41,14 +41,16 @@ Example: Set health insurance budget for 2 months:
|
|
|
41
41
|
// timeout) must reach actualToolsManager.callTool and surface as a tool error.
|
|
42
42
|
const input = InputSchema.parse(args || {});
|
|
43
43
|
const { succeeded, failed } = await adapter.setBudgetBatch(input.operations);
|
|
44
|
-
// Counts
|
|
44
|
+
// Counts and failed indices only. Never amounts, months or category ids.
|
|
45
45
|
log.info('Budget batch finished', {
|
|
46
46
|
total: input.operations.length,
|
|
47
47
|
successCount: succeeded.length,
|
|
48
48
|
failureCount: failed.length,
|
|
49
49
|
});
|
|
50
|
-
|
|
51
|
-
|
|
50
|
+
if (failed.length > 0) {
|
|
51
|
+
// setBudgetBatch returns `failed` already sorted ascending by index, so no re-sort here.
|
|
52
|
+
const failedIndices = failed.map((f) => f.index);
|
|
53
|
+
log.warn('Budget batch items failed', { failureCount: failed.length, failedIndices });
|
|
52
54
|
}
|
|
53
55
|
const result = {
|
|
54
56
|
succeeded,
|
|
@@ -13,7 +13,7 @@ Example: "SELECT id, date, amount, payee.name FROM transactions WHERE amount < 0
|
|
|
13
13
|
|
|
14
14
|
WHERE supports: =, !=, >, >=, <, <=; IN (v1, v2); LIKE / NOT LIKE (case and accent-insensitive, % wildcard, e.g. imported_payee LIKE '%amazon%'); IS NULL / IS NOT NULL; boolean columns as true / false (cleared = false, category.hidden = true); conditions joined with AND. OR, REGEXP, NOT IN and parenthesised groups are not supported and return an error (a query is never silently run unfiltered). Transactions come back in split-INLINE mode: "is_parent = true" returns nothing, use "is_parent = false" to exclude split children.
|
|
15
15
|
|
|
16
|
-
Joins use dot notation: payee.name, category.name, account.name (NOT payee_name). Amounts are in cents: $100.00 = 10000.
|
|
16
|
+
Joins use dot notation: payee.name, category.name, account.name (NOT payee_name). Amounts are in cents: $100.00 = 10000. Dates are quoted YYYY-MM-DD strings: date >= '2025-01-01'.
|
|
17
17
|
|
|
18
18
|
Tables:
|
|
19
19
|
- transactions: id, date, amount, notes, cleared, account, payee, category
|
|
@@ -49,7 +49,7 @@ Tables:
|
|
|
49
49
|
throw new Error('ActualQL query builder is not available. The Actual Budget API may not be properly initialized.');
|
|
50
50
|
}
|
|
51
51
|
else if (errorMessage.includes('parse') || errorMessage.includes('syntax')) {
|
|
52
|
-
throw new Error(`Query syntax error: ${errorMessage}\n\nRecommended: Use SQL syntax\nExample: SELECT * FROM transactions ORDER BY date DESC LIMIT 5\n\nSee tool description for
|
|
52
|
+
throw new Error(`Query syntax error: ${errorMessage}\n\nRecommended: Use SQL syntax\nExample: SELECT * FROM transactions ORDER BY date DESC LIMIT 5\n\nSee the tool description for the supported WHERE operators and join syntax.`);
|
|
53
53
|
}
|
|
54
54
|
else {
|
|
55
55
|
throw new Error(`Query execution failed: ${errorMessage}`);
|
|
@@ -41,7 +41,7 @@ Returns: { succeeded: [{index, id}], failed: [{index, error}], total, successCou
|
|
|
41
41
|
failed.push(...res.failed);
|
|
42
42
|
}
|
|
43
43
|
failed.sort((a, b) => a.index - b.index);
|
|
44
|
-
succeeded.
|
|
44
|
+
// succeeded comes back sorted by index from adapter.createRulesBatch, which owns that order (#524).
|
|
45
45
|
return { succeeded, failed, total, successCount: succeeded.length, failureCount: failed.length };
|
|
46
46
|
},
|
|
47
47
|
});
|
|
@@ -75,7 +75,7 @@ Returns: { id, created: boolean }, created=true for a new rule, false for an upd
|
|
|
75
75
|
const input = InputSchema.parse(args || {});
|
|
76
76
|
// ── Validate conditions ──
|
|
77
77
|
for (const condition of input.conditions) {
|
|
78
|
-
const fieldInfo = FIELD_OPERATORS[condition.field];
|
|
78
|
+
const fieldInfo = Object.hasOwn(FIELD_OPERATORS, condition.field) ? FIELD_OPERATORS[condition.field] : undefined;
|
|
79
79
|
if (fieldInfo && !fieldInfo.operators.includes(condition.op)) {
|
|
80
80
|
throw new Error(`Invalid operator "${condition.op}" for field "${condition.field}". ` +
|
|
81
81
|
`Field "${condition.field}" is a ${fieldInfo.type} field and only supports: ${fieldInfo.operators.join(', ')}.`);
|
|
@@ -83,7 +83,7 @@ Stage: omit it to leave the rule where it is, pass null for the normal stage, or
|
|
|
83
83
|
// Validate field usage to guide users toward correct field selection
|
|
84
84
|
if (input.fields.conditions) {
|
|
85
85
|
for (const condition of input.fields.conditions) {
|
|
86
|
-
const fieldInfo = FIELD_OPERATORS[condition.field];
|
|
86
|
+
const fieldInfo = Object.hasOwn(FIELD_OPERATORS, condition.field) ? FIELD_OPERATORS[condition.field] : undefined;
|
|
87
87
|
// Validate operator is compatible with field type
|
|
88
88
|
if (fieldInfo && !fieldInfo.operators.includes(condition.op)) {
|
|
89
89
|
throw new Error(`Invalid operator "${condition.op}" for field "${condition.field}". ` +
|
|
@@ -37,7 +37,7 @@ const InputSchema = z.object({
|
|
|
37
37
|
});
|
|
38
38
|
const tool = {
|
|
39
39
|
name: 'actual_transactions_update_batch',
|
|
40
|
-
description: `Update multiple transactions in a single call. Accepts up to 50 {id, fields} pairs. Each update is applied independently:
|
|
40
|
+
description: `Update multiple transactions in a single call. Accepts up to 50 {id, fields} pairs. Each update is applied independently and NOT atomically: items applied before a failure stay applied. A missing id or an update Actual rejects is reported per item in failed, and the rest continue. If the call itself fails (lost connection, out of memory, timeout), the error names the ids already applied; read the transactions back with actual_transactions_get BEFORE retrying. Splits are not supported here: a subtransactions field is ignored in batch. Use actual_transactions_update to edit an existing split's children.
|
|
41
41
|
|
|
42
42
|
Returns: { succeeded: [{id}], failed: [{id, error}], total, successCount, failureCount }
|
|
43
43
|
|
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.22.
|
|
4
|
+
"version": "0.22.19",
|
|
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/transactions_aggregate.test.js && node tests/unit/account_flow_summary.test.js && node tests/unit/recurring_expenses_summary.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/e2e_spec_collection.test.js && node tests/unit/compose_profile_sync.test.js && node tests/unit/advertised_tools_sync.test.js && node tests/unit/new_tool_completeness.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/write_effect_audit_staleness.test.js && node tests/unit/write_effect_audit_membership.test.js && node tests/unit/tool_annotations.test.js && node tests/unit/tool_id_schema_drift.test.js && node tests/unit/filter_id_resolution.test.js && node tests/unit/filter_id_tool_wiring.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/adapter_set_budget_batch.test.js && node tests/unit/budget_updates_batch.test.js && node tests/unit/rules_create.test.js && node tests/unit/adapter_rule_reference_guard.test.js && node tests/unit/rules_create_batch.test.js && node tests/unit/budgets_hold_for_next_month.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/httpServer_init_failure_report.test.js && node tests/unit/stdio_init_failure_report.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/accounts_delete.test.js && node tests/unit/update_tools_not_found.test.js && node tests/unit/accounts_close.test.js && node tests/unit/accounts_reopen.test.js && node tests/unit/payees_delete.test.js && node tests/unit/payees_merge.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/session_ownership.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/oidc_resource_identifier.test.js && node tests/unit/httpServer_oidc_resource_metadata.test.js && node tests/unit/oidc_error_cause.test.js && node tests/unit/httpServer_oidc_error_body.test.js && node tests/unit/oidc_token_source.test.js && node tests/unit/config_oidc_assertion_refines.test.js && node tests/unit/httpServer_oidc_assertion_mode.test.js && node tests/unit/httpServer_body_limit.test.js && node tests/unit/httpServer_batch_cap.test.js && node tests/unit/dotenv_stdout_quiet.test.js && node tests/unit/httpServer_bind_host.test.js && node tests/unit/adapter_write_pool_cooperation.test.js && node tests/unit/adapter_drain_listing_cache.test.js && node tests/unit/budget_acl_enforcement.test.js && node tests/unit/budget_selection_precondition.test.js && node tests/unit/budget_load_abandonment.test.js && node tests/unit/budget_loader_postcondition.test.js && node tests/unit/budget_loader_import_allowlist.test.js && node tests/unit/budget_import_tracking.test.js && node tests/unit/pool_shutdown_under_lock.test.js && node tests/unit/api_lock_affinity.test.js && node tests/unit/commit_message_closing_keyword.test.js && node tests/unit/write_queue_stranding.test.js && node tests/unit/write_queue_stranded_bound.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/transactions_update_split_convert.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/transactions_update_batch.test.js && node tests/unit/tool_count_sync.test.js && node tests/unit/stdio_transport.test.js && node tests/unit/stdio_budget_switch.test.js && node tests/unit/logger_structured.test.js && node tests/unit/logger_redaction.test.js && node tests/unit/logger_correlation.test.js && node tests/unit/check_coverage.test.js && node tests/unit/adapter_op_timeout.test.js && node tests/unit/adapter_write_queue_wakeup.test.js && node tests/unit/ci_workflow_guards.test.js && node tests/unit/node_version_guard.test.js && node tests/unit/node_version_drift.test.js && node tests/unit/playwright_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/server_version_probe_outside_op_timeout.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/budgets_import_syncid.test.js && node tests/unit/budget_acl_dynamic.test.js && node tests/unit/budget_acl_identity_map.test.js && node tests/unit/budget_acl_userinfo.test.js && node tests/unit/acl_e2e_fixture_contract.test.js && node tests/unit/budget_acl_middleware.test.js && node tests/unit/rules_stage.test.js && node tests/unit/oidc_scope_list.test.js && node tests/unit/get_context.test.js && node tests/unit/contributor_docs.test.js && node tests/unit/toolsets.test.js && node tests/unit/toolsets_entrypoint.test.js && node tests/unit/tools_list_size_budget.test.js && node tests/unit/rules_update_schedules_schema.test.js",
|
|
33
|
+
"test:unit-js": "node tests/unit/transactions_create.test.js && node tests/unit/transactions_aggregate.test.js && node tests/unit/account_flow_summary.test.js && node tests/unit/recurring_expenses_summary.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/e2e_spec_collection.test.js && node tests/unit/compose_profile_sync.test.js && node tests/unit/advertised_tools_sync.test.js && node tests/unit/new_tool_completeness.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/write_effect_audit_staleness.test.js && node tests/unit/write_effect_audit_membership.test.js && node tests/unit/tool_annotations.test.js && node tests/unit/tool_id_schema_drift.test.js && node tests/unit/filter_id_resolution.test.js && node tests/unit/filter_id_tool_wiring.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/adapter_set_budget_batch.test.js && node tests/unit/budget_updates_batch.test.js && node tests/unit/rules_create.test.js && node tests/unit/adapter_rule_reference_guard.test.js && node tests/unit/rules_create_batch.test.js && node tests/unit/budgets_hold_for_next_month.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/httpServer_init_failure_report.test.js && node tests/unit/stdio_init_failure_report.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/accounts_delete.test.js && node tests/unit/update_tools_not_found.test.js && node tests/unit/accounts_close.test.js && node tests/unit/accounts_reopen.test.js && node tests/unit/payees_delete.test.js && node tests/unit/payees_merge.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/session_ownership.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/oidc_resource_identifier.test.js && node tests/unit/httpServer_oidc_resource_metadata.test.js && node tests/unit/oidc_error_cause.test.js && node tests/unit/httpServer_oidc_error_body.test.js && node tests/unit/oidc_token_source.test.js && node tests/unit/config_oidc_assertion_refines.test.js && node tests/unit/httpServer_oidc_assertion_mode.test.js && node tests/unit/httpServer_body_limit.test.js && node tests/unit/httpServer_batch_cap.test.js && node tests/unit/dotenv_stdout_quiet.test.js && node tests/unit/httpServer_bind_host.test.js && node tests/unit/adapter_write_pool_cooperation.test.js && node tests/unit/adapter_drain_listing_cache.test.js && node tests/unit/budget_acl_enforcement.test.js && node tests/unit/budget_selection_precondition.test.js && node tests/unit/budget_load_abandonment.test.js && node tests/unit/budget_loader_postcondition.test.js && node tests/unit/budget_loader_import_allowlist.test.js && node tests/unit/budget_import_tracking.test.js && node tests/unit/pool_shutdown_under_lock.test.js && node tests/unit/api_lock_affinity.test.js && node tests/unit/commit_message_closing_keyword.test.js && node tests/unit/write_queue_stranding.test.js && node tests/unit/write_queue_stranded_bound.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/transactions_update_split_convert.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/transactions_update_batch.test.js && node tests/unit/tool_count_sync.test.js && node tests/unit/stdio_transport.test.js && node tests/unit/stdio_budget_switch.test.js && node tests/unit/logger_structured.test.js && node tests/unit/logger_redaction.test.js && node tests/unit/logger_correlation.test.js && node tests/unit/check_coverage.test.js && node tests/unit/adapter_op_timeout.test.js && node tests/unit/adapter_write_queue_wakeup.test.js && node tests/unit/ci_workflow_guards.test.js && node tests/unit/node_version_guard.test.js && node tests/unit/node_version_drift.test.js && node tests/unit/playwright_version_drift.test.js && node tests/unit/observability_smoke.test.js && node tests/unit/residue_pattern.test.js && node tests/unit/batch_uncategorized_cleanup.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/server_version_probe_outside_op_timeout.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/budgets_import_syncid.test.js && node tests/unit/budget_acl_dynamic.test.js && node tests/unit/budget_acl_identity_map.test.js && node tests/unit/budget_acl_userinfo.test.js && node tests/unit/acl_e2e_fixture_contract.test.js && node tests/unit/budget_acl_middleware.test.js && node tests/unit/rules_stage.test.js && node tests/unit/oidc_scope_list.test.js && node tests/unit/get_context.test.js && node tests/unit/contributor_docs.test.js && node tests/unit/toolsets.test.js && node tests/unit/toolsets_entrypoint.test.js && node tests/unit/tools_list_size_budget.test.js && node tests/unit/rules_update_schedules_schema.test.js && node tests/unit/source_text_helper.test.js && node tests/unit/transport_onerror.test.js && node tests/unit/adapter_transaction_batch_abort.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",
|