actual-mcp-server 0.16.13 → 0.17.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +13 -12
- package/dist/src/actualToolsManager.js +1 -0
- package/dist/src/lib/actual-adapter/auth-retry.js +8 -1
- package/dist/src/lib/actual-adapter/filter-ids.js +4 -0
- package/dist/src/lib/actual-adapter.js +44 -8
- package/dist/src/lib/financial-analysis.js +214 -0
- package/dist/src/lib/retry.js +38 -1
- package/dist/src/lib/tool-annotations.js +1 -0
- package/dist/src/tools/index.js +1 -0
- package/dist/src/tools/transactions_aggregate.js +87 -0
- package/package.json +3 -3
package/README.md
CHANGED
|
@@ -24,13 +24,13 @@ Actual MCP Server is a [Model Context Protocol](https://modelcontextprotocol.io/
|
|
|
24
24
|
┌─────────────┐ MCP/HTTP ┌──────────────────┐ Actual API ┌──────────────┐
|
|
25
25
|
│ LibreChat │ ◄───────────► │ Actual MCP │ ◄───────────► │ Actual │
|
|
26
26
|
│ LobeChat │ │ Server │ │ Budget │
|
|
27
|
-
│ (remote) │ │ (
|
|
27
|
+
│ (remote) │ │ (75 tools) │ │ Server │
|
|
28
28
|
└─────────────┘ └──────────────────┘ └──────────────┘
|
|
29
29
|
|
|
30
30
|
┌─────────────┐ MCP/stdio ┌──────────────────┐ Actual API ┌──────────────┐
|
|
31
31
|
│ Claude │ ◄───────────► │ Actual MCP │ ◄───────────► │ Actual │
|
|
32
32
|
│ Desktop │ │ Server │ │ Budget │
|
|
33
|
-
│ (local) │ │ (
|
|
33
|
+
│ (local) │ │ (75 tools) │ │ Server │
|
|
34
34
|
└─────────────┘ └──────────────────┘ └──────────────┘
|
|
35
35
|
```
|
|
36
36
|
|
|
@@ -38,14 +38,14 @@ Actual MCP Server is a [Model Context Protocol](https://modelcontextprotocol.io/
|
|
|
38
38
|
|
|
39
39
|
Most Actual Budget MCP implementations are simple stdio bridges designed for single-user, local use with Claude Desktop. This project goes further:
|
|
40
40
|
|
|
41
|
-
- **
|
|
41
|
+
- **75 tools, the most comprehensive coverage available.** Accounts, transactions, categories, payees, tags, notes, rules, budgets, batch operations, bank sync, and more. Covers the reachable Actual Budget API with no genuine gaps.
|
|
42
42
|
- **HTTP and stdio transport.** Runs as a real remote server for LibreChat/LobeChat (`--http`), or as a direct local process for Claude Desktop (`--stdio`). No Docker or HTTP server is needed for local use.
|
|
43
43
|
- **6 exclusive ActualQL-powered tools.** Search and summarise transactions by month, amount, category, or payee using Actual Budget's native query engine. Aggregated results, no raw data dumped into the AI context window.
|
|
44
44
|
- **Multi-budget switching at runtime.** Configure multiple budget files and let the AI switch between them mid-conversation with `actual_budgets_switch`. Works on both transports: HTTP keys the active budget to the MCP session, and stdio (Claude Desktop, Claude Code, Cursor) gets a synthetic per-process session so a switch is scoped to that process rather than shared globally (#348).
|
|
45
45
|
- **Multi-user ready with OIDC.** Secure every session with JWKS-validated JWTs and per-user budget ACLs. No shared tokens required.
|
|
46
|
-
- **Production-grade reliability.**
|
|
46
|
+
- **Production-grade reliability on both transports.** HTTP connection pooling (up to 15 concurrent sessions), and a long-lived stdio process that logs in ONCE and reuses that connection for every tool call instead of re-authenticating per call, so Claude Desktop and Claude Code stay fast and a burst of calls no longer risks a per-call login storm against the upstream limiter. Automatic retry with exponential backoff, and a full test suite (unit + E2E + integration).
|
|
47
47
|
|
|
48
|
-
> **Verified working** with [LibreChat](https://www.librechat.ai/), [LobeChat](https://lobehub.com/home), and [Claude Desktop](https://claude.ai/download). All
|
|
48
|
+
> **Verified working** with [LibreChat](https://www.librechat.ai/), [LobeChat](https://lobehub.com/home), and [Claude Desktop](https://claude.ai/download). All 75 tools tested end-to-end. Any MCP-compatible client should work.
|
|
49
49
|
|
|
50
50
|
---
|
|
51
51
|
|
|
@@ -178,7 +178,7 @@ Add to `claude_desktop_config.json` (see [docs/guides/MCP_CLIENTS_SETUP.md](docs
|
|
|
178
178
|
}
|
|
179
179
|
```
|
|
180
180
|
|
|
181
|
-
> **No token needed.** stdio runs as a local process owned by your user. The transport itself is the security boundary. All
|
|
181
|
+
> **No token needed.** stdio runs as a local process owned by your user. The transport itself is the security boundary. All 75 tools are available.
|
|
182
182
|
>
|
|
183
183
|
> **`MCP_BRIDGE_DATA_DIR` should be an absolute path.** Without one, the data directory resolves relative to wherever the client spawns the process, which can be unpredictable. The directory is created automatically on first run.
|
|
184
184
|
|
|
@@ -304,7 +304,7 @@ For Claude Desktop (stdio), restart Claude after upgrading.
|
|
|
304
304
|
|
|
305
305
|
## Available Tools
|
|
306
306
|
|
|
307
|
-
**
|
|
307
|
+
**75 tools** across all categories. All tools use the `actual_<category>_<action>` naming convention.
|
|
308
308
|
|
|
309
309
|
### Accounts (7)
|
|
310
310
|
|
|
@@ -318,7 +318,7 @@ For Claude Desktop (stdio), restart Claude after upgrading.
|
|
|
318
318
|
| `actual_accounts_reopen` | Reopen closed account. An id that is not an account is refused rather than creating one |
|
|
319
319
|
| `actual_accounts_get_balance` | Get account balance at a date |
|
|
320
320
|
|
|
321
|
-
### Transactions (
|
|
321
|
+
### Transactions (15)
|
|
322
322
|
|
|
323
323
|
**Standard (6)**
|
|
324
324
|
|
|
@@ -340,10 +340,11 @@ For Claude Desktop (stdio), restart Claude after upgrading.
|
|
|
340
340
|
| `actual_transactions_uncategorized` | Summary of uncategorized transactions (totalCount, totalAmount, per-account breakdown); pass `includeTransactions:true` for paginated rows |
|
|
341
341
|
| `actual_transactions_update_batch` | Apply many transaction updates in ONE call (`updates: [{ id, fields }]`); returns per-item success and failure counts |
|
|
342
342
|
|
|
343
|
-
**Exclusive ActualQL-powered (
|
|
343
|
+
**Exclusive ActualQL-powered (7)**, unique to this MCP server
|
|
344
344
|
|
|
345
345
|
| Tool | Description |
|
|
346
346
|
|------|-------------|
|
|
347
|
+
| `actual_transactions_aggregate` | Deterministic integer-cent totals grouped by month, category, category group, payee, or account; transfers excluded via `transfer_id` and split children counted once |
|
|
347
348
|
| `actual_transactions_search_by_month` | Search by month using `$month` transform |
|
|
348
349
|
| `actual_transactions_search_by_amount` | Find by amount range |
|
|
349
350
|
| `actual_transactions_search_by_category` | Search by category name |
|
|
@@ -591,7 +592,7 @@ stdio is the simplest way to connect Claude Desktop directly to Actual Budget. T
|
|
|
591
592
|
- No auth token. Process ownership is the security boundary.
|
|
592
593
|
- All logs go to stderr so they never corrupt the JSON-RPC framing on stdout
|
|
593
594
|
- The process exits when stdin closes (Claude Desktop shutting down)
|
|
594
|
-
- All
|
|
595
|
+
- All 75 tools are available, identical to HTTP mode
|
|
595
596
|
|
|
596
597
|
**Start manually to verify:**
|
|
597
598
|
|
|
@@ -690,7 +691,7 @@ See [AI Client Setup, OIDC](docs/guides/AI_CLIENT_SETUP.md#oidc-authentication-m
|
|
|
690
691
|
| Command | What It Tests | Requires Live Server |
|
|
691
692
|
|---------|---------------|---------------------|
|
|
692
693
|
| `npm run build` | TypeScript compilation | No |
|
|
693
|
-
| `npm run test:unit-js` |
|
|
694
|
+
| `npm run test:unit-js` | 75-tool smoke, schema validation, auth ACL | No |
|
|
694
695
|
| `npm run test:adapter` | Adapter, retry logic, concurrency | No |
|
|
695
696
|
| `npm run test:e2e` | MCP protocol compliance (Playwright) | No |
|
|
696
697
|
| `npm run test:e2e:docker:full` | Full stack integration | Yes (Docker) |
|
|
@@ -775,7 +776,7 @@ The software is provided **as-is**, without warranty of any kind. The author acc
|
|
|
775
776
|
|
|
776
777
|
---
|
|
777
778
|
|
|
778
|
-
**Version:** 0.
|
|
779
|
+
**Version:** 0.17.0 | **Tool Count:** 75 (verified LibreChat-compatible)
|
|
779
780
|
|
|
780
781
|
## Sponsor
|
|
781
782
|
|
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
// getAuthRetryCounts() for getConcurrencyState, and wraps api.init() with
|
|
6
6
|
// withAuthRetry. Linked issue: #127.
|
|
7
7
|
import { DEFAULT_RETRY_ATTEMPTS, MAX_RETRY_DELAY_MS } from '../constants.js';
|
|
8
|
+
import { isRateLimitError } from '../retry.js';
|
|
8
9
|
import logger from '../../logger.js';
|
|
9
10
|
let authRetryCount = 0; // monotonic, observability
|
|
10
11
|
let authRetryFailureCount = 0; // increments only when retry budget exhausted
|
|
@@ -25,7 +26,13 @@ const AUTH_RETRY_BASE_BACKOFF_MS = 5000;
|
|
|
25
26
|
export function isRetryableAuthError(err) {
|
|
26
27
|
if (!(err instanceof Error))
|
|
27
28
|
return false;
|
|
28
|
-
|
|
29
|
+
// #422: reconcile with the pool-drop rate-limit matcher by UNION, never a
|
|
30
|
+
// substitution. `isRateLimitError` matches BOTH the hyphenated `too-many-requests`
|
|
31
|
+
// code AND the spaced express default `Too many requests, ...` the current server
|
|
32
|
+
// returns, so this now retries a login throttle in either form (the spaced form
|
|
33
|
+
// was previously missed). The `network-failure` branch is preserved verbatim so
|
|
34
|
+
// withAuthRetry's login self-heal is not narrowed.
|
|
35
|
+
return (isRateLimitError(err) ||
|
|
29
36
|
err.message.includes('Authentication failed: network-failure'));
|
|
30
37
|
}
|
|
31
38
|
/**
|
|
@@ -31,6 +31,10 @@ import { UUID_PATTERN } from '../constants.js';
|
|
|
31
31
|
export const FILTER_ID_ENTITIES = {
|
|
32
32
|
account: { entity: 'Account', listTool: 'actual_accounts_list' },
|
|
33
33
|
category: { entity: 'Category', listTool: 'actual_categories_get' },
|
|
34
|
+
// #424: category_group joins so the aggregate tool's categoryGroupIds filter resolves a NAME the
|
|
35
|
+
// same way the other three do. Without it a group name would fall through resolveFilterId's rows
|
|
36
|
+
// resolver to the payee listing and match nothing (a wrong answer, not an error).
|
|
37
|
+
category_group: { entity: 'Category group', listTool: 'actual_category_groups_get' },
|
|
34
38
|
payee: { entity: 'Payee', listTool: 'actual_payees_get' },
|
|
35
39
|
};
|
|
36
40
|
/**
|
|
@@ -10,7 +10,7 @@ const { addTransactions: rawAddTransactions, getAccounts: rawGetAccounts, import
|
|
|
10
10
|
} = api;
|
|
11
11
|
import { EventEmitter } from 'events';
|
|
12
12
|
import observability from '../observability.js';
|
|
13
|
-
import { retry, isRetryableError } from './retry.js';
|
|
13
|
+
import { retry, isRetryableError, isRateLimitError } from './retry.js';
|
|
14
14
|
import { withOpTimeout } from './opTimeout.js';
|
|
15
15
|
import { NotFoundRefusal, OutOfRangeRefusal, constraintErrorMsg } from './errors.js';
|
|
16
16
|
import { findMatchingRule } from './rule-matching.js';
|
|
@@ -295,13 +295,16 @@ export function _shouldKeepSingletonAlive(activeSessions, forceFullShutdown) {
|
|
|
295
295
|
* actually corrupted but the error pattern doesn't match, the next call's op
|
|
296
296
|
* will surface the same root cause and we'll catch it then.
|
|
297
297
|
*/
|
|
298
|
-
// Whether an error is infrastructure-level (drop the pooled connection
|
|
299
|
-
// next call re-inits cleanly). This
|
|
300
|
-
//
|
|
301
|
-
//
|
|
302
|
-
//
|
|
298
|
+
// Whether an error is infrastructure-level (drop the pooled connection / tear
|
|
299
|
+
// down the stdio singleton so the next call re-inits cleanly). This DERIVES from
|
|
300
|
+
// isRetryableError (#177: one authored pattern source, pool-drop and retry cannot
|
|
301
|
+
// drift), with ONE deliberate subtraction (#422): a rate-limit is transient but
|
|
302
|
+
// does NOT corrupt the connection, so it must not drop it. Re-logging-in during a
|
|
303
|
+
// throttle adds a fresh login to an already-full window, cascading into a relogin
|
|
304
|
+
// storm (observed over stdio in #383). Retry still backs a rate-limit off; drop
|
|
305
|
+
// does not. A consistency test pins this exact relationship.
|
|
303
306
|
function _shouldDropPoolOnError(err) {
|
|
304
|
-
return isRetryableError(err);
|
|
307
|
+
return isRetryableError(err) && !isRateLimitError(err);
|
|
305
308
|
}
|
|
306
309
|
/**
|
|
307
310
|
* Enforce per-request budget ACL before any pool branching or lock acquisition.
|
|
@@ -1656,7 +1659,8 @@ export async function resolveFilterId(kind, value, opts) {
|
|
|
1656
1659
|
const rows = opts?.rows
|
|
1657
1660
|
?? (kind === 'account' ? await getAccounts()
|
|
1658
1661
|
: kind === 'category' ? await getCategories()
|
|
1659
|
-
: await
|
|
1662
|
+
: kind === 'category_group' ? (await getCategoryGroups())
|
|
1663
|
+
: await getPayees());
|
|
1660
1664
|
if (isEntityId(value)) {
|
|
1661
1665
|
// Only reachable under verifyExists. A well-formed id that names nothing is a not-found,
|
|
1662
1666
|
// not a name to resolve.
|
|
@@ -2817,6 +2821,37 @@ export async function runQuery(queryString) {
|
|
|
2817
2821
|
throw new Error(`Query execution failed: ${errorMsg}`);
|
|
2818
2822
|
}
|
|
2819
2823
|
}
|
|
2824
|
+
/**
|
|
2825
|
+
* #424: one read snapshot for the deterministic financial-analysis tools. Runs a fixed set of
|
|
2826
|
+
* ActualQL reads inside ONE withActualApi session (not N+1): the in-range transactions with splits
|
|
2827
|
+
* GROUPED (so a split reduces to its children exactly once), plus the account/category/group/payee
|
|
2828
|
+
* listings the tool needs to resolve filters and label groups. The account-flow tool (#425) extends
|
|
2829
|
+
* this with transfer counterparts and opening/closing balances.
|
|
2830
|
+
*
|
|
2831
|
+
* Original query shapes by @maxvanweenen (PR #399).
|
|
2832
|
+
*/
|
|
2833
|
+
export async function getFinancialAnalysisSnapshot(params) {
|
|
2834
|
+
return withActualApi(async () => {
|
|
2835
|
+
observability.incrementToolCall('actual.financialAnalysis.snapshot').catch(() => { });
|
|
2836
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
2837
|
+
const { q } = (await import('@actual-app/api'));
|
|
2838
|
+
const queryData = async (query) => {
|
|
2839
|
+
const response = await withConcurrency(() => retry(() => rawRunQuery(query), { retries: 2, backoffMs: 200 }));
|
|
2840
|
+
if (Array.isArray(response))
|
|
2841
|
+
return response;
|
|
2842
|
+
return Array.isArray(response?.data) ? response.data : [];
|
|
2843
|
+
};
|
|
2844
|
+
const transactions = await queryData(q('transactions')
|
|
2845
|
+
.options({ splits: 'grouped' })
|
|
2846
|
+
.filter({ $and: [{ date: { $gte: params.startDate } }, { date: { $lte: params.endDate } }] })
|
|
2847
|
+
.select('*'));
|
|
2848
|
+
const accounts = await queryData(q('accounts').select(['id', 'name', 'offbudget', 'closed']));
|
|
2849
|
+
const categories = await queryData(q('categories').select(['id', 'name', 'is_income', 'group']));
|
|
2850
|
+
const categoryGroups = await queryData(q('category_groups').options({ categories: 'none' }).select(['id', 'name', 'is_income']));
|
|
2851
|
+
const payees = await queryData(q('payees').select(['id', 'name', 'transfer_acct']));
|
|
2852
|
+
return { transactions, accounts, categories, categoryGroups, payees };
|
|
2853
|
+
});
|
|
2854
|
+
}
|
|
2820
2855
|
// WHERE-clause translation extracted to ./actual-adapter/query.ts (#166).
|
|
2821
2856
|
// Imported for internal use by runQuery and re-exported (unit-tested directly).
|
|
2822
2857
|
import { parseWhereClause } from './actual-adapter/query.js';
|
|
@@ -3345,6 +3380,7 @@ export async function getPreferences() {
|
|
|
3345
3380
|
export default {
|
|
3346
3381
|
getAccounts,
|
|
3347
3382
|
resolveFilterId,
|
|
3383
|
+
getFinancialAnalysisSnapshot,
|
|
3348
3384
|
getAccountsWithBalances,
|
|
3349
3385
|
addTransactions,
|
|
3350
3386
|
importTransactions,
|
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
// Deterministic financial-analysis primitives: pure functions that turn a snapshot of Actual
|
|
2
|
+
// Budget rows into aggregated, integer-cent totals, so the accounting is computed in server code
|
|
3
|
+
// rather than re-derived by an LLM.
|
|
4
|
+
//
|
|
5
|
+
// Original implementation by @maxvanweenen (PR #399, https://github.com/agigante80/actual-mcp-server/pull/399),
|
|
6
|
+
// requested in #398. This file is the aggregate slice (the `actual_transactions_aggregate` tool,
|
|
7
|
+
// #424); the account-flow and recurring-expense functions land with their own tools (#425, #426).
|
|
8
|
+
//
|
|
9
|
+
// PURE by design: no api session, no module state, so every branch is unit-testable against fixed
|
|
10
|
+
// rows. The adapter owns the impure half (fetching the snapshot) in `getFinancialAnalysisSnapshot`.
|
|
11
|
+
const zeroAggregate = () => ({
|
|
12
|
+
expenseOutflow: 0,
|
|
13
|
+
credits: 0,
|
|
14
|
+
netExpense: 0,
|
|
15
|
+
income: 0,
|
|
16
|
+
uncategorizedInflows: 0,
|
|
17
|
+
adjustments: 0,
|
|
18
|
+
transferInflow: 0,
|
|
19
|
+
transferOutflow: 0,
|
|
20
|
+
transactionCount: 0,
|
|
21
|
+
transferCount: 0,
|
|
22
|
+
});
|
|
23
|
+
function bool(value) {
|
|
24
|
+
return value === true || value === 1;
|
|
25
|
+
}
|
|
26
|
+
// Money is integer cents everywhere. A non-integer amount is a data-shape error, not something to
|
|
27
|
+
// round, so it throws rather than silently corrupting a total.
|
|
28
|
+
function integer(value, field) {
|
|
29
|
+
if (typeof value !== 'number' || !Number.isSafeInteger(value)) {
|
|
30
|
+
throw new Error(`${field} must be a safe integer number of cents.`);
|
|
31
|
+
}
|
|
32
|
+
return value;
|
|
33
|
+
}
|
|
34
|
+
export function buildMaps(snapshot) {
|
|
35
|
+
return {
|
|
36
|
+
accounts: new Map(snapshot.accounts.map(account => [account.id, account])),
|
|
37
|
+
categories: new Map(snapshot.categories.map(category => [category.id, category])),
|
|
38
|
+
categoryGroups: new Map(snapshot.categoryGroups.map(group => [group.id, group])),
|
|
39
|
+
payees: new Map(snapshot.payees.map(payee => [payee.id, payee])),
|
|
40
|
+
transactions: new Map([...snapshot.transactions, ...(snapshot.transferCounterparts ?? [])].flatMap(transaction => [
|
|
41
|
+
transaction,
|
|
42
|
+
...(transaction.subtransactions ?? []),
|
|
43
|
+
]).map(transaction => [transaction.id, transaction])),
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
/**
|
|
47
|
+
* Convert Actual's grouped split representation into posting rows. A normal transaction appears
|
|
48
|
+
* once. A split contributes its CHILDREN and never its parent, so a split is counted exactly once.
|
|
49
|
+
* A `seen` set dedups a child that arrives both grouped and top-level, in either order.
|
|
50
|
+
*/
|
|
51
|
+
export function postingRows(transactions) {
|
|
52
|
+
const rows = [];
|
|
53
|
+
const seen = new Set();
|
|
54
|
+
for (const transaction of transactions) {
|
|
55
|
+
if (transaction.is_parent === true || (transaction.subtransactions?.length ?? 0) > 0) {
|
|
56
|
+
for (const child of transaction.subtransactions ?? []) {
|
|
57
|
+
if (seen.has(child.id))
|
|
58
|
+
continue;
|
|
59
|
+
seen.add(child.id);
|
|
60
|
+
rows.push({
|
|
61
|
+
...child,
|
|
62
|
+
account: child.account ?? transaction.account,
|
|
63
|
+
date: child.date ?? transaction.date,
|
|
64
|
+
payee: child.payee ?? transaction.payee,
|
|
65
|
+
parent_id: child.parent_id ?? transaction.id,
|
|
66
|
+
is_child: true,
|
|
67
|
+
});
|
|
68
|
+
}
|
|
69
|
+
continue;
|
|
70
|
+
}
|
|
71
|
+
if (transaction.parent_id != null || transaction.is_child === true) {
|
|
72
|
+
if (!seen.has(transaction.id)) {
|
|
73
|
+
seen.add(transaction.id);
|
|
74
|
+
rows.push(transaction);
|
|
75
|
+
}
|
|
76
|
+
continue;
|
|
77
|
+
}
|
|
78
|
+
if (!seen.has(transaction.id)) {
|
|
79
|
+
seen.add(transaction.id);
|
|
80
|
+
rows.push(transaction);
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
return rows;
|
|
84
|
+
}
|
|
85
|
+
/**
|
|
86
|
+
* Classify a posting row. A TRANSFER is detected from `transfer_id` FIRST, before any category or
|
|
87
|
+
* income logic, so a transfer is never spending or income regardless of how it is categorized. That
|
|
88
|
+
* is the exact fragility #398 identified in the existing summary tools.
|
|
89
|
+
*/
|
|
90
|
+
export function classifyExternal(transaction, category) {
|
|
91
|
+
if (transaction.transfer_id != null)
|
|
92
|
+
return 'transfer';
|
|
93
|
+
if (transaction.starting_balance_flag === true)
|
|
94
|
+
return 'adjustment';
|
|
95
|
+
const amount = integer(transaction.amount, `Transaction ${transaction.id} amount`);
|
|
96
|
+
if (category && bool(category.is_income))
|
|
97
|
+
return 'income';
|
|
98
|
+
if (amount < 0)
|
|
99
|
+
return 'expense';
|
|
100
|
+
if (amount > 0 && category)
|
|
101
|
+
return 'credit';
|
|
102
|
+
if (amount > 0)
|
|
103
|
+
return 'uncategorized-inflow';
|
|
104
|
+
return 'expense';
|
|
105
|
+
}
|
|
106
|
+
function groupingFields(transaction, dimensions, maps) {
|
|
107
|
+
const category = transaction.category ? maps.categories.get(transaction.category) : undefined;
|
|
108
|
+
const categoryGroup = category?.group ? maps.categoryGroups.get(category.group) : undefined;
|
|
109
|
+
const payee = transaction.payee ? maps.payees.get(transaction.payee) : undefined;
|
|
110
|
+
const account = maps.accounts.get(transaction.account);
|
|
111
|
+
const fields = {};
|
|
112
|
+
for (const dimension of dimensions) {
|
|
113
|
+
if (dimension === 'month')
|
|
114
|
+
fields.month = transaction.date.slice(0, 7);
|
|
115
|
+
if (dimension === 'category') {
|
|
116
|
+
fields.categoryId = category?.id ?? null;
|
|
117
|
+
fields.categoryName = category?.name ?? 'Uncategorized';
|
|
118
|
+
}
|
|
119
|
+
if (dimension === 'category_group') {
|
|
120
|
+
fields.categoryGroupId = categoryGroup?.id ?? null;
|
|
121
|
+
fields.categoryGroupName = categoryGroup?.name ?? 'Uncategorized';
|
|
122
|
+
}
|
|
123
|
+
if (dimension === 'payee') {
|
|
124
|
+
fields.payeeId = payee?.id ?? null;
|
|
125
|
+
fields.payeeName = payee?.name ?? 'Unknown payee';
|
|
126
|
+
}
|
|
127
|
+
if (dimension === 'account') {
|
|
128
|
+
fields.accountId = transaction.account;
|
|
129
|
+
fields.accountName = account?.name ?? transaction.account;
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
return fields;
|
|
133
|
+
}
|
|
134
|
+
function matchesFilters(transaction, input, maps) {
|
|
135
|
+
if (transaction.date < input.startDate || transaction.date > input.endDate)
|
|
136
|
+
return false;
|
|
137
|
+
if (input.accountIds && !input.accountIds.includes(transaction.account))
|
|
138
|
+
return false;
|
|
139
|
+
if (input.categoryIds && (!transaction.category || !input.categoryIds.includes(transaction.category)))
|
|
140
|
+
return false;
|
|
141
|
+
if (input.payeeIds && (!transaction.payee || !input.payeeIds.includes(transaction.payee)))
|
|
142
|
+
return false;
|
|
143
|
+
if (input.categoryGroupIds) {
|
|
144
|
+
const groupId = transaction.category ? maps.categories.get(transaction.category)?.group : undefined;
|
|
145
|
+
if (!groupId || !input.categoryGroupIds.includes(groupId))
|
|
146
|
+
return false;
|
|
147
|
+
}
|
|
148
|
+
return true;
|
|
149
|
+
}
|
|
150
|
+
function addAggregate(target, source) {
|
|
151
|
+
target.expenseOutflow += source.expenseOutflow;
|
|
152
|
+
target.credits += source.credits;
|
|
153
|
+
target.income += source.income;
|
|
154
|
+
target.uncategorizedInflows += source.uncategorizedInflows;
|
|
155
|
+
target.adjustments += source.adjustments;
|
|
156
|
+
target.transferInflow += source.transferInflow;
|
|
157
|
+
target.transferOutflow += source.transferOutflow;
|
|
158
|
+
target.transactionCount += source.transactionCount;
|
|
159
|
+
target.transferCount += source.transferCount;
|
|
160
|
+
target.netExpense = target.expenseOutflow - target.credits;
|
|
161
|
+
}
|
|
162
|
+
/**
|
|
163
|
+
* Group posting rows over an inclusive date range into integer-cent totals. Transfers are excluded
|
|
164
|
+
* by default (kept in their own inflow/outflow fields when `excludeTransfers` is false); income is
|
|
165
|
+
* excluded unless `includeIncome`. Deliberately groups IN PROCESS rather than via a `.groupBy`
|
|
166
|
+
* query, because a grouped query would flatten splits back to inline and reintroduce double-count.
|
|
167
|
+
*/
|
|
168
|
+
export function aggregateTransactions(snapshot, input) {
|
|
169
|
+
const maps = buildMaps(snapshot);
|
|
170
|
+
const grouped = new Map();
|
|
171
|
+
for (const transaction of postingRows(snapshot.transactions)) {
|
|
172
|
+
if (!matchesFilters(transaction, input, maps))
|
|
173
|
+
continue;
|
|
174
|
+
const category = transaction.category ? maps.categories.get(transaction.category) : undefined;
|
|
175
|
+
const classification = classifyExternal(transaction, category);
|
|
176
|
+
if (classification === 'income' && !input.includeIncome)
|
|
177
|
+
continue;
|
|
178
|
+
if (classification === 'transfer' && input.excludeTransfers)
|
|
179
|
+
continue;
|
|
180
|
+
const dimensions = groupingFields(transaction, input.groupBy, maps);
|
|
181
|
+
const key = JSON.stringify(dimensions);
|
|
182
|
+
const entry = grouped.get(key) ?? { dimensions, values: zeroAggregate() };
|
|
183
|
+
const amount = integer(transaction.amount, `Transaction ${transaction.id} amount`);
|
|
184
|
+
if (classification === 'expense')
|
|
185
|
+
entry.values.expenseOutflow += Math.abs(amount);
|
|
186
|
+
if (classification === 'credit')
|
|
187
|
+
entry.values.credits += amount;
|
|
188
|
+
if (classification === 'income')
|
|
189
|
+
entry.values.income += amount;
|
|
190
|
+
if (classification === 'uncategorized-inflow')
|
|
191
|
+
entry.values.uncategorizedInflows += amount;
|
|
192
|
+
if (classification === 'adjustment')
|
|
193
|
+
entry.values.adjustments += amount;
|
|
194
|
+
if (classification === 'transfer') {
|
|
195
|
+
if (amount >= 0)
|
|
196
|
+
entry.values.transferInflow += amount;
|
|
197
|
+
else
|
|
198
|
+
entry.values.transferOutflow += Math.abs(amount);
|
|
199
|
+
entry.values.transferCount += 1;
|
|
200
|
+
}
|
|
201
|
+
else {
|
|
202
|
+
entry.values.transactionCount += 1;
|
|
203
|
+
}
|
|
204
|
+
entry.values.netExpense = entry.values.expenseOutflow - entry.values.credits;
|
|
205
|
+
grouped.set(key, entry);
|
|
206
|
+
}
|
|
207
|
+
const groups = [...grouped.values()]
|
|
208
|
+
.sort((a, b) => JSON.stringify(a.dimensions).localeCompare(JSON.stringify(b.dimensions)))
|
|
209
|
+
.map(entry => ({ ...entry.dimensions, ...entry.values }));
|
|
210
|
+
const totals = zeroAggregate();
|
|
211
|
+
for (const entry of grouped.values())
|
|
212
|
+
addAggregate(totals, entry.values);
|
|
213
|
+
return { dateRange: { startDate: input.startDate, endDate: input.endDate }, groups, totals };
|
|
214
|
+
}
|
package/dist/src/lib/retry.js
CHANGED
|
@@ -5,9 +5,18 @@ const log = ModuleLoggers.RETRY;
|
|
|
5
5
|
* Error-message fragments that mark a TRANSIENT / infrastructure-level failure:
|
|
6
6
|
* the kind a retry can actually recover from, and the kind worth dropping a
|
|
7
7
|
* pooled connection over. Single source of truth for #177: the adapter's
|
|
8
|
-
* `_shouldDropPoolOnError`
|
|
8
|
+
* `_shouldDropPoolOnError` derives from `isRetryableError`, so the retry
|
|
9
9
|
* decision and the pool-drop decision cannot drift apart.
|
|
10
10
|
*
|
|
11
|
+
* #422 is the ONE deliberate divergence: a rate-limit ("too-many-requests") is
|
|
12
|
+
* transient and worth backing off, but it does NOT corrupt the connection, so
|
|
13
|
+
* `_shouldDropPoolOnError` subtracts it (`isRetryableError && !isRateLimitError`).
|
|
14
|
+
* Re-initing during a throttle adds a fresh login to an already-full window,
|
|
15
|
+
* which is itself rejected, cascading into a relogin storm (observed over stdio
|
|
16
|
+
* in #383). Retry stays unified with this list; only drop carves the one case
|
|
17
|
+
* out. That is #177's spirit (one authored pattern source) preserved, not a
|
|
18
|
+
* second divergent list.
|
|
19
|
+
*
|
|
11
20
|
* Anything NOT matching here (domain/validation errors such as "is required",
|
|
12
21
|
* "not found", "does not exist", Zod failures, and any unknown error) is
|
|
13
22
|
* terminal: it fails the same way on every attempt, so it must NOT be retried.
|
|
@@ -38,6 +47,34 @@ export function isRetryableError(err) {
|
|
|
38
47
|
const msg = err.message || '';
|
|
39
48
|
return TRANSIENT_ERROR_PATTERNS.some(p => msg.includes(p));
|
|
40
49
|
}
|
|
50
|
+
/**
|
|
51
|
+
* The rate-limit signal, in BOTH forms it reaches us (#422). `@actual-app/api`
|
|
52
|
+
* throws `Authentication failed: ${result.error}`, interpolating the server's
|
|
53
|
+
* raw string, so the exact shape is server-dependent:
|
|
54
|
+
* - the hyphenated code `too-many-requests` (referenced by the auth-retry
|
|
55
|
+
* comments, some Actual builds), and
|
|
56
|
+
* - the spaced express-rate-limit default `Too many requests, please try
|
|
57
|
+
* again later.` (what the E2E stack's server actually returns, #383).
|
|
58
|
+
* The `[\s-]?` arms match both; `rate[\s-]?limit` covers "rate limit" phrasing.
|
|
59
|
+
*
|
|
60
|
+
* Shared by `_shouldDropPoolOnError` (to NOT drop a throttled-but-live
|
|
61
|
+
* connection) and by `isRetryableAuthError` (to retry a login throttle). A
|
|
62
|
+
* non-Error rejection is not a rate-limit.
|
|
63
|
+
*
|
|
64
|
+
* Checks both `.message` AND `.code`: a login throttle carries it in the message
|
|
65
|
+
* (`Authentication failed: Too many requests...`), but a SYNC throttle surfaces
|
|
66
|
+
* as `Error("We had an unknown problem opening ...")` with the rate-limit only in
|
|
67
|
+
* `.code` (observed in the #383 write path). Matching just the message would miss
|
|
68
|
+
* that shape.
|
|
69
|
+
*/
|
|
70
|
+
export const RATE_LIMIT_PATTERN = /too[\s-]?many[\s-]?requests|rate[\s-]?limit/i;
|
|
71
|
+
export function isRateLimitError(err) {
|
|
72
|
+
if (!(err instanceof Error))
|
|
73
|
+
return false;
|
|
74
|
+
const code = err.code;
|
|
75
|
+
const codeStr = typeof code === 'string' ? code : '';
|
|
76
|
+
return RATE_LIMIT_PATTERN.test(err.message || '') || RATE_LIMIT_PATTERN.test(codeStr);
|
|
77
|
+
}
|
|
41
78
|
export async function retry(fn, opts) {
|
|
42
79
|
const retries = opts?.retries ?? DEFAULT_RETRY_ATTEMPTS;
|
|
43
80
|
const backoffMs = opts?.backoffMs ?? DEFAULT_RETRY_BACKOFF_MS;
|
package/dist/src/tools/index.js
CHANGED
|
@@ -49,6 +49,7 @@ export { default as schedules_delete } from './schedules_delete.js';
|
|
|
49
49
|
export { default as schedules_get } from './schedules_get.js';
|
|
50
50
|
export { default as schedules_update } from './schedules_update.js';
|
|
51
51
|
export { default as server_info } from './server_info.js';
|
|
52
|
+
export { default as transactions_aggregate } from './transactions_aggregate.js';
|
|
52
53
|
export { default as transactions_create } from './transactions_create.js';
|
|
53
54
|
export { default as transactions_delete } from './transactions_delete.js';
|
|
54
55
|
export { default as transactions_filter } from './transactions_filter.js';
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
// #424: deterministic, read-only aggregation of posted transactions.
|
|
2
|
+
//
|
|
3
|
+
// Original implementation by @maxvanweenen (PR #399), requested in #398. The accounting lives in
|
|
4
|
+
// src/lib/financial-analysis.ts; this file is the tool surface plus the #388 filter-id resolution.
|
|
5
|
+
import { z } from 'zod';
|
|
6
|
+
import { createTool } from '../lib/toolFactory.js';
|
|
7
|
+
import { CommonSchemas } from '../lib/schemas/common.js';
|
|
8
|
+
import adapter from '../lib/actual-adapter.js';
|
|
9
|
+
import { aggregateTransactions, } from '../lib/financial-analysis.js';
|
|
10
|
+
const GroupDimensionSchema = z.enum(['month', 'category', 'category_group', 'payee', 'account']);
|
|
11
|
+
// The optional id filters are BARE strings, not CommonSchemas ids, on purpose (#388): a NAME is
|
|
12
|
+
// resolved to its id by adapter.resolveFilterId rather than rejected with a bare ZodError, so the
|
|
13
|
+
// caller learns the id. Tightening these to CommonSchemas would delete that accommodation.
|
|
14
|
+
const IdArray = z.array(z.string().min(1)).min(1).max(100);
|
|
15
|
+
const InputSchema = z.object({
|
|
16
|
+
startDate: CommonSchemas.date,
|
|
17
|
+
endDate: CommonSchemas.date,
|
|
18
|
+
groupBy: z.union([GroupDimensionSchema, z.array(GroupDimensionSchema).min(1).max(5)]),
|
|
19
|
+
accountIds: IdArray.optional(),
|
|
20
|
+
categoryIds: IdArray.optional(),
|
|
21
|
+
categoryGroupIds: IdArray.optional(),
|
|
22
|
+
payeeIds: IdArray.optional(),
|
|
23
|
+
includeIncome: z.boolean().optional().default(false),
|
|
24
|
+
excludeTransfers: z.boolean().optional().default(true),
|
|
25
|
+
}).strict().superRefine((input, ctx) => {
|
|
26
|
+
if (input.startDate > input.endDate) {
|
|
27
|
+
ctx.addIssue({ code: 'custom', path: ['endDate'], message: 'endDate must be on or after startDate' });
|
|
28
|
+
}
|
|
29
|
+
});
|
|
30
|
+
// Resolve every element of an optional id filter through the #388 chokepoint, reusing the listing
|
|
31
|
+
// the snapshot already fetched (so no extra listing read per element, no N+1). A well-formed known
|
|
32
|
+
// id passes through unchanged; a NAME throws a refusal carrying the resolved id; an unknown
|
|
33
|
+
// well-formed id throws NotFoundRefusal.
|
|
34
|
+
async function resolveIds(ids, kind, rows) {
|
|
35
|
+
if (!ids)
|
|
36
|
+
return undefined;
|
|
37
|
+
const resolved = [];
|
|
38
|
+
for (const id of ids) {
|
|
39
|
+
resolved.push(await adapter.resolveFilterId(kind, id, { verifyExists: true, rows }));
|
|
40
|
+
}
|
|
41
|
+
return resolved;
|
|
42
|
+
}
|
|
43
|
+
export default createTool({
|
|
44
|
+
name: 'actual_transactions_aggregate',
|
|
45
|
+
description: [
|
|
46
|
+
'Deterministically aggregate posted transactions over an inclusive date range using integer cents.',
|
|
47
|
+
'Split parents are excluded and split children are counted once.',
|
|
48
|
+
'Transfers are identified only by transfer_id and never count as spending or income.',
|
|
49
|
+
'Positive non-income-category transactions are credits; positive uncategorized transactions are reported separately as uncategorizedInflows.',
|
|
50
|
+
'groupBy accepts one dimension or an array of month, category, category_group, payee, and account.',
|
|
51
|
+
'includeIncome defaults to false. excludeTransfers defaults to true; when false, transfers stay in separate transferInflow and transferOutflow fields.',
|
|
52
|
+
'Amounts are in cents: $100.00 = 10000. Dates are YYYY-MM-DD.',
|
|
53
|
+
].join(' '),
|
|
54
|
+
schema: InputSchema,
|
|
55
|
+
handler: async (input) => {
|
|
56
|
+
const snapshot = await adapter.getFinancialAnalysisSnapshot({
|
|
57
|
+
startDate: input.startDate,
|
|
58
|
+
endDate: input.endDate,
|
|
59
|
+
});
|
|
60
|
+
const accountIds = await resolveIds(input.accountIds, 'account', snapshot.accounts);
|
|
61
|
+
const categoryIds = await resolveIds(input.categoryIds, 'category', snapshot.categories);
|
|
62
|
+
const categoryGroupIds = await resolveIds(input.categoryGroupIds, 'category_group', snapshot.categoryGroups);
|
|
63
|
+
const payeeIds = await resolveIds(input.payeeIds, 'payee', snapshot.payees);
|
|
64
|
+
const groupBy = (Array.isArray(input.groupBy) ? [...new Set(input.groupBy)] : [input.groupBy]);
|
|
65
|
+
return aggregateTransactions(snapshot, {
|
|
66
|
+
startDate: input.startDate,
|
|
67
|
+
endDate: input.endDate,
|
|
68
|
+
groupBy,
|
|
69
|
+
accountIds,
|
|
70
|
+
categoryIds,
|
|
71
|
+
categoryGroupIds,
|
|
72
|
+
payeeIds,
|
|
73
|
+
includeIncome: input.includeIncome,
|
|
74
|
+
excludeTransfers: input.excludeTransfers,
|
|
75
|
+
});
|
|
76
|
+
},
|
|
77
|
+
examples: [
|
|
78
|
+
{
|
|
79
|
+
description: 'Total spending per category for a month, transfers excluded',
|
|
80
|
+
input: { startDate: '2025-01-01', endDate: '2025-01-31', groupBy: 'category', includeIncome: false, excludeTransfers: true },
|
|
81
|
+
},
|
|
82
|
+
{
|
|
83
|
+
description: 'Spending and income per month, transfers kept separate',
|
|
84
|
+
input: { startDate: '2025-01-01', endDate: '2025-03-31', groupBy: 'month', includeIncome: true, excludeTransfers: false },
|
|
85
|
+
},
|
|
86
|
+
],
|
|
87
|
+
});
|
package/package.json
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "actual-mcp-server",
|
|
3
3
|
"displayName": "Actual MCP Server",
|
|
4
|
-
"version": "0.
|
|
4
|
+
"version": "0.17.0",
|
|
5
5
|
"engines": {
|
|
6
6
|
"node": ">=22.0.0",
|
|
7
7
|
"npm": ">=10.0.0"
|
|
8
8
|
},
|
|
9
|
-
"description": "MCP server with
|
|
9
|
+
"description": "MCP server with 75 tools for AI-driven financial management with Actual Budget. HTTP and stdio transports for LibreChat, Claude Desktop, Cursor, VS Code, Gemini CLI",
|
|
10
10
|
"homepage": "https://github.com/agigante80/actual-mcp-server#readme",
|
|
11
11
|
"repository": {
|
|
12
12
|
"type": "git",
|
|
@@ -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 &&
|
|
33
|
+
"test:unit-js": "node tests/unit/transactions_create.test.js && node tests/unit/transactions_aggregate.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/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/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/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/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/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/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/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/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",
|
|
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",
|