actual-mcp-server 0.5.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/LICENSE +21 -0
- package/README.md +663 -0
- package/bin/actual-mcp-server.js +3 -0
- package/dist/generated/actual-client/types.js +5 -0
- package/dist/package.json +88 -0
- package/dist/src/actualConnection.js +157 -0
- package/dist/src/actualToolsManager.js +211 -0
- package/dist/src/auth/budget-acl.js +143 -0
- package/dist/src/auth/setup.js +58 -0
- package/dist/src/config.js +41 -0
- package/dist/src/index.js +313 -0
- package/dist/src/lib/ActualConnectionPool.js +343 -0
- package/dist/src/lib/ActualMCPConnection.js +125 -0
- package/dist/src/lib/actual-adapter.js +1228 -0
- package/dist/src/lib/actual-schema.js +222 -0
- package/dist/src/lib/budget-registry.js +64 -0
- package/dist/src/lib/constants.js +121 -0
- package/dist/src/lib/errors.js +19 -0
- package/dist/src/lib/loggerFactory.js +72 -0
- package/dist/src/lib/node-polyfills.js +20 -0
- package/dist/src/lib/query-validator.js +221 -0
- package/dist/src/lib/retry.js +26 -0
- package/dist/src/lib/schemas/common.js +203 -0
- package/dist/src/lib/toolFactory.js +109 -0
- package/dist/src/logger.js +127 -0
- package/dist/src/observability.js +58 -0
- package/dist/src/prompts/showLargeTransactions.js +6 -0
- package/dist/src/resources/accountsSummary.js +13 -0
- package/dist/src/server/httpServer.js +540 -0
- package/dist/src/server/httpServer_testing.js +401 -0
- package/dist/src/server/stdioServer.js +52 -0
- package/dist/src/server/streamable-http.js +148 -0
- package/dist/src/tests/actualToolsTests.js +70 -0
- package/dist/src/tests/observability.smoke.test.js +18 -0
- package/dist/src/tests/testMcpClient.js +170 -0
- package/dist/src/tests_adapter_runner.js +86 -0
- package/dist/src/tools/accounts_close.js +16 -0
- package/dist/src/tools/accounts_create.js +27 -0
- package/dist/src/tools/accounts_delete.js +16 -0
- package/dist/src/tools/accounts_get_balance.js +40 -0
- package/dist/src/tools/accounts_list.js +16 -0
- package/dist/src/tools/accounts_reopen.js +16 -0
- package/dist/src/tools/accounts_update.js +52 -0
- package/dist/src/tools/bank_sync.js +22 -0
- package/dist/src/tools/budget_updates_batch.js +77 -0
- package/dist/src/tools/budgets_getMonth.js +14 -0
- package/dist/src/tools/budgets_getMonths.js +14 -0
- package/dist/src/tools/budgets_get_all.js +13 -0
- package/dist/src/tools/budgets_holdForNextMonth.js +19 -0
- package/dist/src/tools/budgets_list_available.js +20 -0
- package/dist/src/tools/budgets_resetHold.js +16 -0
- package/dist/src/tools/budgets_setAmount.js +26 -0
- package/dist/src/tools/budgets_setCarryover.js +18 -0
- package/dist/src/tools/budgets_switch.js +27 -0
- package/dist/src/tools/budgets_transfer.js +64 -0
- package/dist/src/tools/categories_create.js +65 -0
- package/dist/src/tools/categories_delete.js +16 -0
- package/dist/src/tools/categories_get.js +14 -0
- package/dist/src/tools/categories_update.js +22 -0
- package/dist/src/tools/category_groups_create.js +18 -0
- package/dist/src/tools/category_groups_delete.js +26 -0
- package/dist/src/tools/category_groups_get.js +13 -0
- package/dist/src/tools/category_groups_update.js +21 -0
- package/dist/src/tools/get_id_by_name.js +36 -0
- package/dist/src/tools/index.js +63 -0
- package/dist/src/tools/payee_rules_get.js +27 -0
- package/dist/src/tools/payees_create.js +25 -0
- package/dist/src/tools/payees_delete.js +16 -0
- package/dist/src/tools/payees_get.js +14 -0
- package/dist/src/tools/payees_merge.js +17 -0
- package/dist/src/tools/payees_update.js +59 -0
- package/dist/src/tools/query_run.js +78 -0
- package/dist/src/tools/rules_create.js +129 -0
- package/dist/src/tools/rules_create_or_update.js +191 -0
- package/dist/src/tools/rules_delete.js +26 -0
- package/dist/src/tools/rules_get.js +13 -0
- package/dist/src/tools/rules_update.js +120 -0
- package/dist/src/tools/schedules_create.js +54 -0
- package/dist/src/tools/schedules_delete.js +41 -0
- package/dist/src/tools/schedules_get.js +13 -0
- package/dist/src/tools/schedules_update.js +40 -0
- package/dist/src/tools/server_get_version.js +22 -0
- package/dist/src/tools/server_info.js +86 -0
- package/dist/src/tools/session_close.js +100 -0
- package/dist/src/tools/session_list.js +24 -0
- package/dist/src/tools/transactions_create.js +50 -0
- package/dist/src/tools/transactions_delete.js +20 -0
- package/dist/src/tools/transactions_filter.js +73 -0
- package/dist/src/tools/transactions_get.js +23 -0
- package/dist/src/tools/transactions_import.js +21 -0
- package/dist/src/tools/transactions_search_by_amount.js +126 -0
- package/dist/src/tools/transactions_search_by_category.js +137 -0
- package/dist/src/tools/transactions_search_by_month.js +142 -0
- package/dist/src/tools/transactions_search_by_payee.js +142 -0
- package/dist/src/tools/transactions_summary_by_category.js +80 -0
- package/dist/src/tools/transactions_summary_by_payee.js +72 -0
- package/dist/src/tools/transactions_uncategorized.js +66 -0
- package/dist/src/tools/transactions_update.js +34 -0
- package/dist/src/tools/transactions_update_batch.js +60 -0
- package/dist/src/utils.js +63 -0
- package/package.json +88 -0
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { incrementToolCall, getMetricsText } from '../observability.js';
|
|
2
|
+
export default async function runObservabilitySmoke() {
|
|
3
|
+
console.log('Running observability smoke test');
|
|
4
|
+
// Should not throw even if prom-client isn't installed
|
|
5
|
+
await incrementToolCall('test.tool');
|
|
6
|
+
const metrics = await getMetricsText();
|
|
7
|
+
if (metrics !== null && typeof metrics !== 'string') {
|
|
8
|
+
throw new Error('getMetricsText returned unexpected type');
|
|
9
|
+
}
|
|
10
|
+
console.log('✅ Observability smoke test passed');
|
|
11
|
+
}
|
|
12
|
+
const mainUrl = typeof process !== 'undefined' && process.argv && process.argv[1] ? `file://${process.argv[1]}` : '';
|
|
13
|
+
if (import.meta.url === mainUrl) {
|
|
14
|
+
runObservabilitySmoke().catch(err => {
|
|
15
|
+
console.error('Observability smoke test failed:', err);
|
|
16
|
+
process.exit(1);
|
|
17
|
+
});
|
|
18
|
+
}
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
export async function testMcpClient(advertisedUrl, port, httpPath) {
|
|
2
|
+
// Try builtin fetch or fallback to node-fetch if necessary
|
|
3
|
+
const globalFetch = globalThis.fetch;
|
|
4
|
+
const fetchAny = globalFetch ?? (await import('node-fetch')).default;
|
|
5
|
+
const fetchFn = fetchAny;
|
|
6
|
+
const base = new URL(advertisedUrl);
|
|
7
|
+
// quick probe endpoint
|
|
8
|
+
const probeUrl = `${base.origin}/.well-known/oauth-protected-resource`;
|
|
9
|
+
console.info(`MCP client test: probing ${probeUrl}`);
|
|
10
|
+
const probeRes = await fetchFn(probeUrl, { method: 'GET' });
|
|
11
|
+
if (!probeRes.ok) {
|
|
12
|
+
throw new Error(`Probe GET failed: ${probeRes.status} ${probeRes.statusText}`);
|
|
13
|
+
}
|
|
14
|
+
const probeJsonRaw = await probeRes.json();
|
|
15
|
+
const probeJsonObj = probeJsonRaw && typeof probeJsonRaw === 'object' ? probeJsonRaw : undefined;
|
|
16
|
+
const resultRaw = probeJsonObj && 'result' in probeJsonObj ? probeJsonObj.result : probeJsonRaw;
|
|
17
|
+
if (!resultRaw)
|
|
18
|
+
throw new Error('Probe response missing result');
|
|
19
|
+
// validate presence and types with runtime guards
|
|
20
|
+
const resultObj = resultRaw && typeof resultRaw === 'object' ? resultRaw : undefined;
|
|
21
|
+
if (!resultObj)
|
|
22
|
+
throw new Error('Probe result is not an object');
|
|
23
|
+
const serverInstructions = resultObj['serverInstructions'];
|
|
24
|
+
if (serverInstructions && typeof serverInstructions === 'object') {
|
|
25
|
+
if (typeof serverInstructions['instructions'] !== 'string') {
|
|
26
|
+
throw new Error('serverInstructions missing or wrong type');
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
const capabilitiesVal = resultObj['capabilities'];
|
|
30
|
+
if (!capabilitiesVal || typeof capabilitiesVal !== 'object' || !('tools' in capabilitiesVal)) {
|
|
31
|
+
throw new Error('capabilities.tools missing or wrong type');
|
|
32
|
+
}
|
|
33
|
+
const toolsVal = resultObj['tools'];
|
|
34
|
+
if (!Array.isArray(toolsVal)) {
|
|
35
|
+
throw new Error('tools missing or not array');
|
|
36
|
+
}
|
|
37
|
+
console.info('Probe OK: capabilities/tools/serverInstructions present');
|
|
38
|
+
// Now do JSON-RPC initialize + tools/list using the HTTP endpoint
|
|
39
|
+
const rpcUrl = `${base.origin}${httpPath}`;
|
|
40
|
+
const initPayload = {
|
|
41
|
+
jsonrpc: '2.0',
|
|
42
|
+
id: 1,
|
|
43
|
+
method: 'initialize',
|
|
44
|
+
params: {
|
|
45
|
+
protocolVersion: '2025-06-18',
|
|
46
|
+
capabilities: {},
|
|
47
|
+
clientInfo: { name: 'actual-mcp-server-client-test', version: '0.0.1' },
|
|
48
|
+
},
|
|
49
|
+
};
|
|
50
|
+
console.info(`MCP client test: POST initialize -> ${rpcUrl}`);
|
|
51
|
+
// Server expects the client to accept both JSON responses and SSE frames
|
|
52
|
+
const commonPostHeaders = { 'Content-Type': 'application/json', Accept: 'application/json, text/event-stream' };
|
|
53
|
+
const initRes = await fetchFn(rpcUrl, {
|
|
54
|
+
method: 'POST',
|
|
55
|
+
headers: commonPostHeaders,
|
|
56
|
+
body: JSON.stringify(initPayload),
|
|
57
|
+
});
|
|
58
|
+
if (!initRes.ok) {
|
|
59
|
+
throw new Error(`Initialize POST failed: ${initRes.status} ${initRes.statusText}`);
|
|
60
|
+
}
|
|
61
|
+
const initJsonRaw = await initRes.json();
|
|
62
|
+
const initJson = (initJsonRaw && typeof initJsonRaw === 'object') ? initJsonRaw : undefined;
|
|
63
|
+
if (!initJson || !('result' in initJson)) {
|
|
64
|
+
throw new Error(`Initialize response missing result: ${JSON.stringify(initJsonRaw)}`);
|
|
65
|
+
}
|
|
66
|
+
let sessionId = undefined;
|
|
67
|
+
if (initRes.headers && typeof initRes.headers === 'object') {
|
|
68
|
+
const h = initRes.headers;
|
|
69
|
+
if (typeof h.get === 'function') {
|
|
70
|
+
sessionId = h.get('mcp-session-id') ?? undefined;
|
|
71
|
+
}
|
|
72
|
+
else if ('mcp-session-id' in h) {
|
|
73
|
+
sessionId = h['mcp-session-id'];
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
if (sessionId)
|
|
77
|
+
console.info(`Received session id header: ${sessionId}`);
|
|
78
|
+
// Validate initialize result fields (capabilities/tools/serverInstructions)
|
|
79
|
+
const initResultRaw = initJson['result'];
|
|
80
|
+
const initResult = initResultRaw && typeof initResultRaw === 'object' ? initResultRaw : undefined;
|
|
81
|
+
if (!initResult || typeof initResult['capabilities'] !== 'object') {
|
|
82
|
+
throw new Error('initialize result.capabilities missing or not object');
|
|
83
|
+
}
|
|
84
|
+
// Accept either a tools array or derive tools from capabilities.tools object
|
|
85
|
+
let initTools = [];
|
|
86
|
+
if (Array.isArray(initResult['tools'])) {
|
|
87
|
+
initTools = initResult['tools'];
|
|
88
|
+
}
|
|
89
|
+
else if (initResult['capabilities'] && typeof initResult['capabilities']['tools'] === 'object') {
|
|
90
|
+
initTools = Object.keys(initResult['capabilities']['tools']);
|
|
91
|
+
}
|
|
92
|
+
else {
|
|
93
|
+
throw new Error('initialize result.tools missing or not array and capabilities.tools not present');
|
|
94
|
+
}
|
|
95
|
+
// serverInstructions may be a string, an object { instructions }, or absent.
|
|
96
|
+
const si = initResult['serverInstructions'];
|
|
97
|
+
if (!si) {
|
|
98
|
+
console.warn('Warning: serverInstructions missing from initialize result — continuing tests');
|
|
99
|
+
}
|
|
100
|
+
else if (typeof si === 'string') {
|
|
101
|
+
// ok
|
|
102
|
+
}
|
|
103
|
+
else if (typeof si === 'object' && typeof si['instructions'] === 'string') {
|
|
104
|
+
// ok
|
|
105
|
+
}
|
|
106
|
+
else {
|
|
107
|
+
console.warn('Warning: serverInstructions present but in unexpected shape — continuing tests');
|
|
108
|
+
}
|
|
109
|
+
// replace usages below with initTools where needed
|
|
110
|
+
console.info('Initialize OK: capabilities/tools/serverInstructions present');
|
|
111
|
+
// call tools/list (JSON-RPC) to verify RPC path works
|
|
112
|
+
const listPayload = { jsonrpc: '2.0', id: 2, method: 'tools/list', params: {} };
|
|
113
|
+
const listHeaders = { ...commonPostHeaders };
|
|
114
|
+
if (sessionId)
|
|
115
|
+
listHeaders['mcp-session-id'] = sessionId;
|
|
116
|
+
const listRes = await fetchFn(rpcUrl, {
|
|
117
|
+
method: 'POST',
|
|
118
|
+
headers: listHeaders,
|
|
119
|
+
body: JSON.stringify(listPayload),
|
|
120
|
+
});
|
|
121
|
+
if (!listRes.ok)
|
|
122
|
+
throw new Error(`tools/list failed: ${listRes.status}`);
|
|
123
|
+
const listJsonRaw = await listRes.json();
|
|
124
|
+
const listJsonObj = listJsonRaw && typeof listJsonRaw === 'object' ? listJsonRaw : undefined;
|
|
125
|
+
if (!listJsonObj || !('result' in listJsonObj))
|
|
126
|
+
throw new Error('tools/list returned invalid result');
|
|
127
|
+
const listResult = listJsonObj['result'];
|
|
128
|
+
if (!listResult || typeof listResult !== 'object' || !('tools' in listResult) || !Array.isArray(listResult['tools'])) {
|
|
129
|
+
throw new Error('tools/list returned invalid tools array');
|
|
130
|
+
}
|
|
131
|
+
const toolsArray = listResult['tools'];
|
|
132
|
+
const listed = toolsArray.map((t) => (t && typeof t === 'object' && 'name' in t ? t['name'] : String(t))).join(', ');
|
|
133
|
+
console.info(`tools/list OK: ${listed}`);
|
|
134
|
+
// Optionally, attempt to call each tool with empty args (non-destructive expectation)
|
|
135
|
+
for (const tool of toolsArray) {
|
|
136
|
+
const toolObj = tool && typeof tool === 'object' ? tool : undefined;
|
|
137
|
+
const name = toolObj && typeof toolObj['name'] === 'string' ? toolObj['name'] : String(tool);
|
|
138
|
+
console.info(`Calling tool: ${name}`);
|
|
139
|
+
const callPayload = {
|
|
140
|
+
jsonrpc: '2.0',
|
|
141
|
+
id: Math.floor(Math.random() * 1_000_000),
|
|
142
|
+
method: 'tools/call',
|
|
143
|
+
params: { name, arguments: {} },
|
|
144
|
+
};
|
|
145
|
+
const callRes = await fetchFn(rpcUrl, {
|
|
146
|
+
method: 'POST',
|
|
147
|
+
headers: listHeaders,
|
|
148
|
+
body: JSON.stringify(callPayload),
|
|
149
|
+
});
|
|
150
|
+
if (!callRes.ok) {
|
|
151
|
+
console.warn(`tools/call ${name} HTTP ${callRes.status}`);
|
|
152
|
+
continue;
|
|
153
|
+
}
|
|
154
|
+
const callJsonRaw = await callRes.json();
|
|
155
|
+
if (callJsonRaw && typeof callJsonRaw === 'object') {
|
|
156
|
+
const callJson = callJsonRaw;
|
|
157
|
+
if ('error' in callJson && callJson.error) {
|
|
158
|
+
console.warn(`tools/call ${name} returned error: ${JSON.stringify(callJson.error).slice(0, 200)}`);
|
|
159
|
+
}
|
|
160
|
+
else {
|
|
161
|
+
console.info(`tools/call ${name} OK (result truncated): ${JSON.stringify(callJson.result).slice(0, 200)}`);
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
else {
|
|
165
|
+
console.info(`tools/call ${name} OK (result truncated): ${JSON.stringify(callJsonRaw).slice(0, 200)}`);
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
console.info('MCP client tests completed successfully');
|
|
169
|
+
return;
|
|
170
|
+
}
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import { strict as assert } from 'assert';
|
|
2
|
+
import { normalizeToTransactionArray, normalizeToId, normalizeImportResult } from './lib/actual-adapter.js';
|
|
3
|
+
async function runAdapterTests() {
|
|
4
|
+
// normalizeToTransactionArray
|
|
5
|
+
const single = { id: 't1', amount: 100 };
|
|
6
|
+
const arrObjs = [{ id: 't1' }, { id: 't2' }];
|
|
7
|
+
const idList = ['t1', 't2'];
|
|
8
|
+
const r1 = normalizeToTransactionArray(single);
|
|
9
|
+
assert.equal(Array.isArray(r1), true);
|
|
10
|
+
assert.equal(r1.length, 1);
|
|
11
|
+
assert.equal(r1[0].id, 't1');
|
|
12
|
+
const txArr = normalizeToTransactionArray(arrObjs);
|
|
13
|
+
assert.equal(txArr.length, 2);
|
|
14
|
+
assert.equal(txArr[1].id, 't2');
|
|
15
|
+
const txFromIds = normalizeToTransactionArray(idList);
|
|
16
|
+
assert.equal(txFromIds.length, 2);
|
|
17
|
+
assert.equal(txFromIds[0].id, 't1');
|
|
18
|
+
const txEmpty = normalizeToTransactionArray(null);
|
|
19
|
+
assert.equal(Array.isArray(txEmpty), true);
|
|
20
|
+
assert.equal(txEmpty.length, 0);
|
|
21
|
+
// normalizeToId
|
|
22
|
+
assert.equal(normalizeToId('abc'), 'abc');
|
|
23
|
+
assert.equal(normalizeToId({ id: 'xyz' }), 'xyz');
|
|
24
|
+
assert.equal(normalizeToId(['first', 'second']), 'first');
|
|
25
|
+
assert.equal(normalizeToId(null), '');
|
|
26
|
+
// normalizeImportResult
|
|
27
|
+
const raw = { added: ['a'], updated: ['b'], errors: ['e'] };
|
|
28
|
+
const imp = normalizeImportResult(raw);
|
|
29
|
+
assert.deepEqual(imp.added, ['a']);
|
|
30
|
+
assert.deepEqual(imp.updated, ['b']);
|
|
31
|
+
assert.deepEqual(imp.errors, ['e']);
|
|
32
|
+
const imp2 = normalizeImportResult(null);
|
|
33
|
+
assert.deepEqual(imp2.added, []);
|
|
34
|
+
assert.deepEqual(imp2.updated, []);
|
|
35
|
+
assert.deepEqual(imp2.errors, []);
|
|
36
|
+
console.log('✅ Adapter normalization tests passed');
|
|
37
|
+
}
|
|
38
|
+
runAdapterTests().catch((e) => {
|
|
39
|
+
console.error('Adapter tests failed:', e);
|
|
40
|
+
process.exit(2);
|
|
41
|
+
});
|
|
42
|
+
// Concurrency and retry tests
|
|
43
|
+
import { callWithRetry, getConcurrencyState, setMaxConcurrency } from './lib/actual-adapter.js';
|
|
44
|
+
import retry from './lib/retry.js';
|
|
45
|
+
async function runConcurrencyAndRetryTests() {
|
|
46
|
+
console.log('Running concurrency and retry tests...');
|
|
47
|
+
// Concurrency test: set max concurrency to 1 and start 3 tasks that resolve after a delay.
|
|
48
|
+
setMaxConcurrency(1);
|
|
49
|
+
const task = (id, delayMs = 50) => async () => {
|
|
50
|
+
await new Promise(r => setTimeout(r, delayMs));
|
|
51
|
+
return `done-${id}`;
|
|
52
|
+
};
|
|
53
|
+
const p1 = callWithRetry(task(1, 80));
|
|
54
|
+
const p2 = callWithRetry(task(2, 60));
|
|
55
|
+
const p3 = callWithRetry(task(3, 40));
|
|
56
|
+
// allow microtask scheduling
|
|
57
|
+
await new Promise(r => setTimeout(r, 10));
|
|
58
|
+
const stateDuring = getConcurrencyState();
|
|
59
|
+
if (stateDuring.maxConcurrency !== 1)
|
|
60
|
+
throw new Error('maxConcurrency not set');
|
|
61
|
+
if (stateDuring.running < 1)
|
|
62
|
+
throw new Error('expected at least one running task');
|
|
63
|
+
const results = await Promise.all([p1, p2, p3]);
|
|
64
|
+
if (!results.includes('done-1') || !results.includes('done-2') || !results.includes('done-3')) {
|
|
65
|
+
throw new Error('concurrency tasks did not complete as expected');
|
|
66
|
+
}
|
|
67
|
+
// Retry test: function fails twice then succeeds
|
|
68
|
+
let attempts = 0;
|
|
69
|
+
const flaky = async () => {
|
|
70
|
+
attempts++;
|
|
71
|
+
if (attempts < 3)
|
|
72
|
+
throw new Error('transient');
|
|
73
|
+
return 'ok';
|
|
74
|
+
};
|
|
75
|
+
const res = await callWithRetry(() => retry(flaky, { retries: 3, backoffMs: 5 }));
|
|
76
|
+
if (res !== 'ok')
|
|
77
|
+
throw new Error('retry did not return ok');
|
|
78
|
+
if (attempts !== 3)
|
|
79
|
+
throw new Error(`retry attempts expected 3 but got ${attempts}`);
|
|
80
|
+
console.log('✅ Concurrency and retry tests passed');
|
|
81
|
+
}
|
|
82
|
+
// Run the extra tests after the main suite
|
|
83
|
+
runConcurrencyAndRetryTests().catch(e => {
|
|
84
|
+
console.error('Concurrency/retry tests failed:', e);
|
|
85
|
+
process.exit(2);
|
|
86
|
+
});
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
import adapter from '../lib/actual-adapter.js';
|
|
3
|
+
const InputSchema = z.object({
|
|
4
|
+
id: z.string().describe('Account ID to close'),
|
|
5
|
+
});
|
|
6
|
+
const tool = {
|
|
7
|
+
name: 'actual_accounts_close',
|
|
8
|
+
description: `Mark an account as closed in Actual Budget. Closed accounts are hidden from most views but their transaction history is preserved. Useful for accounts that are no longer active but you want to keep the historical data.`,
|
|
9
|
+
inputSchema: InputSchema,
|
|
10
|
+
call: async (args, _meta) => {
|
|
11
|
+
const input = InputSchema.parse(args || {});
|
|
12
|
+
await adapter.closeAccount(input.id);
|
|
13
|
+
return { success: true };
|
|
14
|
+
},
|
|
15
|
+
};
|
|
16
|
+
export default tool;
|
|
@@ -0,0 +1,27 @@
|
|
|
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
|
+
export default createTool({
|
|
6
|
+
name: 'actual_accounts_create',
|
|
7
|
+
description: 'Create a new account in Actual Budget',
|
|
8
|
+
schema: z.object({
|
|
9
|
+
id: z.string().optional(),
|
|
10
|
+
name: CommonSchemas.name,
|
|
11
|
+
balance: CommonSchemas.optionalAmountCents
|
|
12
|
+
}),
|
|
13
|
+
handler: async (input) => {
|
|
14
|
+
const accountPayload = { id: input.id, name: input.name, balance: input.balance };
|
|
15
|
+
return await adapter.createAccount(accountPayload, input.balance);
|
|
16
|
+
},
|
|
17
|
+
examples: [
|
|
18
|
+
{
|
|
19
|
+
description: 'Create a checking account with $1000 initial balance',
|
|
20
|
+
input: { name: 'Checking', balance: 100000 },
|
|
21
|
+
},
|
|
22
|
+
{
|
|
23
|
+
description: 'Create a savings account with no initial balance',
|
|
24
|
+
input: { name: 'Savings' },
|
|
25
|
+
},
|
|
26
|
+
],
|
|
27
|
+
});
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
import adapter from '../lib/actual-adapter.js';
|
|
3
|
+
const InputSchema = z.object({
|
|
4
|
+
id: z.string().describe('Account ID to delete'),
|
|
5
|
+
});
|
|
6
|
+
const tool = {
|
|
7
|
+
name: 'actual_accounts_delete',
|
|
8
|
+
description: 'Delete an account from Actual Budget. Note: The account must not have any transactions. This operation cannot be undone.',
|
|
9
|
+
inputSchema: InputSchema,
|
|
10
|
+
call: async (args, _meta) => {
|
|
11
|
+
const input = InputSchema.parse(args || {});
|
|
12
|
+
await adapter.deleteAccount(input.id);
|
|
13
|
+
return { success: true };
|
|
14
|
+
},
|
|
15
|
+
};
|
|
16
|
+
export default tool;
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
import adapter from '../lib/actual-adapter.js';
|
|
3
|
+
import { notFoundMsg } from '../lib/errors.js';
|
|
4
|
+
const InputSchema = z.object({
|
|
5
|
+
id: z.string().min(1, 'Account ID is required').describe('The UUID of the account'),
|
|
6
|
+
cutoff: z.string().optional().describe('Optional cutoff date (YYYY-MM-DD format)')
|
|
7
|
+
}).strict();
|
|
8
|
+
const tool = {
|
|
9
|
+
name: 'actual_accounts_get_balance',
|
|
10
|
+
description: `Get the current balance of a specific account.
|
|
11
|
+
|
|
12
|
+
Required:
|
|
13
|
+
- id: Account UUID
|
|
14
|
+
|
|
15
|
+
Optional:
|
|
16
|
+
- cutoff: Date to calculate balance up to (YYYY-MM-DD format)
|
|
17
|
+
|
|
18
|
+
Returns the account balance as a number (in cents), or an error if the account does not exist.
|
|
19
|
+
|
|
20
|
+
Example:
|
|
21
|
+
{
|
|
22
|
+
"id": "791f738b-847b-48cc-b32b-bbcf2bc8314f"
|
|
23
|
+
}`,
|
|
24
|
+
inputSchema: InputSchema,
|
|
25
|
+
call: async (args, _meta) => {
|
|
26
|
+
const input = InputSchema.parse(args || {});
|
|
27
|
+
// Pre-flight: verify account exists (BUG-5)
|
|
28
|
+
const accounts = await adapter.getAccounts();
|
|
29
|
+
const account = accounts.find((a) => a.id === input.id);
|
|
30
|
+
if (!account) {
|
|
31
|
+
return {
|
|
32
|
+
error: notFoundMsg('Account', input.id, 'actual_accounts_list'),
|
|
33
|
+
balance: null,
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
const result = await adapter.getAccountBalance(input.id, input.cutoff);
|
|
37
|
+
return { balance: result, accountName: account.name };
|
|
38
|
+
},
|
|
39
|
+
};
|
|
40
|
+
export default tool;
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
import adapter from '../lib/actual-adapter.js';
|
|
3
|
+
const InputSchema = z.object({});
|
|
4
|
+
const tool = {
|
|
5
|
+
name: 'actual_accounts_list',
|
|
6
|
+
description: "List all accounts in Actual Budget including checking, savings, credit cards, and investment accounts. Returns account ID, name, balance (in cents), on-budget/off-budget status, and open/closed state.",
|
|
7
|
+
inputSchema: InputSchema,
|
|
8
|
+
call: async (args, _meta) => {
|
|
9
|
+
// Single API session: getAccounts + all balances in one withActualApi cycle.
|
|
10
|
+
// Calling getAccountBalance() per-account would open N separate sessions and
|
|
11
|
+
// overwhelm the Actual server with concurrent init/shutdown cycles.
|
|
12
|
+
const result = await adapter.getAccountsWithBalances();
|
|
13
|
+
return { result };
|
|
14
|
+
},
|
|
15
|
+
};
|
|
16
|
+
export default tool;
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
import adapter from '../lib/actual-adapter.js';
|
|
3
|
+
const InputSchema = z.object({
|
|
4
|
+
id: z.string().describe('Account ID to reopen'),
|
|
5
|
+
});
|
|
6
|
+
const tool = {
|
|
7
|
+
name: 'actual_accounts_reopen',
|
|
8
|
+
description: `Reopen a previously closed account in Actual Budget. The account will become active again and visible in all views. All historical transactions remain intact.`,
|
|
9
|
+
inputSchema: InputSchema,
|
|
10
|
+
call: async (args, _meta) => {
|
|
11
|
+
const input = InputSchema.parse(args || {});
|
|
12
|
+
await adapter.reopenAccount(input.id);
|
|
13
|
+
return { success: true };
|
|
14
|
+
},
|
|
15
|
+
};
|
|
16
|
+
export default tool;
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
import { CommonSchemas } from '../lib/schemas/common.js';
|
|
3
|
+
import adapter from '../lib/actual-adapter.js';
|
|
4
|
+
const InputSchema = z.object({
|
|
5
|
+
id: CommonSchemas.accountId,
|
|
6
|
+
fields: z.object({
|
|
7
|
+
name: CommonSchemas.name.optional(),
|
|
8
|
+
offbudget: CommonSchemas.offBudget,
|
|
9
|
+
closed: CommonSchemas.closed,
|
|
10
|
+
}).strict().optional().describe('Fields to update - only recognized fields allowed'),
|
|
11
|
+
});
|
|
12
|
+
const tool = {
|
|
13
|
+
name: 'actual_accounts_update',
|
|
14
|
+
description: `Update an account's properties in Actual Budget.
|
|
15
|
+
|
|
16
|
+
Updatable fields:
|
|
17
|
+
- name: Account name (1-255 chars)
|
|
18
|
+
- offbudget: Exclude from budget (true/false)
|
|
19
|
+
- closed: Mark as closed (true/false)
|
|
20
|
+
|
|
21
|
+
Example: Update account name and offbudget status:
|
|
22
|
+
{
|
|
23
|
+
"id": "<account-uuid>",
|
|
24
|
+
"fields": {
|
|
25
|
+
"name": "Checking Account",
|
|
26
|
+
"offbudget": false
|
|
27
|
+
}
|
|
28
|
+
}`,
|
|
29
|
+
inputSchema: InputSchema,
|
|
30
|
+
call: async (args, _meta) => {
|
|
31
|
+
try {
|
|
32
|
+
const input = InputSchema.parse(args || {});
|
|
33
|
+
if (!input.fields || Object.keys(input.fields).length === 0) {
|
|
34
|
+
throw new Error('No fields provided to update. Include at least one field: name, offbudget, or closed.');
|
|
35
|
+
}
|
|
36
|
+
await adapter.updateAccount(input.id, input.fields);
|
|
37
|
+
return {
|
|
38
|
+
success: true,
|
|
39
|
+
accountId: input.id,
|
|
40
|
+
updatedFields: Object.keys(input.fields),
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
catch (error) {
|
|
44
|
+
if (error instanceof z.ZodError) {
|
|
45
|
+
const fieldErrors = error.issues.map((e) => `${e.path.join('.')}: ${e.message}`).join('; ');
|
|
46
|
+
throw new Error(`Invalid account update data: ${fieldErrors}`);
|
|
47
|
+
}
|
|
48
|
+
throw error;
|
|
49
|
+
}
|
|
50
|
+
},
|
|
51
|
+
};
|
|
52
|
+
export default tool;
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
import adapter from '../lib/actual-adapter.js';
|
|
3
|
+
const InputSchema = z.object({
|
|
4
|
+
accountId: z.string().nullable().optional().describe('Optional account ID to sync a specific account. If omitted, syncs all linked accounts.'),
|
|
5
|
+
});
|
|
6
|
+
const tool = {
|
|
7
|
+
name: 'actual_bank_sync',
|
|
8
|
+
description: 'Trigger 3rd party bank sync (GoCardless, SimpleFIN) for linked bank accounts. Returns immediately with an error if no bank-linked accounts are found. When bank-linked accounts exist, waits up to 30 seconds for the provider to confirm the operation and surfaces errors such as rate limits or auth failures that occur within that window. Successful syncs may take a few additional moments for transactions to appear in Actual Budget.',
|
|
9
|
+
inputSchema: InputSchema,
|
|
10
|
+
call: async (args, _meta) => {
|
|
11
|
+
const input = InputSchema.parse(args || {});
|
|
12
|
+
try {
|
|
13
|
+
await adapter.runBankSync(input.accountId ?? undefined);
|
|
14
|
+
return { result: 'Bank sync initiated successfully' };
|
|
15
|
+
}
|
|
16
|
+
catch (error) {
|
|
17
|
+
const msg = error instanceof Error ? error.message : String(error);
|
|
18
|
+
throw new Error(msg);
|
|
19
|
+
}
|
|
20
|
+
},
|
|
21
|
+
};
|
|
22
|
+
export default tool;
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
import adapter from '../lib/actual-adapter.js';
|
|
3
|
+
import api from '@actual-app/api';
|
|
4
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
5
|
+
const { setBudgetAmount: rawSetBudgetAmount, setBudgetCarryover: rawSetBudgetCarryover } = api;
|
|
6
|
+
const BudgetOperationSchema = z.object({
|
|
7
|
+
month: z.string().regex(/^\d{4}-(0[1-9]|1[0-2])$/, 'month must be in YYYY-MM format (e.g. 2025-01)').describe('Budget month in YYYY-MM format'),
|
|
8
|
+
categoryId: z.string().describe('Category ID'),
|
|
9
|
+
amount: z.number().optional().describe('Budget amount in cents (if setting amount)'),
|
|
10
|
+
carryover: z.boolean().optional().describe('Carryover flag (if setting carryover)'),
|
|
11
|
+
});
|
|
12
|
+
const InputSchema = z.object({
|
|
13
|
+
operations: z.array(BudgetOperationSchema).describe('Array of budget operations to perform in batch'),
|
|
14
|
+
});
|
|
15
|
+
const tool = {
|
|
16
|
+
name: 'actual_budget_updates_batch',
|
|
17
|
+
description: `Perform multiple budget updates in a single batch operation. More efficient than individual calls for bulk budget changes.
|
|
18
|
+
|
|
19
|
+
Use cases:
|
|
20
|
+
- Set budget amounts for multiple months/categories at once
|
|
21
|
+
- Copy budgets across time periods
|
|
22
|
+
- Bulk budget initialization
|
|
23
|
+
|
|
24
|
+
Limits: Recommended max 50 operations per batch to avoid timeouts.
|
|
25
|
+
|
|
26
|
+
Example: Set health insurance budget for 12 months:
|
|
27
|
+
{
|
|
28
|
+
"operations": [
|
|
29
|
+
{"month": "2025-01", "categoryId": "<uuid>", "amount": 50000},
|
|
30
|
+
{"month": "2025-02", "categoryId": "<uuid>", "amount": 50000}
|
|
31
|
+
]
|
|
32
|
+
}`,
|
|
33
|
+
inputSchema: InputSchema,
|
|
34
|
+
call: async (args, _meta) => {
|
|
35
|
+
const input = InputSchema.parse(args || {});
|
|
36
|
+
// Validate operation count
|
|
37
|
+
if (input.operations.length > 100) {
|
|
38
|
+
throw new Error(`Too many operations (${input.operations.length}). Maximum 100 per batch. Split into multiple batches.`);
|
|
39
|
+
}
|
|
40
|
+
if (input.operations.length > 50) {
|
|
41
|
+
console.warn(`Large batch (${input.operations.length} operations). Consider splitting for better reliability.`);
|
|
42
|
+
}
|
|
43
|
+
// Track successes and failures
|
|
44
|
+
const results = { successful: 0, failed: 0, errors: [] };
|
|
45
|
+
// Use batchBudgetUpdates to wrap all operations in a single transaction
|
|
46
|
+
await adapter.batchBudgetUpdates(async () => {
|
|
47
|
+
for (let i = 0; i < input.operations.length; i++) {
|
|
48
|
+
const op = input.operations[i];
|
|
49
|
+
try {
|
|
50
|
+
// Use raw API calls directly to avoid nested queueing/deadlock
|
|
51
|
+
if (op.amount !== undefined) {
|
|
52
|
+
await rawSetBudgetAmount(op.month, op.categoryId, op.amount);
|
|
53
|
+
}
|
|
54
|
+
if (op.carryover !== undefined) {
|
|
55
|
+
await rawSetBudgetCarryover(op.month, op.categoryId, op.carryover);
|
|
56
|
+
}
|
|
57
|
+
results.successful++;
|
|
58
|
+
}
|
|
59
|
+
catch (error) {
|
|
60
|
+
results.failed++;
|
|
61
|
+
const errorMsg = `Operation ${i + 1} failed (month: ${op.month}, category: ${op.categoryId}): ${error.message}`;
|
|
62
|
+
results.errors.push(errorMsg);
|
|
63
|
+
console.error(errorMsg);
|
|
64
|
+
// Continue processing remaining operations
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
});
|
|
68
|
+
return {
|
|
69
|
+
success: results.failed === 0,
|
|
70
|
+
totalOperations: input.operations.length,
|
|
71
|
+
successful: results.successful,
|
|
72
|
+
failed: results.failed,
|
|
73
|
+
errors: results.errors.length > 0 ? results.errors.slice(0, 5) : undefined, // Return first 5 errors
|
|
74
|
+
};
|
|
75
|
+
},
|
|
76
|
+
};
|
|
77
|
+
export default tool;
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
import adapter from '../lib/actual-adapter.js';
|
|
3
|
+
const InputSchema = z.object({ month: z.string().optional() });
|
|
4
|
+
const tool = {
|
|
5
|
+
name: 'actual_budgets_getMonth',
|
|
6
|
+
description: "Get budget data for a specific month in YYYY-MM format (e.g., '2025-12'). Returns all categories with their budgeted amounts, actual spending, and available balances for the month.",
|
|
7
|
+
inputSchema: InputSchema,
|
|
8
|
+
call: async (args, _meta) => {
|
|
9
|
+
const input = InputSchema.parse(args || {});
|
|
10
|
+
const result = await adapter.getBudgetMonth(input.month);
|
|
11
|
+
return { result };
|
|
12
|
+
},
|
|
13
|
+
};
|
|
14
|
+
export default tool;
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
import adapter from '../lib/actual-adapter.js';
|
|
3
|
+
const InputSchema = z.object({});
|
|
4
|
+
const tool = {
|
|
5
|
+
name: 'actual_budgets_getMonths',
|
|
6
|
+
description: "Get a list of all available budget months with summary data. Returns an array of months showing total budgeted, spent, and income for each month. Useful for viewing budget history and trends over time.",
|
|
7
|
+
inputSchema: InputSchema,
|
|
8
|
+
call: async (args, _meta) => {
|
|
9
|
+
InputSchema.parse(args || {});
|
|
10
|
+
const result = await adapter.getBudgetMonths();
|
|
11
|
+
return { result };
|
|
12
|
+
},
|
|
13
|
+
};
|
|
14
|
+
export default tool;
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
import adapter from '../lib/actual-adapter.js';
|
|
3
|
+
const InputSchema = z.object({});
|
|
4
|
+
const tool = {
|
|
5
|
+
name: 'actual_budgets_get_all',
|
|
6
|
+
description: 'Get a list of all available budget files. Useful for multi-budget management and discovering available budgets on the server.',
|
|
7
|
+
inputSchema: InputSchema,
|
|
8
|
+
call: async (_args, _meta) => {
|
|
9
|
+
const result = await adapter.getBudgets();
|
|
10
|
+
return { result };
|
|
11
|
+
},
|
|
12
|
+
};
|
|
13
|
+
export default tool;
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
import adapter from '../lib/actual-adapter.js';
|
|
3
|
+
const InputSchema = z.object({
|
|
4
|
+
month: z.string().regex(/^\d{4}-(0[1-9]|1[0-2])$/, 'month must be in YYYY-MM format').describe('Budget month in YYYY-MM format'),
|
|
5
|
+
amount: z.number().int().describe('Amount in cents to hold for next month'),
|
|
6
|
+
});
|
|
7
|
+
const tool = {
|
|
8
|
+
name: 'actual_budgets_holdForNextMonth',
|
|
9
|
+
description: `Hold an amount from this month's budget to carry into next month. The amount is in cents. This moves money from the current month's available budget into next month.
|
|
10
|
+
|
|
11
|
+
Note: This operates on the month as a whole, not on a specific category.`,
|
|
12
|
+
inputSchema: InputSchema,
|
|
13
|
+
call: async (args, _meta) => {
|
|
14
|
+
const input = InputSchema.parse(args || {});
|
|
15
|
+
await adapter.holdBudgetForNextMonth(input.month, input.amount);
|
|
16
|
+
return { success: true };
|
|
17
|
+
},
|
|
18
|
+
};
|
|
19
|
+
export default tool;
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
import adapter from '../lib/actual-adapter.js';
|
|
3
|
+
const InputSchema = z.object({});
|
|
4
|
+
const tool = {
|
|
5
|
+
name: 'actual_budgets_list_available',
|
|
6
|
+
description: 'List all pre-configured budgets available for switching. ' +
|
|
7
|
+
'Each entry shows the budget name, sync ID, server URL, and whether it uses E2E encryption. ' +
|
|
8
|
+
'Budgets are configured via BUDGET_n_NAME / BUDGET_n_SYNC_ID / BUDGET_n_SERVER_URL env vars. ' +
|
|
9
|
+
'Pass the budget name to actual_budgets_switch to change the active budget.',
|
|
10
|
+
inputSchema: InputSchema,
|
|
11
|
+
call: async (_args) => {
|
|
12
|
+
const budgets = await Promise.resolve(adapter.getBudgetRegistry());
|
|
13
|
+
return {
|
|
14
|
+
budgets,
|
|
15
|
+
count: budgets.length,
|
|
16
|
+
hint: 'Pass the name (or partial name) of the desired budget to actual_budgets_switch.',
|
|
17
|
+
};
|
|
18
|
+
},
|
|
19
|
+
};
|
|
20
|
+
export default tool;
|