actual-mcp-server 0.18.0 → 0.19.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
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) │ │ (77 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) │ │ (77 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
|
+
- **77 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 77 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 77 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
|
+
**77 tools** across all categories. All tools use the `actual_<category>_<action>` naming convention.
|
|
308
308
|
|
|
309
309
|
### Accounts (8)
|
|
310
310
|
|
|
@@ -319,7 +319,7 @@ For Claude Desktop (stdio), restart Claude after upgrading.
|
|
|
319
319
|
| `actual_accounts_get_balance` | Get account balance at a date |
|
|
320
320
|
| `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 |
|
|
321
321
|
|
|
322
|
-
### Transactions (
|
|
322
|
+
### Transactions (16)
|
|
323
323
|
|
|
324
324
|
**Standard (6)**
|
|
325
325
|
|
|
@@ -341,7 +341,7 @@ For Claude Desktop (stdio), restart Claude after upgrading.
|
|
|
341
341
|
| `actual_transactions_uncategorized` | Summary of uncategorized transactions (totalCount, totalAmount, per-account breakdown); pass `includeTransactions:true` for paginated rows |
|
|
342
342
|
| `actual_transactions_update_batch` | Apply many transaction updates in ONE call (`updates: [{ id, fields }]`); returns per-item success and failure counts |
|
|
343
343
|
|
|
344
|
-
**Exclusive ActualQL-powered (
|
|
344
|
+
**Exclusive ActualQL-powered (8)**, unique to this MCP server
|
|
345
345
|
|
|
346
346
|
| Tool | Description |
|
|
347
347
|
|------|-------------|
|
|
@@ -352,6 +352,7 @@ For Claude Desktop (stdio), restart Claude after upgrading.
|
|
|
352
352
|
| `actual_transactions_search_by_payee` | Find by payee/vendor |
|
|
353
353
|
| `actual_transactions_summary_by_category` | Spending summary grouped by category |
|
|
354
354
|
| `actual_transactions_summary_by_payee` | Top vendors with totals and counts |
|
|
355
|
+
| `actual_recurring_expenses_summary` | Detect recurring charges (subscriptions, bills) from posted history: cadence (with date-drift and month-end tolerance), latest amount, price changes, occurrences, annualized cost, and active or inactive state |
|
|
355
356
|
|
|
356
357
|
### Transfers (1)
|
|
357
358
|
|
|
@@ -593,7 +594,7 @@ stdio is the simplest way to connect Claude Desktop directly to Actual Budget. T
|
|
|
593
594
|
- No auth token. Process ownership is the security boundary.
|
|
594
595
|
- All logs go to stderr so they never corrupt the JSON-RPC framing on stdout
|
|
595
596
|
- The process exits when stdin closes (Claude Desktop shutting down)
|
|
596
|
-
- All
|
|
597
|
+
- All 77 tools are available, identical to HTTP mode
|
|
597
598
|
|
|
598
599
|
**Start manually to verify:**
|
|
599
600
|
|
|
@@ -692,7 +693,7 @@ See [AI Client Setup, OIDC](docs/guides/AI_CLIENT_SETUP.md#oidc-authentication-m
|
|
|
692
693
|
| Command | What It Tests | Requires Live Server |
|
|
693
694
|
|---------|---------------|---------------------|
|
|
694
695
|
| `npm run build` | TypeScript compilation | No |
|
|
695
|
-
| `npm run test:unit-js` |
|
|
696
|
+
| `npm run test:unit-js` | 77-tool smoke, schema validation, auth ACL | No |
|
|
696
697
|
| `npm run test:adapter` | Adapter, retry logic, concurrency | No |
|
|
697
698
|
| `npm run test:e2e` | MCP protocol compliance (Playwright) | No |
|
|
698
699
|
| `npm run test:e2e:docker:full` | Full stack integration | Yes (Docker) |
|
|
@@ -777,7 +778,7 @@ The software is provided **as-is**, without warranty of any kind. The author acc
|
|
|
777
778
|
|
|
778
779
|
---
|
|
779
780
|
|
|
780
|
-
**Version:** 0.
|
|
781
|
+
**Version:** 0.19.0 | **Tool Count:** 77 (verified LibreChat-compatible)
|
|
781
782
|
|
|
782
783
|
## Sponsor
|
|
783
784
|
|
|
@@ -5,6 +5,7 @@ import { formatZodError } from './lib/zod-error-format.js';
|
|
|
5
5
|
// Adding the tool name here is considered fully implemented.
|
|
6
6
|
const IMPLEMENTED_TOOLS = [
|
|
7
7
|
'actual_account_flow_summary',
|
|
8
|
+
'actual_recurring_expenses_summary',
|
|
8
9
|
'actual_accounts_close',
|
|
9
10
|
'actual_accounts_create',
|
|
10
11
|
'actual_accounts_delete',
|
|
@@ -328,3 +328,254 @@ export function summarizeAccountFlow(snapshot, input) {
|
|
|
328
328
|
},
|
|
329
329
|
};
|
|
330
330
|
}
|
|
331
|
+
function paymentEvents(snapshot, input, maps) {
|
|
332
|
+
const selected = input.accountIds ? new Set(input.accountIds) : null;
|
|
333
|
+
const events = [];
|
|
334
|
+
for (const transaction of snapshot.transactions) {
|
|
335
|
+
if (transaction.date < input.startDate || transaction.date > input.endDate)
|
|
336
|
+
continue;
|
|
337
|
+
if (selected && !selected.has(transaction.account))
|
|
338
|
+
continue;
|
|
339
|
+
const postings = transaction.is_parent === true || (transaction.subtransactions?.length ?? 0) > 0
|
|
340
|
+
? postingRows([transaction])
|
|
341
|
+
: [transaction];
|
|
342
|
+
let amount = 0;
|
|
343
|
+
let payeeId = transaction.payee ?? null;
|
|
344
|
+
for (const posting of postings) {
|
|
345
|
+
if (posting.transfer_id != null) {
|
|
346
|
+
continue;
|
|
347
|
+
}
|
|
348
|
+
if (posting.starting_balance_flag === true)
|
|
349
|
+
continue;
|
|
350
|
+
const category = posting.category ? maps.categories.get(posting.category) : undefined;
|
|
351
|
+
if (category && bool(category.is_income))
|
|
352
|
+
continue;
|
|
353
|
+
const postingAmount = integer(posting.amount, `Transaction ${posting.id} amount`);
|
|
354
|
+
if (postingAmount < 0)
|
|
355
|
+
amount += Math.abs(postingAmount);
|
|
356
|
+
payeeId = payeeId ?? posting.payee ?? null;
|
|
357
|
+
}
|
|
358
|
+
if (amount <= 0 || !payeeId)
|
|
359
|
+
continue;
|
|
360
|
+
events.push({ id: transaction.id, date: transaction.date, amount, payeeId, accountId: transaction.account });
|
|
361
|
+
}
|
|
362
|
+
return events.sort((a, b) => a.date.localeCompare(b.date) || a.id.localeCompare(b.id));
|
|
363
|
+
}
|
|
364
|
+
function utcDate(value) {
|
|
365
|
+
return new Date(`${value}T00:00:00Z`);
|
|
366
|
+
}
|
|
367
|
+
function daysBetween(a, b) {
|
|
368
|
+
return Math.round((utcDate(b).getTime() - utcDate(a).getTime()) / 86_400_000);
|
|
369
|
+
}
|
|
370
|
+
function isLastDayOfMonth(value) {
|
|
371
|
+
const date = utcDate(value);
|
|
372
|
+
const next = new Date(Date.UTC(date.getUTCFullYear(), date.getUTCMonth() + 1, 1));
|
|
373
|
+
return date.getUTCDate() === new Date(next.getTime() - 86_400_000).getUTCDate();
|
|
374
|
+
}
|
|
375
|
+
function monthsBetween(a, b) {
|
|
376
|
+
const left = utcDate(a);
|
|
377
|
+
const right = utcDate(b);
|
|
378
|
+
return (right.getUTCFullYear() - left.getUTCFullYear()) * 12 + right.getUTCMonth() - left.getUTCMonth();
|
|
379
|
+
}
|
|
380
|
+
function intervalMatches(a, b, frequency) {
|
|
381
|
+
const days = daysBetween(a, b);
|
|
382
|
+
if (frequency === 'weekly')
|
|
383
|
+
return days >= 5 && days <= 9;
|
|
384
|
+
if (frequency === 'biweekly')
|
|
385
|
+
return days >= 12 && days <= 16;
|
|
386
|
+
if (frequency === 'monthly') {
|
|
387
|
+
if (monthsBetween(a, b) !== 1)
|
|
388
|
+
return false;
|
|
389
|
+
const dayDiff = Math.abs(utcDate(a).getUTCDate() - utcDate(b).getUTCDate());
|
|
390
|
+
return dayDiff <= 7 || (isLastDayOfMonth(a) && isLastDayOfMonth(b));
|
|
391
|
+
}
|
|
392
|
+
if (frequency === 'quarterly')
|
|
393
|
+
return monthsBetween(a, b) === 3 && Math.abs(utcDate(a).getUTCDate() - utcDate(b).getUTCDate()) <= 10;
|
|
394
|
+
if (frequency === 'yearly')
|
|
395
|
+
return monthsBetween(a, b) === 12 && Math.abs(utcDate(a).getUTCDate() - utcDate(b).getUTCDate()) <= 15;
|
|
396
|
+
return false;
|
|
397
|
+
}
|
|
398
|
+
function detectFrequency(events) {
|
|
399
|
+
if (events.length < 2)
|
|
400
|
+
return { frequency: 'other', regularity: 0 };
|
|
401
|
+
const candidates = ['weekly', 'biweekly', 'monthly', 'quarterly', 'yearly'];
|
|
402
|
+
let best = { frequency: 'other', regularity: 0 };
|
|
403
|
+
for (const frequency of candidates) {
|
|
404
|
+
let matches = 0;
|
|
405
|
+
for (let i = 1; i < events.length; i += 1) {
|
|
406
|
+
if (intervalMatches(events[i - 1].date, events[i].date, frequency))
|
|
407
|
+
matches += 1;
|
|
408
|
+
}
|
|
409
|
+
const regularity = matches / (events.length - 1);
|
|
410
|
+
if (regularity > best.regularity)
|
|
411
|
+
best = { frequency, regularity };
|
|
412
|
+
}
|
|
413
|
+
return best.regularity >= 0.6 ? best : { frequency: 'other', regularity: best.regularity };
|
|
414
|
+
}
|
|
415
|
+
function amountClose(a, b) {
|
|
416
|
+
return Math.abs(a - b) <= Math.max(100, Math.round(Math.max(a, b) * 0.05));
|
|
417
|
+
}
|
|
418
|
+
function initialAmountLanes(events) {
|
|
419
|
+
const lanes = [];
|
|
420
|
+
for (const event of events) {
|
|
421
|
+
const lane = lanes.find(candidate => amountClose(candidate[candidate.length - 1].amount, event.amount));
|
|
422
|
+
if (lane)
|
|
423
|
+
lane.push(event);
|
|
424
|
+
else
|
|
425
|
+
lanes.push([event]);
|
|
426
|
+
}
|
|
427
|
+
return lanes;
|
|
428
|
+
}
|
|
429
|
+
function mergePriceLanes(lanes) {
|
|
430
|
+
const result = lanes.map(lane => [...lane].sort((a, b) => a.date.localeCompare(b.date)));
|
|
431
|
+
let changed = true;
|
|
432
|
+
while (changed) {
|
|
433
|
+
changed = false;
|
|
434
|
+
outer: for (let i = 0; i < result.length; i += 1) {
|
|
435
|
+
for (let j = i + 1; j < result.length; j += 1) {
|
|
436
|
+
const a = result[i];
|
|
437
|
+
const b = result[j];
|
|
438
|
+
const aBeforeB = a[a.length - 1].date < b[0].date;
|
|
439
|
+
const bBeforeA = b[b.length - 1].date < a[0].date;
|
|
440
|
+
if (!aBeforeB && !bBeforeA)
|
|
441
|
+
continue;
|
|
442
|
+
const combined = [...a, ...b].sort((x, y) => x.date.localeCompare(y.date));
|
|
443
|
+
const detected = detectFrequency(combined);
|
|
444
|
+
if (detected.frequency !== 'other' && detected.regularity >= 0.75) {
|
|
445
|
+
result[i] = combined;
|
|
446
|
+
result.splice(j, 1);
|
|
447
|
+
changed = true;
|
|
448
|
+
break outer;
|
|
449
|
+
}
|
|
450
|
+
}
|
|
451
|
+
}
|
|
452
|
+
}
|
|
453
|
+
return result;
|
|
454
|
+
}
|
|
455
|
+
function addFrequency(dateValue, frequency) {
|
|
456
|
+
if (frequency === 'other')
|
|
457
|
+
return undefined;
|
|
458
|
+
const date = utcDate(dateValue);
|
|
459
|
+
if (frequency === 'weekly')
|
|
460
|
+
date.setUTCDate(date.getUTCDate() + 7);
|
|
461
|
+
if (frequency === 'biweekly')
|
|
462
|
+
date.setUTCDate(date.getUTCDate() + 14);
|
|
463
|
+
if (frequency === 'monthly' || frequency === 'quarterly' || frequency === 'yearly') {
|
|
464
|
+
const months = frequency === 'monthly' ? 1 : frequency === 'quarterly' ? 3 : 12;
|
|
465
|
+
const originalDay = date.getUTCDate();
|
|
466
|
+
const wasMonthEnd = isLastDayOfMonth(dateValue);
|
|
467
|
+
date.setUTCDate(1);
|
|
468
|
+
date.setUTCMonth(date.getUTCMonth() + months);
|
|
469
|
+
const lastDay = new Date(Date.UTC(date.getUTCFullYear(), date.getUTCMonth() + 1, 0)).getUTCDate();
|
|
470
|
+
date.setUTCDate(wasMonthEnd ? lastDay : Math.min(originalDay, lastDay));
|
|
471
|
+
}
|
|
472
|
+
return date.toISOString().slice(0, 10);
|
|
473
|
+
}
|
|
474
|
+
function establishedAmounts(events) {
|
|
475
|
+
const runs = [];
|
|
476
|
+
for (const event of events) {
|
|
477
|
+
const current = runs[runs.length - 1];
|
|
478
|
+
// The wider amount tolerance belongs to series matching, not price history.
|
|
479
|
+
// Once events are known to be one series, preserve exact posted prices so a
|
|
480
|
+
// small increase cannot be hidden inside a historical median.
|
|
481
|
+
if (current && current[current.length - 1] === event.amount)
|
|
482
|
+
current.push(event.amount);
|
|
483
|
+
else
|
|
484
|
+
runs.push([event.amount]);
|
|
485
|
+
}
|
|
486
|
+
const median = (values) => [...values].sort((a, b) => a - b)[Math.floor(values.length / 2)];
|
|
487
|
+
return {
|
|
488
|
+
latest: median(runs[runs.length - 1]),
|
|
489
|
+
previous: runs.length > 1 ? median(runs[runs.length - 2]) : undefined,
|
|
490
|
+
};
|
|
491
|
+
}
|
|
492
|
+
function annualize(amount, frequency) {
|
|
493
|
+
const multiplier = {
|
|
494
|
+
weekly: 52,
|
|
495
|
+
biweekly: 26,
|
|
496
|
+
monthly: 12,
|
|
497
|
+
quarterly: 4,
|
|
498
|
+
yearly: 1,
|
|
499
|
+
};
|
|
500
|
+
const value = multiplier[frequency];
|
|
501
|
+
return value === undefined ? undefined : integer(amount * value, 'Annualized amount');
|
|
502
|
+
}
|
|
503
|
+
function inactiveAt(endDate, expectedNextDate, frequency) {
|
|
504
|
+
if (!expectedNextDate)
|
|
505
|
+
return true;
|
|
506
|
+
const secondMiss = addFrequency(expectedNextDate, frequency);
|
|
507
|
+
return secondMiss !== undefined && endDate >= secondMiss;
|
|
508
|
+
}
|
|
509
|
+
export function summarizeRecurringExpenses(snapshot, input) {
|
|
510
|
+
const maps = buildMaps(snapshot);
|
|
511
|
+
const byPayeeAccount = new Map();
|
|
512
|
+
for (const event of paymentEvents(snapshot, input, maps)) {
|
|
513
|
+
const key = `${event.payeeId}:${event.accountId}`;
|
|
514
|
+
const values = byPayeeAccount.get(key) ?? [];
|
|
515
|
+
values.push(event);
|
|
516
|
+
byPayeeAccount.set(key, values);
|
|
517
|
+
}
|
|
518
|
+
const detected = [];
|
|
519
|
+
for (const events of byPayeeAccount.values()) {
|
|
520
|
+
for (const lane of mergePriceLanes(initialAmountLanes(events))) {
|
|
521
|
+
const cadence = detectFrequency(lane);
|
|
522
|
+
if (cadence.frequency === 'other')
|
|
523
|
+
continue;
|
|
524
|
+
const needed = cadence.frequency === 'yearly' ? Math.min(2, input.minOccurrences) : input.minOccurrences;
|
|
525
|
+
if (lane.length < needed)
|
|
526
|
+
continue;
|
|
527
|
+
detected.push({ events: lane, ...cadence });
|
|
528
|
+
}
|
|
529
|
+
}
|
|
530
|
+
const series = detected.flatMap(item => {
|
|
531
|
+
const first = item.events[0];
|
|
532
|
+
const last = item.events[item.events.length - 1];
|
|
533
|
+
const expectedNextDate = addFrequency(last.date, item.frequency);
|
|
534
|
+
const inactive = inactiveAt(input.endDate, expectedNextDate, item.frequency);
|
|
535
|
+
if (inactive && !input.includeInactive)
|
|
536
|
+
return [];
|
|
537
|
+
const amounts = establishedAmounts(item.events);
|
|
538
|
+
const annualizedAmount = annualize(amounts.latest, item.frequency);
|
|
539
|
+
if (annualizedAmount === undefined)
|
|
540
|
+
return [];
|
|
541
|
+
const confidence = item.regularity >= 0.9 && item.events.length >= 4
|
|
542
|
+
? 'high'
|
|
543
|
+
: item.regularity >= 0.7
|
|
544
|
+
? 'medium'
|
|
545
|
+
: 'low';
|
|
546
|
+
const previousAmount = amounts.previous;
|
|
547
|
+
const priceChange = previousAmount !== undefined && previousAmount !== amounts.latest
|
|
548
|
+
? {
|
|
549
|
+
previousAmount,
|
|
550
|
+
currentAmount: amounts.latest,
|
|
551
|
+
absoluteChange: amounts.latest - previousAmount,
|
|
552
|
+
percentageChange: Math.round(((amounts.latest - previousAmount) / previousAmount) * 10_000) / 100,
|
|
553
|
+
}
|
|
554
|
+
: undefined;
|
|
555
|
+
return [{
|
|
556
|
+
payeeId: first.payeeId,
|
|
557
|
+
payeeName: maps.payees.get(first.payeeId)?.name ?? first.payeeId,
|
|
558
|
+
accountId: first.accountId,
|
|
559
|
+
accountName: maps.accounts.get(first.accountId)?.name ?? first.accountId,
|
|
560
|
+
frequency: item.frequency,
|
|
561
|
+
occurrenceCount: item.events.length,
|
|
562
|
+
latestAmount: amounts.latest,
|
|
563
|
+
...(previousAmount !== undefined ? { previousAmount } : {}),
|
|
564
|
+
annualizedAmount,
|
|
565
|
+
firstObservedDate: first.date,
|
|
566
|
+
lastObservedDate: last.date,
|
|
567
|
+
expectedNextDate,
|
|
568
|
+
...(priceChange ? { priceChange } : {}),
|
|
569
|
+
confidence,
|
|
570
|
+
confidenceScore: Math.round(item.regularity * 100),
|
|
571
|
+
active: !inactive,
|
|
572
|
+
}];
|
|
573
|
+
}).sort((a, b) => a.payeeName.localeCompare(b.payeeName) || b.latestAmount - a.latestAmount);
|
|
574
|
+
return {
|
|
575
|
+
dateRange: { startDate: input.startDate, endDate: input.endDate },
|
|
576
|
+
series,
|
|
577
|
+
totalAnnualizedRecurringExpenses: series
|
|
578
|
+
.filter(item => item.active)
|
|
579
|
+
.reduce((total, item) => total + item.annualizedAmount, 0),
|
|
580
|
+
};
|
|
581
|
+
}
|
package/dist/src/tools/index.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
// Auto-generated index for all tool modules
|
|
2
2
|
export { default as entities_search } from './entities_search.js';
|
|
3
3
|
export { default as account_flow_summary } from './account_flow_summary.js';
|
|
4
|
+
export { default as recurring_expenses_summary } from './recurring_expenses_summary.js';
|
|
4
5
|
export { default as accounts_close } from './accounts_close.js';
|
|
5
6
|
export { default as accounts_create } from './accounts_create.js';
|
|
6
7
|
export { default as accounts_delete } from './accounts_delete.js';
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
// #426: deterministic, read-only recurring-expense detection.
|
|
2
|
+
//
|
|
3
|
+
// Original heuristic by @maxvanweenen (PR #399), the third tool from #398. The detection lives in
|
|
4
|
+
// src/lib/financial-analysis.ts (summarizeRecurringExpenses); this file is the tool surface plus the
|
|
5
|
+
// #388 filter-id resolution. The heuristic core is ported verbatim; only the id-filter surface is
|
|
6
|
+
// adapted to this server's resolveFilterId convention (a NAME resolves or is refused with its id),
|
|
7
|
+
// matching the two sibling tools rather than PR #399's strict-UUID plus manual existence check.
|
|
8
|
+
import { z } from 'zod';
|
|
9
|
+
import { createTool } from '../lib/toolFactory.js';
|
|
10
|
+
import { CommonSchemas } from '../lib/schemas/common.js';
|
|
11
|
+
import adapter from '../lib/actual-adapter.js';
|
|
12
|
+
import { summarizeRecurringExpenses } from '../lib/financial-analysis.js';
|
|
13
|
+
// Compute the inclusive start date for a "last N months" lookback, clamping the day to the target
|
|
14
|
+
// month's length (so a 31-day anchor lands on the last valid day) and then stepping one day forward
|
|
15
|
+
// so the window is a whole number of months ending at endDate.
|
|
16
|
+
function startFromMonths(endDate, months) {
|
|
17
|
+
const end = new Date(`${endDate}T00:00:00Z`);
|
|
18
|
+
const originalDay = end.getUTCDate();
|
|
19
|
+
end.setUTCDate(1);
|
|
20
|
+
end.setUTCMonth(end.getUTCMonth() - months);
|
|
21
|
+
const lastDay = new Date(Date.UTC(end.getUTCFullYear(), end.getUTCMonth() + 1, 0)).getUTCDate();
|
|
22
|
+
end.setUTCDate(Math.min(originalDay, lastDay));
|
|
23
|
+
end.setUTCDate(end.getUTCDate() + 1);
|
|
24
|
+
return end.toISOString().slice(0, 10);
|
|
25
|
+
}
|
|
26
|
+
// Bare-string ids (#388): a NAME resolves via adapter.resolveFilterId rather than being rejected.
|
|
27
|
+
const InputSchema = z.object({
|
|
28
|
+
startDate: CommonSchemas.date.optional(),
|
|
29
|
+
endDate: CommonSchemas.date.optional(),
|
|
30
|
+
months: z.number().int().min(1).max(60).optional(),
|
|
31
|
+
accountIds: z.array(z.string().min(1)).min(1).max(100).optional(),
|
|
32
|
+
minOccurrences: z.number().int().min(2).max(100).default(3),
|
|
33
|
+
includeInactive: z.boolean().default(false),
|
|
34
|
+
}).strict().superRefine((input, ctx) => {
|
|
35
|
+
if (input.startDate && input.months) {
|
|
36
|
+
ctx.addIssue({ code: 'custom', path: ['months'], message: 'Use startDate or months, not both' });
|
|
37
|
+
}
|
|
38
|
+
if (input.startDate && input.endDate && input.startDate > input.endDate) {
|
|
39
|
+
ctx.addIssue({ code: 'custom', path: ['endDate'], message: 'endDate must be on or after startDate' });
|
|
40
|
+
}
|
|
41
|
+
});
|
|
42
|
+
export default createTool({
|
|
43
|
+
name: 'actual_recurring_expenses_summary',
|
|
44
|
+
description: [
|
|
45
|
+
'Detect recurring expenses (subscriptions, bills) from posted transaction history, without relying on schedules.',
|
|
46
|
+
'Transfers, income, starting balances, and split-parent duplicates are excluded; a transfer is never recurring.',
|
|
47
|
+
'Date drift and month-end clamping are tolerated, distinct amount lanes for one payee stay separate, and a later contiguous lane is treated as a price change with the exact new latestAmount preserved.',
|
|
48
|
+
'A series that has gone quiet is inactive after a two-miss rule; includeInactive controls whether it appears.',
|
|
49
|
+
'Annualized amounts use exact integer multipliers: weekly 52, biweekly 26, monthly 12, quarterly 4, yearly 1.',
|
|
50
|
+
'Defaults to an 18-month lookback, three occurrences, and active series only. Amounts are in cents; dates are YYYY-MM-DD.',
|
|
51
|
+
].join(' '),
|
|
52
|
+
schema: InputSchema,
|
|
53
|
+
handler: async (input) => {
|
|
54
|
+
const endDate = input.endDate ?? new Date().toISOString().slice(0, 10);
|
|
55
|
+
const startDate = input.startDate ?? startFromMonths(endDate, input.months ?? 18);
|
|
56
|
+
// Recurring detection reads only the transaction stream and listings, no balance queries, so the
|
|
57
|
+
// snapshot is fetched without balanceAccountIds. One withActualApi read session, no N+1.
|
|
58
|
+
const snapshot = await adapter.getFinancialAnalysisSnapshot({ startDate, endDate });
|
|
59
|
+
let accountIds;
|
|
60
|
+
if (input.accountIds) {
|
|
61
|
+
accountIds = [];
|
|
62
|
+
for (const id of input.accountIds) {
|
|
63
|
+
// A NAME throws NotFoundRefusal carrying the resolved id; a well-formed id is verified against
|
|
64
|
+
// the listing we already hold, so a correct call reads no extra listing.
|
|
65
|
+
accountIds.push(await adapter.resolveFilterId('account', id, { verifyExists: true, rows: snapshot.accounts }));
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
return summarizeRecurringExpenses(snapshot, {
|
|
69
|
+
startDate,
|
|
70
|
+
endDate,
|
|
71
|
+
accountIds,
|
|
72
|
+
minOccurrences: input.minOccurrences,
|
|
73
|
+
includeInactive: input.includeInactive,
|
|
74
|
+
});
|
|
75
|
+
},
|
|
76
|
+
examples: [
|
|
77
|
+
{
|
|
78
|
+
description: 'What am I paying for every month, over the last year',
|
|
79
|
+
input: { months: 12, minOccurrences: 3, includeInactive: false },
|
|
80
|
+
},
|
|
81
|
+
],
|
|
82
|
+
});
|
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.19.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 77 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/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",
|
|
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/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",
|