actual-mcp-server 0.19.4 → 0.21.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 +14 -10
- package/dist/src/actualToolsManager.js +4 -0
- package/dist/src/lib/actual-adapter/query.js +36 -3
- package/dist/src/lib/actual-adapter.js +64 -1
- package/dist/src/lib/actual-schema.js +13 -0
- package/dist/src/lib/installed-api-version.js +17 -2
- package/dist/src/lib/schemas/common.js +10 -1
- package/dist/src/lib/tool-annotations.js +4 -0
- package/dist/src/server/httpServer.js +54 -25
- package/dist/src/tools/account_groups_create.js +26 -0
- package/dist/src/tools/account_groups_delete.js +23 -0
- package/dist/src/tools/account_groups_list.js +23 -0
- package/dist/src/tools/account_groups_update.js +25 -0
- package/dist/src/tools/index.js +4 -0
- package/dist/src/tools/server_info.js +17 -0
- package/package.json +6 -6
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) │ │ (81 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) │ │ (81 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
|
+
- **81 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
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 81 tools tested end-to-end. Any MCP-compatible client should work.
|
|
49
49
|
|
|
50
50
|
---
|
|
51
51
|
|
|
@@ -182,7 +182,7 @@ Add to `claude_desktop_config.json` (see [docs/guides/MCP_CLIENTS_SETUP.md](docs
|
|
|
182
182
|
}
|
|
183
183
|
```
|
|
184
184
|
|
|
185
|
-
> **No token needed.** stdio runs as a local process owned by your user. The transport itself is the security boundary. All
|
|
185
|
+
> **No token needed.** stdio runs as a local process owned by your user. The transport itself is the security boundary. All 81 tools are available.
|
|
186
186
|
>
|
|
187
187
|
> **`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.
|
|
188
188
|
|
|
@@ -308,9 +308,9 @@ For Claude Desktop (stdio), restart Claude after upgrading.
|
|
|
308
308
|
|
|
309
309
|
## Available Tools
|
|
310
310
|
|
|
311
|
-
**
|
|
311
|
+
**81 tools** across all categories. All tools use the `actual_<category>_<action>` naming convention.
|
|
312
312
|
|
|
313
|
-
### Accounts (
|
|
313
|
+
### Accounts (12)
|
|
314
314
|
|
|
315
315
|
| Tool | Description |
|
|
316
316
|
|------|-------------|
|
|
@@ -322,6 +322,10 @@ For Claude Desktop (stdio), restart Claude after upgrading.
|
|
|
322
322
|
| `actual_accounts_reopen` | Reopen closed account. An id that is not an account is refused rather than creating one |
|
|
323
323
|
| `actual_accounts_get_balance` | Get account balance at a date |
|
|
324
324
|
| `actual_account_flow_summary` | Explain the exact balance change across a set of accounts over a date range: external income, expense outflow, credits, uncategorized inflows, and transfers into, out of, or within the selection, with an exact reconciliation |
|
|
325
|
+
| `actual_account_groups_list` | List the custom account groups used to organise accounts in the sidebar (Actual 26.9.0+) |
|
|
326
|
+
| `actual_account_groups_create` | Create an account group. Assigning accounts to it is a separate `actual_accounts_update` call |
|
|
327
|
+
| `actual_account_groups_update` | Rename an account group or change its sidebar position. A group that does not exist is refused, writing nothing |
|
|
328
|
+
| `actual_account_groups_delete` | Delete an account group. Its accounts are NOT deleted, they become ungrouped |
|
|
325
329
|
|
|
326
330
|
### Transactions (16)
|
|
327
331
|
|
|
@@ -594,7 +598,7 @@ stdio is the simplest way to connect Claude Desktop directly to Actual Budget. T
|
|
|
594
598
|
- No auth token. Process ownership is the security boundary.
|
|
595
599
|
- All logs go to stderr so they never corrupt the JSON-RPC framing on stdout
|
|
596
600
|
- The process exits when stdin closes (Claude Desktop shutting down)
|
|
597
|
-
- All
|
|
601
|
+
- All 81 tools are available, identical to HTTP mode
|
|
598
602
|
|
|
599
603
|
**Start manually to verify:**
|
|
600
604
|
|
|
@@ -693,7 +697,7 @@ See [AI Client Setup, OIDC](docs/guides/AI_CLIENT_SETUP.md#oidc-authentication-m
|
|
|
693
697
|
| Command | What It Tests | Requires Live Server |
|
|
694
698
|
|---------|---------------|---------------------|
|
|
695
699
|
| `npm run build` | TypeScript compilation | No |
|
|
696
|
-
| `npm run test:unit-js` |
|
|
700
|
+
| `npm run test:unit-js` | 81-tool smoke, schema validation, auth ACL | No |
|
|
697
701
|
| `npm run test:adapter` | Adapter, retry logic, concurrency | No |
|
|
698
702
|
| `npm run test:e2e` | MCP protocol compliance (Playwright) | No |
|
|
699
703
|
| `npm run test:e2e:docker:full` | Full stack integration | Yes (Docker) |
|
|
@@ -777,7 +781,7 @@ The software is provided **as-is**, without warranty of any kind. The author acc
|
|
|
777
781
|
|
|
778
782
|
---
|
|
779
783
|
|
|
780
|
-
**Version:** 0.
|
|
784
|
+
**Version:** 0.21.0 | **Tool Count:** 81 (verified LibreChat-compatible)
|
|
781
785
|
|
|
782
786
|
## Sponsor
|
|
783
787
|
|
|
@@ -10,6 +10,10 @@ const IMPLEMENTED_TOOLS = [
|
|
|
10
10
|
'actual_accounts_create',
|
|
11
11
|
'actual_accounts_delete',
|
|
12
12
|
'actual_accounts_get_balance',
|
|
13
|
+
'actual_account_groups_list',
|
|
14
|
+
'actual_account_groups_create',
|
|
15
|
+
'actual_account_groups_update',
|
|
16
|
+
'actual_account_groups_delete',
|
|
13
17
|
'actual_accounts_list',
|
|
14
18
|
'actual_accounts_reopen',
|
|
15
19
|
'actual_accounts_update',
|
|
@@ -45,13 +45,44 @@ function _splitInList(valuesStr) {
|
|
|
45
45
|
// a double-quoted element hiding a single quote (`"x' UNION SELECT 1 --"`) is well formed by (1)
|
|
46
46
|
// yet its inner `'` would terminate upstream's wrapper. Checking the stripped value closes that.
|
|
47
47
|
// A legitimate `'groceries'`, `'Smith, John'`, `"cash"`, `100` or `-5.5` passes both.
|
|
48
|
+
/** A single-quoted literal whose internal quotes are SQL-ESCAPED by doubling.
|
|
49
|
+
* `'McDonald''s'` matches; `'McDonald's'` (a lone quote) does not. */
|
|
50
|
+
const SQL_ESCAPED_SINGLE_QUOTED = /^'(?:[^']|'')*'$/;
|
|
48
51
|
function _isSafeInListElement(raw) {
|
|
49
52
|
const t = raw.trim();
|
|
50
|
-
|
|
53
|
+
// #433: a single-quoted element may now carry DOUBLED internal quotes. That is
|
|
54
|
+
// safe for the reason #421 made the original rule, read the other way round:
|
|
55
|
+
// upstream compiles $oneof by inlining each value verbatim as `'${String(id)}'`
|
|
56
|
+
// (aql/compiler.ts, verified against the installed source), so a LONE quote
|
|
57
|
+
// terminates that wrapper and smuggles trailing SQL, while a DOUBLED quote is
|
|
58
|
+
// exactly the escape SQL defines and produces a balanced literal.
|
|
59
|
+
if (SQL_ESCAPED_SINGLE_QUOTED.test(t))
|
|
60
|
+
return true;
|
|
61
|
+
const wellFormed = /^"[^"]*"$/.test(t) || /^-?\d+(?:\.\d+)?$/.test(t);
|
|
51
62
|
if (!wellFormed)
|
|
52
63
|
return false;
|
|
64
|
+
// A double-quoted element must still carry no single quote at all: upstream
|
|
65
|
+
// re-wraps it in SINGLE quotes, so an internal one would break out exactly as
|
|
66
|
+
// before. This is the #421 injection witness shape.
|
|
53
67
|
return !_stripWhereQuotes(t).includes("'");
|
|
54
68
|
}
|
|
69
|
+
/** Coercion for IN-list elements ONLY.
|
|
70
|
+
*
|
|
71
|
+
* Deliberately separate from `_coerceWhereValue`, which serves `=`, `!=`, `<`
|
|
72
|
+
* and friends. Those operands travel a different compile path, and this doubling
|
|
73
|
+
* is correct ONLY because $oneof inlines its values into SQL text verbatim. A
|
|
74
|
+
* shared coercion would apply SQL escaping to an operand that may never reach
|
|
75
|
+
* raw SQL, which would silently match nothing.
|
|
76
|
+
*
|
|
77
|
+
* The doubling is PRESERVED rather than unescaped, so upstream's `'${id}'` wrap
|
|
78
|
+
* yields `'McDonald''s'`, which SQLite reads as `McDonald's`. */
|
|
79
|
+
function _coerceInListValue(s) {
|
|
80
|
+
const t = s.trim();
|
|
81
|
+
if (SQL_ESCAPED_SINGLE_QUOTED.test(t) && t.includes("''")) {
|
|
82
|
+
return t.slice(1, -1); // keep the doubling; upstream supplies the outer quotes
|
|
83
|
+
}
|
|
84
|
+
return _coerceWhereValue(s);
|
|
85
|
+
}
|
|
55
86
|
// Coerce a SQL value literal to a number when it looks numeric, else keep the
|
|
56
87
|
// (unquoted) string. Used for IN lists and comparison operands. Empty stays a
|
|
57
88
|
// string so an empty literal is not silently turned into 0.
|
|
@@ -162,10 +193,12 @@ export function parseWhereClause(query, whereClause, tableName) {
|
|
|
162
193
|
for (const raw of rawValues) {
|
|
163
194
|
if (!_isSafeInListElement(raw)) {
|
|
164
195
|
throw new Error(`Invalid value in IN list for column "${field}": ${JSON.stringify(raw.trim())}. ` +
|
|
165
|
-
`Each value must be a number
|
|
196
|
+
`Each value must be a number, a double-quoted string with no single quote, ` +
|
|
197
|
+
`or a single-quoted string whose internal quotes are SQL-escaped by doubling ` +
|
|
198
|
+
`(for example 'McDonald''s').`);
|
|
166
199
|
}
|
|
167
200
|
}
|
|
168
|
-
query = query.filter({ [field]: { $oneof: rawValues.map(
|
|
201
|
+
query = query.filter({ [field]: { $oneof: rawValues.map(_coerceInListValue) } });
|
|
169
202
|
}
|
|
170
203
|
continue;
|
|
171
204
|
}
|
|
@@ -5,7 +5,7 @@ import api from '@actual-app/api';
|
|
|
5
5
|
// cannot expose its named exports via static import syntax. At runtime the default import
|
|
6
6
|
// IS module.exports, so all methods are accessible as properties.
|
|
7
7
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
8
|
-
const { addTransactions: rawAddTransactions, getAccounts: rawGetAccounts, importTransactions: rawImportTransactions, getTransactions: rawGetTransactions, getCategories: rawGetCategories, createCategory: rawCreateCategory, getPayees: rawGetPayees, getCommonPayees: rawGetCommonPayees, createPayee: rawCreatePayee, getBudgetMonths: rawGetBudgetMonths, getBudgetMonth: rawGetBudgetMonth, setBudgetAmount: rawSetBudgetAmount, createAccount: rawCreateAccount, updateAccount: rawUpdateAccount, getAccountBalance: rawGetAccountBalance, updateTransaction: rawUpdateTransaction, deleteTransaction: rawDeleteTransaction, updateCategory: rawUpdateCategory, deleteCategory: rawDeleteCategory, updatePayee: rawUpdatePayee, deletePayee: rawDeletePayee, deleteAccount: rawDeleteAccount, getRules: rawGetRules, createRule: rawCreateRule, updateRule: rawUpdateRule, deleteRule: rawDeleteRule, setBudgetCarryover: rawSetBudgetCarryover, closeAccount: rawCloseAccount, reopenAccount: rawReopenAccount, getCategoryGroups: rawGetCategoryGroups, createCategoryGroup: rawCreateCategoryGroup, updateCategoryGroup: rawUpdateCategoryGroup, deleteCategoryGroup: rawDeleteCategoryGroup, mergePayees: rawMergePayees, getPayeeRules: rawGetPayeeRules, batchBudgetUpdates: rawBatchBudgetUpdates, holdBudgetForNextMonth: rawHoldBudgetForNextMonth, resetBudgetHold: rawResetBudgetHold, runQuery: rawRunQuery, runBankSync: rawRunBankSync, getBudgets: rawGetBudgets, getIDByName: rawGetIDByName, getServerVersion: rawGetServerVersion, getSchedules: rawGetSchedules, createSchedule: rawCreateSchedule, updateSchedule: rawUpdateSchedule, deleteSchedule: rawDeleteSchedule, getTags: rawGetTags, createTag: rawCreateTag, updateTag: rawUpdateTag, deleteTag: rawDeleteTag, getNote: rawGetNote, updateNote: rawUpdateNote, exportBudget: rawExportBudget, importBudget: rawImportBudget, getPreferences: rawGetPreferences,
|
|
8
|
+
const { addTransactions: rawAddTransactions, getAccounts: rawGetAccounts, importTransactions: rawImportTransactions, getTransactions: rawGetTransactions, getCategories: rawGetCategories, createCategory: rawCreateCategory, getPayees: rawGetPayees, getCommonPayees: rawGetCommonPayees, createPayee: rawCreatePayee, getBudgetMonths: rawGetBudgetMonths, getBudgetMonth: rawGetBudgetMonth, setBudgetAmount: rawSetBudgetAmount, createAccount: rawCreateAccount, updateAccount: rawUpdateAccount, getAccountBalance: rawGetAccountBalance, updateTransaction: rawUpdateTransaction, deleteTransaction: rawDeleteTransaction, updateCategory: rawUpdateCategory, deleteCategory: rawDeleteCategory, updatePayee: rawUpdatePayee, deletePayee: rawDeletePayee, deleteAccount: rawDeleteAccount, getRules: rawGetRules, createRule: rawCreateRule, updateRule: rawUpdateRule, deleteRule: rawDeleteRule, setBudgetCarryover: rawSetBudgetCarryover, closeAccount: rawCloseAccount, reopenAccount: rawReopenAccount, getCategoryGroups: rawGetCategoryGroups, createCategoryGroup: rawCreateCategoryGroup, updateCategoryGroup: rawUpdateCategoryGroup, deleteCategoryGroup: rawDeleteCategoryGroup, mergePayees: rawMergePayees, getPayeeRules: rawGetPayeeRules, batchBudgetUpdates: rawBatchBudgetUpdates, holdBudgetForNextMonth: rawHoldBudgetForNextMonth, resetBudgetHold: rawResetBudgetHold, runQuery: rawRunQuery, runBankSync: rawRunBankSync, getBudgets: rawGetBudgets, getIDByName: rawGetIDByName, getServerVersion: rawGetServerVersion, getSchedules: rawGetSchedules, createSchedule: rawCreateSchedule, updateSchedule: rawUpdateSchedule, deleteSchedule: rawDeleteSchedule, getTags: rawGetTags, createTag: rawCreateTag, updateTag: rawUpdateTag, deleteTag: rawDeleteTag, getNote: rawGetNote, updateNote: rawUpdateNote, exportBudget: rawExportBudget, importBudget: rawImportBudget, getPreferences: rawGetPreferences, getAccountGroups: rawGetAccountGroups, createAccountGroup: rawCreateAccountGroup, updateAccountGroup: rawUpdateAccountGroup, deleteAccountGroup: rawDeleteAccountGroup,
|
|
9
9
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
10
10
|
} = api;
|
|
11
11
|
import { EventEmitter } from 'events';
|
|
@@ -3200,6 +3200,65 @@ export async function getServerVersion() {
|
|
|
3200
3200
|
});
|
|
3201
3201
|
}
|
|
3202
3202
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
3203
|
+
/**
|
|
3204
|
+
* Account groups (#429). Upstream added these in 26.9.0; the schema arrives through a
|
|
3205
|
+
* migration SHIPPED IN `@actual-app/api` and applied to the LOCAL budget file, so these
|
|
3206
|
+
* work against an older sync server too: the server relays messages and does not execute
|
|
3207
|
+
* the queries.
|
|
3208
|
+
*
|
|
3209
|
+
* One semantic worth carrying into the tool descriptions, taken from upstream's own
|
|
3210
|
+
* comment on the delete path: clearing member refs is best effort under CRDT sync, so a
|
|
3211
|
+
* concurrent assignment on another device can win against those nulls. Consumers must
|
|
3212
|
+
* treat an account whose `account_group_id` points at a missing or tombstoned group as
|
|
3213
|
+
* UNGROUPED rather than as an error.
|
|
3214
|
+
*/
|
|
3215
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
3216
|
+
export async function getAccountGroups() {
|
|
3217
|
+
return withActualApi(async () => {
|
|
3218
|
+
observability.incrementToolCall('actual.account_groups.list').catch(() => { });
|
|
3219
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
3220
|
+
return await withConcurrency(() => retry(() => rawGetAccountGroups(), { retries: 2, backoffMs: 200 }));
|
|
3221
|
+
});
|
|
3222
|
+
}
|
|
3223
|
+
export async function createAccountGroup(group) {
|
|
3224
|
+
observability.incrementToolCall('actual.account_groups.create').catch(() => { });
|
|
3225
|
+
return queueWriteOperation(async () => {
|
|
3226
|
+
const raw = await withConcurrency(() => retry(() => rawCreateAccountGroup(group), { retries: 2, backoffMs: 200, isRetryable: isRetryableError }));
|
|
3227
|
+
return normalizeToId(raw);
|
|
3228
|
+
// Lands in account_groups only, so the four entity listings are untouched.
|
|
3229
|
+
}, { preservesListings: PRESERVES_ALL_ENTITY_LISTINGS });
|
|
3230
|
+
}
|
|
3231
|
+
export async function updateAccountGroup(id, fields) {
|
|
3232
|
+
observability.incrementToolCall('actual.account_groups.update').catch(() => { });
|
|
3233
|
+
return queueWriteOperation(async () => {
|
|
3234
|
+
// Read, decide and write inside ONE queued operation, so the existence check and the
|
|
3235
|
+
// write are a single api-lock cycle and cannot be interleaved by a sibling (#371/#378).
|
|
3236
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
3237
|
+
const groups = await withConcurrency(() => retry(() => rawGetAccountGroups(), { retries: 2, backoffMs: 200 }));
|
|
3238
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
3239
|
+
if (!groups.some((g) => g.id === id)) {
|
|
3240
|
+
throw new NotFoundRefusal('Account group', id, 'actual_account_groups_list');
|
|
3241
|
+
}
|
|
3242
|
+
await withConcurrency(() => retry(() => rawUpdateAccountGroup(id, fields), { retries: 0, backoffMs: 200, isRetryable: isRetryableError }));
|
|
3243
|
+
// Renames the group row itself; no account row changes, so listings are preserved.
|
|
3244
|
+
}, { preservesListings: PRESERVES_ALL_ENTITY_LISTINGS });
|
|
3245
|
+
}
|
|
3246
|
+
export async function deleteAccountGroup(id) {
|
|
3247
|
+
observability.incrementToolCall('actual.account_groups.delete').catch(() => { });
|
|
3248
|
+
return queueWriteOperation(async () => {
|
|
3249
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
3250
|
+
const groups = await withConcurrency(() => retry(() => rawGetAccountGroups(), { retries: 2, backoffMs: 200 }));
|
|
3251
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
3252
|
+
if (!groups.some((g) => g.id === id)) {
|
|
3253
|
+
throw new NotFoundRefusal('Account group', id, 'actual_account_groups_list');
|
|
3254
|
+
}
|
|
3255
|
+
await withConcurrency(() => retry(() => rawDeleteAccountGroup(id), { retries: 0, backoffMs: 200, isRetryable: isRetryableError }));
|
|
3256
|
+
// NO preservesListings claim, deliberately: upstream's delete UPDATES every member
|
|
3257
|
+
// account to null its account_group_id before removing the group, so the accounts
|
|
3258
|
+
// listing CONTENT changes even though its id set does not. Claiming preservation here
|
|
3259
|
+
// would serve a stale accounts listing to the next guard in the same drain.
|
|
3260
|
+
});
|
|
3261
|
+
}
|
|
3203
3262
|
export async function getTags() {
|
|
3204
3263
|
return withActualApi(async () => {
|
|
3205
3264
|
observability.incrementToolCall('actual.tags.get').catch(() => { });
|
|
@@ -3421,6 +3480,10 @@ export default {
|
|
|
3421
3480
|
getPayees,
|
|
3422
3481
|
getCommonPayees,
|
|
3423
3482
|
createPayee,
|
|
3483
|
+
getAccountGroups,
|
|
3484
|
+
createAccountGroup,
|
|
3485
|
+
updateAccountGroup,
|
|
3486
|
+
deleteAccountGroup,
|
|
3424
3487
|
getTags,
|
|
3425
3488
|
createTag,
|
|
3426
3489
|
updateTag,
|
|
@@ -52,6 +52,19 @@ export const ACTUAL_SCHEMA = {
|
|
|
52
52
|
mask: { type: 'string' },
|
|
53
53
|
bank: { type: 'id', ref: 'banks' },
|
|
54
54
|
account_sync_source: { type: 'string' },
|
|
55
|
+
// #429: these four exist in upstream's ActualQL schema but were missing here, so
|
|
56
|
+
// actual_query_run rejected valid queries against them. account_group_id arrived with
|
|
57
|
+
// account groups in 26.9.0; the other three predate it and were simply never added.
|
|
58
|
+
account_group_id: { type: 'id', ref: 'account_groups' },
|
|
59
|
+
last_reconciled: { type: 'string' },
|
|
60
|
+
last_sync: { type: 'string' },
|
|
61
|
+
bank_sync_status: { type: 'string' },
|
|
62
|
+
},
|
|
63
|
+
account_groups: {
|
|
64
|
+
id: { type: 'id' },
|
|
65
|
+
name: { type: 'string', required: true },
|
|
66
|
+
sort_order: { type: 'float' },
|
|
67
|
+
tombstone: { type: 'boolean' },
|
|
55
68
|
},
|
|
56
69
|
categories: {
|
|
57
70
|
id: { type: 'id' },
|
|
@@ -53,15 +53,30 @@ export function readVersionFromTree(startDir, pkgName = PACKAGE) {
|
|
|
53
53
|
}
|
|
54
54
|
return null;
|
|
55
55
|
}
|
|
56
|
-
|
|
56
|
+
/** Resolve any installed package's real version, or null. Same contract as the
|
|
57
|
+
* constant below: SILENT, never throws, and null is a normal state. #445 made
|
|
58
|
+
* this take a name so `actual_server_info` can report the resolved MCP SDK
|
|
59
|
+
* version too without duplicating the walk. */
|
|
60
|
+
export function resolveInstalledVersion(pkgName, entry = pkgName) {
|
|
57
61
|
try {
|
|
58
62
|
const require = createRequire(import.meta.url);
|
|
59
|
-
|
|
63
|
+
// `entry` exists because a bare package name is NOT always resolvable. The MCP
|
|
64
|
+
// SDK exports no root path, so require.resolve('@modelcontextprotocol/sdk')
|
|
65
|
+
// throws MODULE_NOT_FOUND while '@modelcontextprotocol/sdk/server/index.js'
|
|
66
|
+
// resolves fine. That is the same class of trap as @actual-app/api not
|
|
67
|
+
// exporting its own package.json, and it matters more than it looks: with the
|
|
68
|
+
// omit-on-failure contract, an unresolvable name looks exactly like a package
|
|
69
|
+
// that is legitimately absent, so the caller passes a subpath it knows exists
|
|
70
|
+
// rather than letting the field silently disappear.
|
|
71
|
+
return readVersionFromTree(dirname(require.resolve(entry)), pkgName);
|
|
60
72
|
}
|
|
61
73
|
catch {
|
|
62
74
|
return null;
|
|
63
75
|
}
|
|
64
76
|
}
|
|
77
|
+
function resolveInstalledApiVersion() {
|
|
78
|
+
return resolveInstalledVersion(PACKAGE);
|
|
79
|
+
}
|
|
65
80
|
/** The installed version, or null when it cannot be determined. Resolved ONCE at
|
|
66
81
|
* module load. Null is a normal state, not an error: every consumer must stay
|
|
67
82
|
* silent on it rather than reporting a sentinel. */
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
*
|
|
4
4
|
* Common validation schemas used across MCP tools for Actual Budget.
|
|
5
5
|
* These schemas provide consistent validation, better error messages,
|
|
6
|
-
* and reduce duplication across the
|
|
6
|
+
* and reduce duplication across the 81 tool definitions.
|
|
7
7
|
*/
|
|
8
8
|
import { z } from 'zod';
|
|
9
9
|
import { MAX_NAME_LENGTH, MAX_NOTES_LENGTH, DATE_PATTERN, MONTH_PATTERN, UUID_PATTERN } from '../constants.js';
|
|
@@ -62,6 +62,14 @@ export const ruleIdSchema = z
|
|
|
62
62
|
* Tag UUID validation
|
|
63
63
|
* Used for: tag management operations (update, delete)
|
|
64
64
|
*/
|
|
65
|
+
/**
|
|
66
|
+
* Account group UUID validation (#429)
|
|
67
|
+
* Used for: account group management operations (update, delete)
|
|
68
|
+
*/
|
|
69
|
+
export const accountGroupIdSchema = z
|
|
70
|
+
.string()
|
|
71
|
+
.regex(UUID_PATTERN, 'Invalid account group ID format (expected UUID)')
|
|
72
|
+
.describe('Account group UUID');
|
|
65
73
|
export const tagIdSchema = z
|
|
66
74
|
.string()
|
|
67
75
|
.regex(UUID_PATTERN, 'Invalid tag ID format (expected UUID)')
|
|
@@ -225,6 +233,7 @@ export const CommonSchemas = {
|
|
|
225
233
|
categoryGroupId: categoryGroupIdSchema,
|
|
226
234
|
payeeId: payeeIdSchema,
|
|
227
235
|
ruleId: ruleIdSchema,
|
|
236
|
+
accountGroupId: accountGroupIdSchema,
|
|
228
237
|
tagId: tagIdSchema,
|
|
229
238
|
scheduleId: scheduleIdSchema,
|
|
230
239
|
// Dates
|
|
@@ -55,6 +55,7 @@ const OPEN_WORLD = new Set(['actual_bank_sync']);
|
|
|
55
55
|
* actual_session_close closes a pooled connection: server state, not budget data.
|
|
56
56
|
*/
|
|
57
57
|
const READ_ONLY = new Set([
|
|
58
|
+
'actual_account_groups_list',
|
|
58
59
|
'actual_account_flow_summary',
|
|
59
60
|
'actual_recurring_expenses_summary',
|
|
60
61
|
'actual_accounts_get_balance',
|
|
@@ -99,6 +100,7 @@ const READ_ONLY = new Set([
|
|
|
99
100
|
* and in #357), so a close can be a delete.
|
|
100
101
|
*/
|
|
101
102
|
const DESTRUCTIVE = new Set([
|
|
103
|
+
'actual_account_groups_delete',
|
|
102
104
|
'actual_accounts_close',
|
|
103
105
|
'actual_accounts_delete',
|
|
104
106
|
// Upstream `reconcileTransactions` overwrites fields on transactions that ALREADY exist
|
|
@@ -151,6 +153,8 @@ const DESTRUCTIVE = new Set([
|
|
|
151
153
|
* not, because on the split path it deletes and recreates child rows.
|
|
152
154
|
*/
|
|
153
155
|
const ADDITIVE = new Set([
|
|
156
|
+
'actual_account_groups_create',
|
|
157
|
+
'actual_account_groups_update',
|
|
154
158
|
'actual_accounts_create',
|
|
155
159
|
'actual_accounts_reopen',
|
|
156
160
|
'actual_accounts_update',
|
|
@@ -703,16 +703,43 @@ advertisedUrl) {
|
|
|
703
703
|
if (e2 && typeof e2.stack === 'string')
|
|
704
704
|
logger.error(e2.stack);
|
|
705
705
|
if (!res.headersSent) {
|
|
706
|
-
//
|
|
707
|
-
//
|
|
708
|
-
//
|
|
709
|
-
//
|
|
710
|
-
|
|
706
|
+
// #446: the client gets a STABLE message plus a correlation id, never the
|
|
707
|
+
// raw error. `String(err)` here could carry a stack, raw SQL from
|
|
708
|
+
// SyncError.meta.query, an EACCES path with the OS username, or the
|
|
709
|
+
// configured upstream URL. The detail is already logged above with the
|
|
710
|
+
// request id stamped by requestContext (#221), so an operator joins the two
|
|
711
|
+
// by that id instead of reading it off the wire.
|
|
712
|
+
//
|
|
713
|
+
// The id is best effort: this catch can fire OUTSIDE requestContext.run
|
|
714
|
+
// (an error thrown while establishing the context), in which case there is
|
|
715
|
+
// nothing to correlate and the field is omitted rather than invented.
|
|
716
|
+
const requestId = requestContext.getStore()?.requestId;
|
|
717
|
+
logger.error(`[HTTP] Unhandled POST error${requestId ? ` (requestId=${requestId})` : ''}`);
|
|
718
|
+
res.status(500).json({
|
|
719
|
+
jsonrpc: '2.0',
|
|
720
|
+
id: payload?.id ?? null,
|
|
721
|
+
error: {
|
|
722
|
+
code: -32603,
|
|
723
|
+
message: 'Internal error. The server logged the cause; quote the requestId when reporting it.',
|
|
724
|
+
...(requestId ? { data: { requestId } } : {}),
|
|
725
|
+
},
|
|
726
|
+
});
|
|
711
727
|
}
|
|
712
728
|
}
|
|
713
729
|
});
|
|
714
730
|
// GET for SSE connect (reuse transport)
|
|
715
731
|
app.get(httpPath, async (req, res) => {
|
|
732
|
+
// #447: the SAME gate the POST route uses. Until now this route never called
|
|
733
|
+
// it outside the OIDC branch, so in the default static-bearer posture a GET on
|
|
734
|
+
// the MCP path was unauthenticated while a POST on the identical path was not.
|
|
735
|
+
// That inconsistency is what forced #438 to withhold the session-init cause
|
|
736
|
+
// here: the cause sentences are configuration hints, and putting them on an
|
|
737
|
+
// unauthenticated route would widen what a caller without a token can learn.
|
|
738
|
+
// authenticateRequest writes its own 401 and returns false, so this is the
|
|
739
|
+
// whole change.
|
|
740
|
+
if (!authenticateRequest(req, res)) {
|
|
741
|
+
return;
|
|
742
|
+
}
|
|
716
743
|
const sessionId = req.headers['mcp-session-id'];
|
|
717
744
|
if (!sessionId) {
|
|
718
745
|
res.status(400).json({ jsonrpc: '2.0', error: { code: -32000, message: 'No session id' }, id: null });
|
|
@@ -728,30 +755,32 @@ advertisedUrl) {
|
|
|
728
755
|
// throw reaches the default final handler, which puts err.stack in the body
|
|
729
756
|
// whenever NODE_ENV is not production (unset for a bare node run, and
|
|
730
757
|
// `development` in docker-compose).
|
|
731
|
-
// #438
|
|
732
|
-
//
|
|
733
|
-
//
|
|
734
|
-
//
|
|
735
|
-
//
|
|
736
|
-
//
|
|
737
|
-
//
|
|
738
|
-
//
|
|
739
|
-
//
|
|
740
|
-
|
|
741
|
-
// wire. try/catch is load bearing because this route has NO error handling and
|
|
742
|
-
// the file registers no error middleware, so under Express 5 a throw reaches the
|
|
743
|
-
// default final handler, which emits err.stack whenever NODE_ENV is not
|
|
744
|
-
// production (unset for a bare node run, `development` in docker-compose).
|
|
758
|
+
// #438 + #447: the cause IS reported here now. It was withheld while this
|
|
759
|
+
// route was unauthenticated in the default static-bearer posture, because
|
|
760
|
+
// the cause sentences are configuration hints. #447 gated the route with the
|
|
761
|
+
// same authenticateRequest the POST route uses, so the reason for withholding
|
|
762
|
+
// is gone. Same closed enum, same fixed sentences, this route's own status
|
|
763
|
+
// and code. try/catch is still load bearing: this route has no error handling
|
|
764
|
+
// of its own and the file registers no error middleware, so under Express 5 a
|
|
765
|
+
// throw reaches the default final handler, which emits err.stack whenever
|
|
766
|
+
// NODE_ENV is not production.
|
|
767
|
+
let knownFailure;
|
|
745
768
|
try {
|
|
746
|
-
|
|
747
|
-
if (knownFailure) {
|
|
748
|
-
logger.warn(`[SESSION] GET on session ${sessionId} whose init failed (${knownFailure.cause}); returning the generic body (auth posture, see the note above)`);
|
|
749
|
-
}
|
|
769
|
+
knownFailure = peekInitFailure(sessionId);
|
|
750
770
|
}
|
|
751
771
|
catch {
|
|
752
|
-
|
|
772
|
+
knownFailure = undefined;
|
|
773
|
+
}
|
|
774
|
+
if (knownFailure) {
|
|
775
|
+
logger.warn(`[SESSION] GET on session ${sessionId} whose init failed (${knownFailure.cause}); reporting the cause`);
|
|
753
776
|
}
|
|
754
|
-
res.status(400).json({
|
|
777
|
+
res.status(400).json({
|
|
778
|
+
jsonrpc: '2.0',
|
|
779
|
+
error: knownFailure
|
|
780
|
+
? { code: -32000, message: knownFailure.sentence, data: { cause: knownFailure.cause, sessionInitFailed: true } }
|
|
781
|
+
: { code: -32000, message: 'Transport not ready' },
|
|
782
|
+
id: null,
|
|
783
|
+
});
|
|
755
784
|
return;
|
|
756
785
|
}
|
|
757
786
|
await transport.handleRequest(req, res);
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
import { createTool } from '../lib/toolFactory.js';
|
|
3
|
+
import adapter from '../lib/actual-adapter.js';
|
|
4
|
+
/** #429: create a custom account group (Actual 26.9.0+). */
|
|
5
|
+
export default createTool({
|
|
6
|
+
name: 'actual_account_groups_create',
|
|
7
|
+
description: 'Create a custom account group for organising accounts in the sidebar. ' +
|
|
8
|
+
'Returns the id of the new group. Assigning accounts to it is a separate step: ' +
|
|
9
|
+
'set the account\'s account_group_id with actual_accounts_update. ' +
|
|
10
|
+
'Requires an Actual server on 26.9.0 or newer.',
|
|
11
|
+
schema: z.object({
|
|
12
|
+
name: z.string().min(1).describe('Display name for the group, for example "Savings" or "Joint accounts"'),
|
|
13
|
+
sort_order: z
|
|
14
|
+
.number()
|
|
15
|
+
.optional()
|
|
16
|
+
.describe('Optional ordering position in the sidebar. Lower sorts first; omit to let Actual place it.'),
|
|
17
|
+
}),
|
|
18
|
+
handler: async (input) => {
|
|
19
|
+
const id = await adapter.createAccountGroup(input);
|
|
20
|
+
return { id, name: input.name };
|
|
21
|
+
},
|
|
22
|
+
examples: [
|
|
23
|
+
{ description: 'Create a group for savings accounts', input: { name: 'Savings' } },
|
|
24
|
+
{ description: 'Create a group pinned to the top of the sidebar', input: { name: 'Daily spending', sort_order: 0 } },
|
|
25
|
+
],
|
|
26
|
+
});
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
import { createTool } from '../lib/toolFactory.js';
|
|
3
|
+
import { CommonSchemas } from '../lib/schemas/common.js';
|
|
4
|
+
import adapter from '../lib/actual-adapter.js';
|
|
5
|
+
/** #429: delete a custom account group (Actual 26.9.0+). */
|
|
6
|
+
export default createTool({
|
|
7
|
+
name: 'actual_account_groups_delete',
|
|
8
|
+
description: 'Delete a custom account group. The accounts in it are NOT deleted: they become ungrouped. ' +
|
|
9
|
+
'Note that upstream clears those member references best-effort under sync, so a concurrent ' +
|
|
10
|
+
'assignment on another device can survive the delete; treat any account still pointing at a ' +
|
|
11
|
+
'missing group as ungrouped. Refuses with a not-found error, writing nothing, when the group ' +
|
|
12
|
+
'does not exist. Requires an Actual server on 26.9.0 or newer.',
|
|
13
|
+
schema: z.object({
|
|
14
|
+
id: CommonSchemas.accountGroupId.describe('Id of the account group to delete (actual_account_groups_list lists them)'),
|
|
15
|
+
}),
|
|
16
|
+
handler: async (input) => {
|
|
17
|
+
await adapter.deleteAccountGroup(input.id);
|
|
18
|
+
return { success: true, id: input.id };
|
|
19
|
+
},
|
|
20
|
+
examples: [
|
|
21
|
+
{ description: 'Delete a group, leaving its accounts ungrouped', input: { id: '00000000-0000-0000-0000-000000000000' } },
|
|
22
|
+
],
|
|
23
|
+
});
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
import { createTool } from '../lib/toolFactory.js';
|
|
3
|
+
import adapter from '../lib/actual-adapter.js';
|
|
4
|
+
/**
|
|
5
|
+
* #429: list the custom account groups (Actual 26.9.0+).
|
|
6
|
+
*
|
|
7
|
+
* Read the grouping semantics before relying on a group id: upstream clears member
|
|
8
|
+
* references on a best-effort basis under CRDT sync, so an account whose
|
|
9
|
+
* `account_group_id` points at a group that is missing here is UNGROUPED, not an error.
|
|
10
|
+
*/
|
|
11
|
+
export default createTool({
|
|
12
|
+
name: 'actual_account_groups_list',
|
|
13
|
+
description: 'List the custom account groups used to organise accounts in the sidebar. ' +
|
|
14
|
+
'Returns each group with its id, name and sort order. An account whose account_group_id ' +
|
|
15
|
+
'does not appear here is ungrouped: upstream clears member references best-effort under ' +
|
|
16
|
+
'sync, so a dangling reference is expected and is not an error. Requires an Actual server ' +
|
|
17
|
+
'on 26.9.0 or newer, which is where account groups were introduced.',
|
|
18
|
+
schema: z.object({}),
|
|
19
|
+
handler: async () => {
|
|
20
|
+
return await adapter.getAccountGroups();
|
|
21
|
+
},
|
|
22
|
+
examples: [{ description: 'List every account group', input: {} }],
|
|
23
|
+
});
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
import { createTool } from '../lib/toolFactory.js';
|
|
3
|
+
import { CommonSchemas } from '../lib/schemas/common.js';
|
|
4
|
+
import adapter from '../lib/actual-adapter.js';
|
|
5
|
+
/** #429: rename or reorder a custom account group (Actual 26.9.0+). */
|
|
6
|
+
export default createTool({
|
|
7
|
+
name: 'actual_account_groups_update',
|
|
8
|
+
description: 'Rename a custom account group or change its sidebar position. ' +
|
|
9
|
+
'Does not change which accounts belong to it: use actual_accounts_update to move an ' +
|
|
10
|
+
'account between groups. Refuses with a not-found error, writing nothing, when the group ' +
|
|
11
|
+
'does not exist. Requires an Actual server on 26.9.0 or newer.',
|
|
12
|
+
schema: z.object({
|
|
13
|
+
id: CommonSchemas.accountGroupId.describe('Id of the account group to update (actual_account_groups_list lists them)'),
|
|
14
|
+
name: z.string().min(1).optional().describe('New display name'),
|
|
15
|
+
sort_order: z.number().optional().describe('New ordering position in the sidebar; lower sorts first'),
|
|
16
|
+
}),
|
|
17
|
+
handler: async (input) => {
|
|
18
|
+
const { id, ...fields } = input;
|
|
19
|
+
await adapter.updateAccountGroup(id, fields);
|
|
20
|
+
return { success: true, id, updated: Object.keys(fields) };
|
|
21
|
+
},
|
|
22
|
+
examples: [
|
|
23
|
+
{ description: 'Rename a group', input: { id: '00000000-0000-0000-0000-000000000000', name: 'Long-term savings' } },
|
|
24
|
+
],
|
|
25
|
+
});
|
package/dist/src/tools/index.js
CHANGED
|
@@ -6,6 +6,10 @@ export { default as accounts_close } from './accounts_close.js';
|
|
|
6
6
|
export { default as accounts_create } from './accounts_create.js';
|
|
7
7
|
export { default as accounts_delete } from './accounts_delete.js';
|
|
8
8
|
export { default as accounts_get_balance } from './accounts_get_balance.js';
|
|
9
|
+
export { default as account_groups_list } from './account_groups_list.js';
|
|
10
|
+
export { default as account_groups_create } from './account_groups_create.js';
|
|
11
|
+
export { default as account_groups_update } from './account_groups_update.js';
|
|
12
|
+
export { default as account_groups_delete } from './account_groups_delete.js';
|
|
9
13
|
export { default as accounts_list } from './accounts_list.js';
|
|
10
14
|
export { default as accounts_reopen } from './accounts_reopen.js';
|
|
11
15
|
export { default as accounts_update } from './accounts_update.js';
|
|
@@ -3,6 +3,11 @@ import { readFileSync } from 'fs';
|
|
|
3
3
|
import { fileURLToPath } from 'url';
|
|
4
4
|
import { dirname, join } from 'path';
|
|
5
5
|
import actualToolsManager from '../actualToolsManager.js';
|
|
6
|
+
import { INSTALLED_API_VERSION, resolveInstalledVersion } from '../lib/installed-api-version.js';
|
|
7
|
+
/** Resolved once at module load, not per call: the walk does blocking file reads
|
|
8
|
+
* and this tool can be called in a loop. The SDK exports no root path, so the
|
|
9
|
+
* resolvable subpath is passed explicitly (#445). */
|
|
10
|
+
const MCP_SDK_RESOLVED = resolveInstalledVersion('@modelcontextprotocol/sdk', '@modelcontextprotocol/sdk/server/index.js');
|
|
6
11
|
const __filename = fileURLToPath(import.meta.url);
|
|
7
12
|
const __dirname = dirname(__filename);
|
|
8
13
|
// Read version from environment variable (set during Docker build) or package.json
|
|
@@ -65,9 +70,21 @@ Use this to check server status, verify version compatibility, or debug issues.`
|
|
|
65
70
|
platform: process.platform,
|
|
66
71
|
arch: process.arch,
|
|
67
72
|
},
|
|
73
|
+
// #445: report BOTH the declared range and what is actually installed. They
|
|
74
|
+
// answer different questions ("what does this build allow" versus "what is it
|
|
75
|
+
// running"), and #427 needed the second: a caret range reports identically for
|
|
76
|
+
// every version inside it, which is exactly the ambiguity that made a server
|
|
77
|
+
// and api version skew hard to diagnose.
|
|
78
|
+
//
|
|
79
|
+
// The resolved fields are OMITTED when they cannot be determined, never set to
|
|
80
|
+
// a sentinel: absence means "could not resolve" and no consumer has to
|
|
81
|
+
// special-case a magic string. The declared fields keep their existing
|
|
82
|
+
// behaviour and value, so nothing that reads them today breaks.
|
|
68
83
|
dependencies: {
|
|
69
84
|
mcpSdk: packageInfo.dependencies?.['@modelcontextprotocol/sdk'] ?? 'unknown',
|
|
70
85
|
actualApi: packageInfo.dependencies?.['@actual-app/api'] ?? 'unknown',
|
|
86
|
+
...(MCP_SDK_RESOLVED ? { mcpSdkResolved: MCP_SDK_RESOLVED } : {}),
|
|
87
|
+
...(INSTALLED_API_VERSION ? { actualApiResolved: INSTALLED_API_VERSION } : {}),
|
|
71
88
|
},
|
|
72
89
|
status: {
|
|
73
90
|
uptime: uptimeFormatted,
|
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.21.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 81 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/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/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/httpServer_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/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",
|
|
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/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/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",
|
|
@@ -78,7 +78,7 @@
|
|
|
78
78
|
"mcp-auth": "^0.2.0",
|
|
79
79
|
"winston": "^3.18.3",
|
|
80
80
|
"winston-daily-rotate-file": "^5.0.0",
|
|
81
|
-
"zod": "^4.4
|
|
81
|
+
"zod": "^4.5.4"
|
|
82
82
|
},
|
|
83
83
|
"optionalDependencies": {
|
|
84
84
|
"prom-client": "*"
|
|
@@ -86,8 +86,8 @@
|
|
|
86
86
|
"devDependencies": {
|
|
87
87
|
"@playwright/test": "^1.60.0",
|
|
88
88
|
"@types/express": "^5.0.3",
|
|
89
|
-
"@types/node": "^26.1
|
|
90
|
-
"knip": "^6.
|
|
89
|
+
"@types/node": "^26.4.1",
|
|
90
|
+
"knip": "^6.34.0",
|
|
91
91
|
"node-fetch": "^3.3.2",
|
|
92
92
|
"tsconfig-paths": "^4.2.0",
|
|
93
93
|
"typescript": "^7.0.2"
|