actual-mcp-server 0.25.2 → 0.26.1
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 +5 -5
- package/dist/src/actualToolsManager.js +1 -0
- package/dist/src/config.js +4 -0
- package/dist/src/index.js +29 -53
- package/dist/src/lib/actual-adapter.js +122 -75
- package/dist/src/lib/apiLogin.js +7 -1
- package/dist/src/lib/authPosture.js +17 -0
- package/dist/src/lib/budget-registry.js +39 -1
- package/dist/src/lib/constants.js +8 -8
- package/dist/src/lib/mcp-version.js +6 -0
- package/dist/src/lib/processHandlers.js +183 -0
- package/dist/src/lib/processHandlersInstall.js +25 -0
- package/dist/src/lib/rejection-allowlist.js +4 -3
- package/dist/src/lib/schemas/common.js +1 -1
- package/dist/src/lib/tool-annotations.js +5 -4
- package/dist/src/lib/toolsets.js +2 -1
- package/dist/src/server/httpServer.js +7 -4
- package/dist/src/server/stdioServer.js +5 -2
- package/dist/src/tools/bank_sync.js +22 -9
- package/dist/src/tools/budgets_list_available.js +1 -1
- package/dist/src/tools/index.js +1 -0
- package/dist/src/tools/schedules_dates.js +35 -0
- package/dist/src/tools/schedules_update.js +6 -4
- package/package.json +3 -3
|
@@ -25,6 +25,44 @@
|
|
|
25
25
|
* The AI uses `actual_budgets_list_available` to see all configured budgets,
|
|
26
26
|
* then `actual_budgets_switch` with the budget name to switch between them.
|
|
27
27
|
*/
|
|
28
|
+
/**
|
|
29
|
+
* #621: a server URL as it may appear in a log line: scheme, host and port only. URL userinfo
|
|
30
|
+
* (a reverse-proxy credential), path and query are dropped. Never throws; an unparseable URL
|
|
31
|
+
* or one with a `null` origin (a non-special scheme such as a schemeless `user:pass@host`
|
|
32
|
+
* typo) becomes `<invalid url>`. Display text only, for log lines and tool results (#628):
|
|
33
|
+
* its output is never used to connect, and the login itself must keep the full URL.
|
|
34
|
+
*/
|
|
35
|
+
export function logSafeOrigin(url) {
|
|
36
|
+
try {
|
|
37
|
+
const origin = new URL(url).origin;
|
|
38
|
+
return origin === 'null' ? '<invalid url>' : origin;
|
|
39
|
+
}
|
|
40
|
+
catch {
|
|
41
|
+
return '<invalid url>';
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* #621: the startup registry line, counts and schemes only, so no budget name, host or
|
|
46
|
+
* credential reaches the log. Servers are counted by the same [serverUrl, password] key
|
|
47
|
+
* setLoginsSpanServers uses, so the two can never disagree. Never throws.
|
|
48
|
+
*/
|
|
49
|
+
export function formatBudgetRegistrySummary(registry) {
|
|
50
|
+
const budgets = [...registry.values()];
|
|
51
|
+
const servers = new Set(budgets.map((b) => JSON.stringify([b.serverUrl, b.password || '']))).size;
|
|
52
|
+
const schemes = new Set();
|
|
53
|
+
for (const b of budgets) {
|
|
54
|
+
let scheme;
|
|
55
|
+
try {
|
|
56
|
+
const protocol = new URL(b.serverUrl).protocol;
|
|
57
|
+
scheme = protocol === 'http:' ? 'http' : protocol === 'https:' ? 'https' : 'other';
|
|
58
|
+
}
|
|
59
|
+
catch {
|
|
60
|
+
scheme = 'invalid';
|
|
61
|
+
}
|
|
62
|
+
schemes.add(scheme);
|
|
63
|
+
}
|
|
64
|
+
return `Budget registry: ${budgets.length} budget(s) across ${servers} server(s) (${[...schemes].sort().join(', ')})`;
|
|
65
|
+
}
|
|
28
66
|
/**
|
|
29
67
|
* Parse the budget registry from environment variables.
|
|
30
68
|
* Always includes the default budget from the provided defaults (ACTUAL_* vars).
|
|
@@ -62,7 +100,7 @@ export function parseBudgetRegistry(env, defaults) {
|
|
|
62
100
|
// Mirror the default-budget check (#161): never send an E2E encryption
|
|
63
101
|
// password over an http:// upstream unless ALLOW_INSECURE_UPSTREAM is set.
|
|
64
102
|
if (encryptionPassword && env.ALLOW_INSECURE_UPSTREAM !== 'true' && /^http:\/\//i.test(serverUrl)) {
|
|
65
|
-
console.error(`[CONFIG] BUDGET_${i}_ENCRYPTION_PASSWORD is set but the upstream URL is http:// (${serverUrl}). ` +
|
|
103
|
+
console.error(`[CONFIG] BUDGET_${i}_ENCRYPTION_PASSWORD is set but the upstream URL is http:// (${logSafeOrigin(serverUrl)}). ` +
|
|
66
104
|
`Refusing to send the encryption password over plaintext (#161). ` +
|
|
67
105
|
`Use https:// or set ALLOW_INSECURE_UPSTREAM=true to override.`);
|
|
68
106
|
process.exit(1);
|
|
@@ -31,15 +31,11 @@ export const DEFAULT_CONCURRENCY_LIMIT = 5;
|
|
|
31
31
|
// TIMEOUTS
|
|
32
32
|
// ============================================================================
|
|
33
33
|
/**
|
|
34
|
-
* How
|
|
35
|
-
*
|
|
36
|
-
*
|
|
37
|
-
* Bank provider errors (GoCardless RATE_LIMIT_EXCEEDED, auth failures, etc.)
|
|
38
|
-
* arrive as HTTP responses. Fast banks respond within 1-3 seconds; slower
|
|
39
|
-
* institutions can take considerably longer. 30 seconds gives a comfortable
|
|
40
|
-
* margin while keeping the tool's wall-clock time acceptable for MCP clients.
|
|
34
|
+
* How many ids each list of an `actual_bank_sync` report carries (#548). The counts are
|
|
35
|
+
* always the full lengths; only the id lists are cut, so a first sync of a large account
|
|
36
|
+
* cannot flood the client's context. The tool sets `truncated` when any list was cut.
|
|
41
37
|
*/
|
|
42
|
-
export const
|
|
38
|
+
export const BANK_SYNC_IDS_CAP = 100;
|
|
43
39
|
/**
|
|
44
40
|
* How long (ms) to wait for additional queued writes before closing the
|
|
45
41
|
* shared budget session. Increasing this value batches more writes per
|
|
@@ -80,6 +76,10 @@ export const DEFAULT_HTTP_PORT = 3600;
|
|
|
80
76
|
// ============================================================================
|
|
81
77
|
// VALIDATION & LIMITS
|
|
82
78
|
// ============================================================================
|
|
79
|
+
/**
|
|
80
|
+
* #479: the most dates actual_schedules_dates returns, so a daily schedule cannot flood the context.
|
|
81
|
+
*/
|
|
82
|
+
export const SCHEDULE_DATES_CAP = 366;
|
|
83
83
|
/**
|
|
84
84
|
* Maximum length for name fields (accounts, categories, payees)
|
|
85
85
|
*/
|
|
@@ -3,6 +3,12 @@ import { existsSync } from 'node:fs';
|
|
|
3
3
|
import { dirname, join } from 'node:path';
|
|
4
4
|
import { fileURLToPath } from 'node:url';
|
|
5
5
|
import { findRootPackageJson } from './node-version-guard.js';
|
|
6
|
+
/**
|
|
7
|
+
* #624: the MCP initialize `serverInfo.name` on both transports. It equals the package.json
|
|
8
|
+
* name (tests/unit/server_identity.test.js pins that), so a client shows the server under the
|
|
9
|
+
* name it was installed as, never a description sentence.
|
|
10
|
+
*/
|
|
11
|
+
export const SERVER_NAME = 'actual-mcp-server';
|
|
6
12
|
/**
|
|
7
13
|
* #541: the single source of this server's own version, shared by the MCP initialize
|
|
8
14
|
* `serverInfo.version` (src/index.ts) and the `actual_server_info` tool.
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* #622: the process-level unhandledRejection and uncaughtException handlers, as a pure
|
|
3
|
+
* factory so unit tests can drive them without exiting. src/lib/processHandlersInstall.ts
|
|
4
|
+
* registers them on `process`.
|
|
5
|
+
*
|
|
6
|
+
* Records are built from gated fields only (logging convention v1, agreed with
|
|
7
|
+
* actual-sync): `{ type, code?, httpStatus?, category, safeMessage }`. The reason itself,
|
|
8
|
+
* its message, its stack and any upstream `.type` never reach a record or winston, because
|
|
9
|
+
* a rejection reason can carry a request object with an Authorization header, and upstream
|
|
10
|
+
* text can quote account or payee names (#559).
|
|
11
|
+
*
|
|
12
|
+
* INVARIANT: imports only node: builtins, ./errors.js and ./rejection-allowlist.js, never
|
|
13
|
+
* logger.ts or loggerFactory.ts. This module loads before MCP_STDIO_MODE is set, and the
|
|
14
|
+
* logger reads that variable when winston is built.
|
|
15
|
+
*/
|
|
16
|
+
import { errorLogFields } from './errors.js';
|
|
17
|
+
import { isKnownBenignRejection } from './rejection-allowlist.js';
|
|
18
|
+
/** Fixed sentence per category, used when the throw site set no log-safe text. */
|
|
19
|
+
const SAFE_MESSAGES = {
|
|
20
|
+
not_found: 'A requested item was not found.',
|
|
21
|
+
validation: 'A request was rejected as invalid.',
|
|
22
|
+
auth: 'An upstream request was not authorized.',
|
|
23
|
+
timeout: 'An operation timed out.',
|
|
24
|
+
conflict: 'An operation conflicted with existing state.',
|
|
25
|
+
rate_limited: 'An upstream service rate limited the request.',
|
|
26
|
+
upstream_unavailable: 'An upstream service was unavailable.',
|
|
27
|
+
cancelled: 'An operation was cancelled.',
|
|
28
|
+
internal: 'An internal error occurred.',
|
|
29
|
+
};
|
|
30
|
+
const UNAVAILABLE_CODES = new Set(['ECONNREFUSED', 'ECONNRESET', 'ENOTFOUND', 'EAI_AGAIN', 'EPIPE']);
|
|
31
|
+
/** Closed mapping, first match wins. Never reads message text. */
|
|
32
|
+
export function categorizeError({ errorClass, refusalKind, code, httpStatus: s }) {
|
|
33
|
+
if (refusalKind === 'not-found')
|
|
34
|
+
return 'not_found';
|
|
35
|
+
if (refusalKind === 'out-of-range')
|
|
36
|
+
return 'validation';
|
|
37
|
+
if (s === 401 || s === 403)
|
|
38
|
+
return 'auth';
|
|
39
|
+
if (s === 404)
|
|
40
|
+
return 'not_found';
|
|
41
|
+
if (s === 408 || s === 504 || code === 'ETIMEDOUT')
|
|
42
|
+
return 'timeout';
|
|
43
|
+
if (s === 409)
|
|
44
|
+
return 'conflict';
|
|
45
|
+
if (s === 429)
|
|
46
|
+
return 'rate_limited';
|
|
47
|
+
if (s === 400 || s === 422)
|
|
48
|
+
return 'validation';
|
|
49
|
+
if ((s !== undefined && s >= 500 && s <= 599) || (code !== undefined && UNAVAILABLE_CODES.has(code))) {
|
|
50
|
+
return 'upstream_unavailable';
|
|
51
|
+
}
|
|
52
|
+
// The Node AbortError class (its ABORT_ERR code fails the E-shape gate). A fetch abort is a
|
|
53
|
+
// DOMException and falls to internal by design: this keys on the class, never on `name`.
|
|
54
|
+
if (errorClass === 'AbortError')
|
|
55
|
+
return 'cancelled';
|
|
56
|
+
return 'internal';
|
|
57
|
+
}
|
|
58
|
+
function isHttpStatus(v) {
|
|
59
|
+
return typeof v === 'number' && Number.isInteger(v) && v >= 100 && v <= 599;
|
|
60
|
+
}
|
|
61
|
+
function readHttpStatus(reason) {
|
|
62
|
+
if (typeof reason !== 'object' || reason === null)
|
|
63
|
+
return undefined;
|
|
64
|
+
const r = reason;
|
|
65
|
+
if (isHttpStatus(r.status))
|
|
66
|
+
return r.status;
|
|
67
|
+
if (isHttpStatus(r.statusCode))
|
|
68
|
+
return r.statusCode;
|
|
69
|
+
const response = r.response;
|
|
70
|
+
if (typeof response === 'object' && response !== null && isHttpStatus(response.status))
|
|
71
|
+
return response.status;
|
|
72
|
+
return undefined;
|
|
73
|
+
}
|
|
74
|
+
/**
|
|
75
|
+
* The gated error object of a record. `type` is the prototype class name, never the
|
|
76
|
+
* reason's own `.type` property (that can carry CR/LF from upstream). Can throw when the
|
|
77
|
+
* reason has a hostile getter; the handlers catch that and write the fixed fallback.
|
|
78
|
+
*/
|
|
79
|
+
export function errorRecordFields(reason) {
|
|
80
|
+
const { errorClass, refusalKind, code, logMessage } = errorLogFields(reason);
|
|
81
|
+
const httpStatus = readHttpStatus(reason);
|
|
82
|
+
const category = categorizeError({ errorClass, refusalKind, code, httpStatus });
|
|
83
|
+
const record = { type: errorClass, category, safeMessage: logMessage ?? SAFE_MESSAGES[category] };
|
|
84
|
+
if (code !== undefined)
|
|
85
|
+
record.code = code;
|
|
86
|
+
if (httpStatus !== undefined)
|
|
87
|
+
record.httpStatus = httpStatus;
|
|
88
|
+
return record;
|
|
89
|
+
}
|
|
90
|
+
const NODE_FLOOR_HINT = [
|
|
91
|
+
`This almost certainly means Node ${process.version} is too old.`,
|
|
92
|
+
'actual-mcp-server requires the Node version in package.json "engines".',
|
|
93
|
+
'Import attributes (`with { type: "json" }`) need Node 22 or newer.',
|
|
94
|
+
];
|
|
95
|
+
/**
|
|
96
|
+
* Upstream raises bank sync failures as plain Errors with no code, so message text is the
|
|
97
|
+
* only signal. This is a documented exception to the no-text-matching rule: the text only
|
|
98
|
+
* decides survive versus exit, and is never logged.
|
|
99
|
+
*/
|
|
100
|
+
function isBankSyncError(error) {
|
|
101
|
+
const errMsg = String(error?.message || error);
|
|
102
|
+
const errType = error?.type;
|
|
103
|
+
return (errType === 'BankSyncError' ||
|
|
104
|
+
errMsg.includes('BankSyncError') ||
|
|
105
|
+
errMsg.includes('NORDIGEN_ERROR') ||
|
|
106
|
+
errMsg.includes('RATE_LIMIT_EXCEEDED') ||
|
|
107
|
+
errMsg.includes('Rate limit exceeded') ||
|
|
108
|
+
errMsg.includes('Failed syncing account') ||
|
|
109
|
+
errMsg.includes('GoCardless') ||
|
|
110
|
+
errMsg.includes('SimpleFIN'));
|
|
111
|
+
}
|
|
112
|
+
export function createProcessHandlers(deps) {
|
|
113
|
+
const { exit, writeSync, getLogger } = deps;
|
|
114
|
+
// Fatal: the one synchronous record, then exit 1 even when the write fails. Never also
|
|
115
|
+
// through winston, which under stdio already writes to stderr (one record per event).
|
|
116
|
+
function crash(source, error) {
|
|
117
|
+
try {
|
|
118
|
+
writeSync(2, JSON.stringify({ level: 'error', event: 'sys_crash', source, error }) + '\n');
|
|
119
|
+
}
|
|
120
|
+
catch {
|
|
121
|
+
// stderr is a full or closed pipe: nothing more can be written.
|
|
122
|
+
}
|
|
123
|
+
finally {
|
|
124
|
+
exit(1);
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
function fallback(source) {
|
|
128
|
+
crash(source, { type: 'unknown', category: 'internal', safeMessage: SAFE_MESSAGES.internal });
|
|
129
|
+
}
|
|
130
|
+
function survive(source, event, message, error) {
|
|
131
|
+
const logger = getLogger();
|
|
132
|
+
if (logger) {
|
|
133
|
+
logger.warn(message, { event, source, error });
|
|
134
|
+
}
|
|
135
|
+
else {
|
|
136
|
+
writeSync(2, JSON.stringify({ level: 'warn', event, source, error }) + '\n');
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
function writeNodeFloorHint(error) {
|
|
140
|
+
if (error.code !== 'ERR_IMPORT_ASSERTION_TYPE_MISSING')
|
|
141
|
+
return;
|
|
142
|
+
for (const line of NODE_FLOOR_HINT)
|
|
143
|
+
writeSync(2, line + '\n');
|
|
144
|
+
}
|
|
145
|
+
function onUnhandledRejection(reason) {
|
|
146
|
+
try {
|
|
147
|
+
const error = errorRecordFields(reason);
|
|
148
|
+
// #275 backstop: a library consumer reaches neither entry point's guard, and import
|
|
149
|
+
// attributes fail asynchronously on an under-floor Node, so name the real cause.
|
|
150
|
+
writeNodeFloorHint(error);
|
|
151
|
+
if (isKnownBenignRejection(reason)) {
|
|
152
|
+
survive('unhandledRejection', 'process.rejection_benign', 'Process-level rejection survived', error);
|
|
153
|
+
return;
|
|
154
|
+
}
|
|
155
|
+
crash('unhandledRejection', error);
|
|
156
|
+
}
|
|
157
|
+
catch {
|
|
158
|
+
fallback('unhandledRejection');
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
function onUncaughtException(reason) {
|
|
162
|
+
try {
|
|
163
|
+
const error = errorRecordFields(reason);
|
|
164
|
+
writeNodeFloorHint(error);
|
|
165
|
+
// GoCardless and SimpleFIN failures can surface here from inside @actual-app/api when
|
|
166
|
+
// the provider answers asynchronously (RATE_LIMIT_EXCEEDED, for example). Non-fatal.
|
|
167
|
+
if (isBankSyncError(reason)) {
|
|
168
|
+
if (error.category === 'internal') {
|
|
169
|
+
error.category = 'upstream_unavailable';
|
|
170
|
+
if (error.safeMessage === SAFE_MESSAGES.internal)
|
|
171
|
+
error.safeMessage = SAFE_MESSAGES.upstream_unavailable;
|
|
172
|
+
}
|
|
173
|
+
survive('uncaughtException', 'bank_sync.exception_benign', 'Bank sync error survived', error);
|
|
174
|
+
return;
|
|
175
|
+
}
|
|
176
|
+
crash('uncaughtException', error);
|
|
177
|
+
}
|
|
178
|
+
catch {
|
|
179
|
+
fallback('uncaughtException');
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
return { onUnhandledRejection, onUncaughtException };
|
|
183
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* #622: side-effect module that registers the process-level handlers. src/index.ts imports
|
|
3
|
+
* it SECOND, right after ./node-version-guard.js (#275), so the handlers are armed before
|
|
4
|
+
* any other module evaluates. The logger arrives later through attachProcessLogger, once
|
|
5
|
+
* src/index.ts has set MCP_STDIO_MODE and loaded src/logger.ts; until then the handlers
|
|
6
|
+
* write one fixed JSON line to stderr.
|
|
7
|
+
*
|
|
8
|
+
* INVARIANT: no import of logger.ts or loggerFactory.ts (see processHandlers.ts).
|
|
9
|
+
*/
|
|
10
|
+
import { writeSync } from 'node:fs';
|
|
11
|
+
import { createProcessHandlers } from './processHandlers.js';
|
|
12
|
+
let attached;
|
|
13
|
+
/** Route the survivable records through the logger from now on. */
|
|
14
|
+
export function attachProcessLogger(logger) {
|
|
15
|
+
attached = logger;
|
|
16
|
+
}
|
|
17
|
+
const handlers = createProcessHandlers({
|
|
18
|
+
exit: (code) => process.exit(code),
|
|
19
|
+
writeSync: (fd, text) => {
|
|
20
|
+
writeSync(fd, text);
|
|
21
|
+
},
|
|
22
|
+
getLogger: () => attached,
|
|
23
|
+
});
|
|
24
|
+
process.on('unhandledRejection', handlers.onUnhandledRejection);
|
|
25
|
+
process.on('uncaughtException', handlers.onUncaughtException);
|
|
@@ -7,9 +7,10 @@
|
|
|
7
7
|
*
|
|
8
8
|
* INVARIANT: this module MUST remain side-effect-free.
|
|
9
9
|
*
|
|
10
|
-
* src/
|
|
11
|
-
*
|
|
12
|
-
*
|
|
10
|
+
* src/lib/processHandlersInstall.ts (the second import of src/index.ts, #622)
|
|
11
|
+
* registers the process.on('unhandledRejection', ...) handler AFTER importing
|
|
12
|
+
* this module through processHandlers.ts (ESM static imports are evaluated
|
|
13
|
+
* before the top-level body runs). Any side effect introduced here, e.g. importing a
|
|
13
14
|
* logger that performs winston/file-handle init, importing dotenv, or any
|
|
14
15
|
* code that runs at module load, opens a window during ESM evaluation in
|
|
15
16
|
* which an unhandled rejection bypasses this allow-list and crashes the
|
|
@@ -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 85 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';
|
|
@@ -44,13 +44,13 @@ const OPEN_WORLD = new Set(['actual_bank_sync']);
|
|
|
44
44
|
* `tests/unit/tool_annotations.test.js`, which resolves each tool's `adapter.*` calls and
|
|
45
45
|
* fails if one of these reaches `queueWriteOperation`.
|
|
46
46
|
*
|
|
47
|
-
* Claimed only where it is certain. Three
|
|
47
|
+
* Claimed only where it is certain. Three tools are deliberately absent because
|
|
48
48
|
* "does not modify its environment" is false for them, and a wrong `true` here is worse
|
|
49
49
|
* than no annotation at all:
|
|
50
50
|
*
|
|
51
|
-
* actual_bank_sync
|
|
52
|
-
*
|
|
53
|
-
*
|
|
51
|
+
* actual_bank_sync IMPORTS transactions through `withActualApiWrite` (one
|
|
52
|
+
* upstream call, not the write queue, #548), so a queue-based
|
|
53
|
+
* reading of the call graph misses it.
|
|
54
54
|
* actual_budgets_export writes a zip into the server's data directory.
|
|
55
55
|
* actual_budgets_switch changes the session's active budget and persists a preference.
|
|
56
56
|
* actual_session_close closes a pooled connection: server state, not budget data.
|
|
@@ -77,6 +77,7 @@ const READ_ONLY = new Set([
|
|
|
77
77
|
'actual_preferences_get',
|
|
78
78
|
'actual_query_run',
|
|
79
79
|
'actual_rules_get',
|
|
80
|
+
'actual_schedules_dates',
|
|
80
81
|
'actual_schedules_get',
|
|
81
82
|
'actual_server_get_version',
|
|
82
83
|
'actual_server_info',
|
package/dist/src/lib/toolsets.js
CHANGED
|
@@ -94,6 +94,7 @@ export const TOOLSETS = {
|
|
|
94
94
|
],
|
|
95
95
|
schedules: [
|
|
96
96
|
'actual_schedules_get',
|
|
97
|
+
'actual_schedules_dates',
|
|
97
98
|
'actual_schedules_create',
|
|
98
99
|
'actual_schedules_update',
|
|
99
100
|
'actual_schedules_delete',
|
|
@@ -170,7 +171,7 @@ export const PRESET_NAMES = Object.keys(PRESETS);
|
|
|
170
171
|
*
|
|
171
172
|
* Derived by hand from the adapter call graph: the 43 tools whose adapter path reaches
|
|
172
173
|
* queueWriteOperation, plus the four that mutate WITHOUT the write queue (bank_sync imports
|
|
173
|
-
* transactions through
|
|
174
|
+
* transactions through withActualApiWrite, budgets_export writes a zip to ACTUAL_EXPORT_DIR,
|
|
174
175
|
* budgets_switch changes the active budget, session_close closes a pooled connection).
|
|
175
176
|
* Never derive this from tool annotations.
|
|
176
177
|
*
|
|
@@ -5,6 +5,8 @@ import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/
|
|
|
5
5
|
import { ListToolsRequestSchema, CallToolRequestSchema, } from '@modelcontextprotocol/sdk/types.js';
|
|
6
6
|
import logger, { resolveRequestId } from '../logger.js';
|
|
7
7
|
import { attachTransportErrorLogger } from '../lib/transportErrorLogger.js';
|
|
8
|
+
import { SERVER_NAME } from '../lib/mcp-version.js';
|
|
9
|
+
import { createModuleLogger } from '../lib/loggerFactory.js';
|
|
8
10
|
import { getLocalIp } from '../utils.js';
|
|
9
11
|
import actualToolsManager from '../actualToolsManager.js';
|
|
10
12
|
import { getConnectionState, connectToActualForSession, shutdownActualForSession, shutdownActual, admitSession, releaseAdmission, isConnectionPoolEnabled } from '../actualConnection.js';
|
|
@@ -43,6 +45,7 @@ import { buildToolListEntries } from '../lib/tool-list-entry.js';
|
|
|
43
45
|
import { classifyInitFailure } from '../lib/init-failure.js';
|
|
44
46
|
import { createHttpErrorHandler } from './httpErrorHandler.js';
|
|
45
47
|
export { classifyInitFailure };
|
|
48
|
+
const httpLog = createModuleLogger('HTTP');
|
|
46
49
|
export { requestContext };
|
|
47
50
|
// Resolve the authenticated principal for the per-principal budget preference
|
|
48
51
|
// (#189). OIDC: the verified JWT subject. A MCP_STATIC_TOKENS token (#572): its
|
|
@@ -486,7 +489,7 @@ advertisedUrl) {
|
|
|
486
489
|
capabilities: capabilitiesObj,
|
|
487
490
|
};
|
|
488
491
|
const server = new Server({
|
|
489
|
-
name:
|
|
492
|
+
name: SERVER_NAME,
|
|
490
493
|
version: version || "0.1.0",
|
|
491
494
|
}, serverOptions);
|
|
492
495
|
attachTransportErrorLogger(server, 'http');
|
|
@@ -1124,9 +1127,9 @@ advertisedUrl) {
|
|
|
1124
1127
|
// #239: when bound to a specific (non all-interfaces) host the server is NOT on
|
|
1125
1128
|
// loopback, so point the health hint at the actual bind host instead of localhost.
|
|
1126
1129
|
const healthHost = (!bindHost || bindHost === '0.0.0.0' || bindHost === '::') ? 'localhost' : bindHost;
|
|
1127
|
-
|
|
1128
|
-
console.info
|
|
1129
|
-
|
|
1130
|
+
const health = `${scheme}://${healthHost}:${port}/health`;
|
|
1131
|
+
// #625: one structured record through the module logger instead of three console.info lines.
|
|
1132
|
+
httpLog.info(`MCP Streamable HTTP Server listening on ${port}; MCP endpoint: ${advertised} (health: ${health})`, { port, endpoint: advertised, health });
|
|
1130
1133
|
if (config.AUTH_PROVIDER === 'oidc') {
|
|
1131
1134
|
logger.info(`🔒 OIDC authentication enabled (JWT Bearer token required; issuer: ${config.OIDC_ISSUER})`);
|
|
1132
1135
|
// #345: name the userNames the ACL can match, once, at boot. Deliberately
|
|
@@ -5,6 +5,7 @@ import { ListToolsRequestSchema, CallToolRequestSchema } from '@modelcontextprot
|
|
|
5
5
|
import { randomUUID } from 'node:crypto';
|
|
6
6
|
import logger from '../logger.js';
|
|
7
7
|
import { attachTransportErrorLogger } from '../lib/transportErrorLogger.js';
|
|
8
|
+
import { SERVER_NAME } from '../lib/mcp-version.js';
|
|
8
9
|
import actualToolsManager from '../actualToolsManager.js';
|
|
9
10
|
import { requestContext } from '../lib/requestContext.js';
|
|
10
11
|
import { buildToolListEntries } from '../lib/tool-list-entry.js';
|
|
@@ -85,7 +86,9 @@ export function createStdioCallToolHandler(mcp, stdioSessionId) {
|
|
|
85
86
|
}
|
|
86
87
|
};
|
|
87
88
|
}
|
|
88
|
-
export async function startStdioServer(mcp, capabilities, implementedTools,
|
|
89
|
+
export async function startStdioServer(mcp, capabilities, implementedTools,
|
|
90
|
+
// #624: no longer the serverInfo name; kept so the positional signature matches HTTP.
|
|
91
|
+
_serverDescription, instructions, toolSchemas, version) {
|
|
89
92
|
const toolsList = Array.isArray(implementedTools) ? implementedTools : [];
|
|
90
93
|
// #348: a synthetic session identity for this stdio process.
|
|
91
94
|
//
|
|
@@ -108,7 +111,7 @@ export async function startStdioServer(mcp, capabilities, implementedTools, serv
|
|
|
108
111
|
// restarts and can never collide with an HTTP session id.
|
|
109
112
|
const stdioSessionId = `stdio-${randomUUID()}`;
|
|
110
113
|
logger.debug(`[STDIO] session id ${stdioSessionId}`);
|
|
111
|
-
const server = new Server({ name:
|
|
114
|
+
const server = new Server({ name: SERVER_NAME, version: version || '0.1.0' }, { capabilities, instructions });
|
|
112
115
|
attachTransportErrorLogger(server, 'stdio');
|
|
113
116
|
// List tools handler — mirrors createServerInstance() in httpServer.ts
|
|
114
117
|
server.setRequestHandler(ListToolsRequestSchema, async () => {
|
|
@@ -1,11 +1,17 @@
|
|
|
1
1
|
import { z } from 'zod';
|
|
2
2
|
import adapter from '../lib/actual-adapter.js';
|
|
3
|
+
import { BANK_SYNC_IDS_CAP } from '../lib/constants.js';
|
|
3
4
|
const InputSchema = z.object({
|
|
4
5
|
accountId: z.string().nullable().optional().describe('Optional account ID to sync a specific account. If omitted, syncs all linked accounts.'),
|
|
5
6
|
});
|
|
6
7
|
const tool = {
|
|
7
8
|
name: 'actual_bank_sync',
|
|
8
|
-
description: '
|
|
9
|
+
description: 'Run 3rd party bank sync (GoCardless, SimpleFIN) for one linked account, or for every linked open account when accountId is omitted. ' +
|
|
10
|
+
'Refuses at once for a local, closed, half-linked or unknown account, or when no open account is linked. ' +
|
|
11
|
+
'Reports what Actual reconciled: newTransactions (imported), matchedTransactions (existing ones updated) and updatedAccounts (accounts that received new transactions), ' +
|
|
12
|
+
`each as { count, ids } with up to ${BANK_SYNC_IDS_CAP} ids (count is always the full total; truncated is true when any list was cut). ` +
|
|
13
|
+
'Never retried. A sync that runs past the server operation timeout reports "timed out" and may still have imported, so re-list transactions before retrying. ' +
|
|
14
|
+
'An all-accounts sync that fails on one account may still have imported the others; those imports reach the Actual server with the next write in this session or when it closes, so other clients may not see them yet.',
|
|
9
15
|
inputSchema: InputSchema,
|
|
10
16
|
call: async (args, _meta) => {
|
|
11
17
|
const input = InputSchema.parse(args || {});
|
|
@@ -17,14 +23,21 @@ const tool = {
|
|
|
17
23
|
if (input.accountId) {
|
|
18
24
|
await adapter.resolveFilterId('account', input.accountId, { verifyExists: true });
|
|
19
25
|
}
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
}
|
|
26
|
+
// #548: rethrow the adapter's error as is, so a typed refusal keeps its brand.
|
|
27
|
+
const report = await adapter.runBankSync(input.accountId ?? undefined);
|
|
28
|
+
let truncated = false;
|
|
29
|
+
const capped = (ids) => {
|
|
30
|
+
if (ids.length > BANK_SYNC_IDS_CAP)
|
|
31
|
+
truncated = true;
|
|
32
|
+
return { count: ids.length, ids: ids.slice(0, BANK_SYNC_IDS_CAP) };
|
|
33
|
+
};
|
|
34
|
+
return {
|
|
35
|
+
result: 'Bank sync completed',
|
|
36
|
+
newTransactions: capped(report.newTransactions),
|
|
37
|
+
matchedTransactions: capped(report.matchedTransactions),
|
|
38
|
+
updatedAccounts: capped(report.updatedAccounts),
|
|
39
|
+
truncated,
|
|
40
|
+
};
|
|
28
41
|
},
|
|
29
42
|
};
|
|
30
43
|
export default tool;
|
|
@@ -4,7 +4,7 @@ const InputSchema = z.object({});
|
|
|
4
4
|
const tool = {
|
|
5
5
|
name: 'actual_budgets_list_available',
|
|
6
6
|
description: 'List the pre-configured budgets this session may switch to (a session limited to some budgets sees only those). ' +
|
|
7
|
-
'Each entry shows the budget name, sync ID, server
|
|
7
|
+
'Each entry shows the budget name, sync ID, server origin, and whether it uses E2E encryption. ' +
|
|
8
8
|
'Budgets are configured via BUDGET_n_NAME / BUDGET_n_SYNC_ID / BUDGET_n_SERVER_URL env vars. ' +
|
|
9
9
|
'Pass the budget name to actual_budgets_switch to change the active budget.',
|
|
10
10
|
inputSchema: InputSchema,
|
package/dist/src/tools/index.js
CHANGED
|
@@ -52,6 +52,7 @@ export { default as rules_delete } from './rules_delete.js';
|
|
|
52
52
|
export { default as rules_get } from './rules_get.js';
|
|
53
53
|
export { default as rules_update } from './rules_update.js';
|
|
54
54
|
export { default as schedules_create } from './schedules_create.js';
|
|
55
|
+
export { default as schedules_dates } from './schedules_dates.js';
|
|
55
56
|
export { default as schedules_delete } from './schedules_delete.js';
|
|
56
57
|
export { default as schedules_get } from './schedules_get.js';
|
|
57
58
|
export { default as schedules_update } from './schedules_update.js';
|
|
@@ -0,0 +1,35 @@
|
|
|
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
|
+
import { SCHEDULE_DATES_CAP } from '../lib/constants.js';
|
|
6
|
+
export default createTool({
|
|
7
|
+
name: 'actual_schedules_dates',
|
|
8
|
+
description: 'List the dates one schedule falls on, computed by Actual itself, so month ends, patterns ' +
|
|
9
|
+
'("last Friday"), intervals and the weekend move match the Actual UI. Use this instead of ' +
|
|
10
|
+
'expanding a schedule\'s recurrence yourself. Get schedule ids from actual_schedules_get. ' +
|
|
11
|
+
'`start` defaults to today; pass it whenever you pass `end`. With `end` and `count` both set, the list stops at whichever ' +
|
|
12
|
+
`comes first. At most ${SCHEDULE_DATES_CAP} dates are returned. \`truncated\` is true when more dates exist ` +
|
|
13
|
+
'after the last one returned (within `end`, if given): ask again with a later `start` to continue. ' +
|
|
14
|
+
'Returns { schedule_id, name, dates (ascending YYYY-MM-DD), count, truncated }.',
|
|
15
|
+
schema: z.object({
|
|
16
|
+
schedule_id: CommonSchemas.scheduleId,
|
|
17
|
+
start: CommonSchemas.date.optional().describe('First date to consider (YYYY-MM-DD); defaults to today'),
|
|
18
|
+
end: CommonSchemas.date.optional().describe('Last date to consider (YYYY-MM-DD), inclusive'),
|
|
19
|
+
count: z.number().int().min(1).max(SCHEDULE_DATES_CAP).optional()
|
|
20
|
+
.describe(`How many dates to return, 1 to ${SCHEDULE_DATES_CAP}`),
|
|
21
|
+
}),
|
|
22
|
+
handler: async ({ schedule_id, start, end, count }) => {
|
|
23
|
+
return await adapter.getScheduleDates(schedule_id, { start, end, count });
|
|
24
|
+
},
|
|
25
|
+
examples: [
|
|
26
|
+
{
|
|
27
|
+
description: 'Next three dates of a schedule',
|
|
28
|
+
input: { schedule_id: '00000000-0000-0000-0000-000000000001', count: 3 },
|
|
29
|
+
},
|
|
30
|
+
{
|
|
31
|
+
description: 'Every date of a schedule in Q4 2026',
|
|
32
|
+
input: { schedule_id: '00000000-0000-0000-0000-000000000001', start: '2026-10-01', end: '2026-12-31' },
|
|
33
|
+
},
|
|
34
|
+
],
|
|
35
|
+
});
|
|
@@ -3,7 +3,7 @@ import adapter from '../lib/actual-adapter.js';
|
|
|
3
3
|
import { CommonSchemas } from '../lib/schemas/common.js';
|
|
4
4
|
import { RecurConfigSchema } from '../lib/schemas/recur.js';
|
|
5
5
|
import { ScheduleAmountSchema, amountOpMismatch } from '../lib/schemas/schedule-amount.js';
|
|
6
|
-
const InputSchema = z.
|
|
6
|
+
const InputSchema = z.strictObject({
|
|
7
7
|
id: CommonSchemas.scheduleId.describe('Schedule UUID'),
|
|
8
8
|
name: z.string().optional()
|
|
9
9
|
.describe('New display name'),
|
|
@@ -24,10 +24,12 @@ const InputSchema = z.object({
|
|
|
24
24
|
.describe('New YYYY-MM-DD date or RecurConfig object'),
|
|
25
25
|
posts_transaction: z.boolean().optional()
|
|
26
26
|
.describe('Auto-post a transaction on each occurrence'),
|
|
27
|
-
completed: z.boolean().optional()
|
|
28
|
-
.describe('true marks it completed, false reactivates it'),
|
|
29
27
|
resetNextDate: z.boolean().optional().default(false)
|
|
30
28
|
.describe('Recalculate next_date'),
|
|
29
|
+
// #627: strict, because upstream updateSchedule refuses `completed` and `next_date` as
|
|
30
|
+
// system-managed. A stripped key would return success while changing nothing; an advertised
|
|
31
|
+
// one failed upstream after the write queue. Either is refused here, before any write, as
|
|
32
|
+
// "unexpected field(s): completed" (formatZodError sanitises the key); the description says why.
|
|
31
33
|
}).superRefine((input, ctx) => {
|
|
32
34
|
// #527: with both fields supplied the input alone decides. With only one, the answer
|
|
33
35
|
// depends on the stored schedule, so adapter.updateSchedule checks it inside the write queue.
|
|
@@ -39,7 +41,7 @@ const InputSchema = z.object({
|
|
|
39
41
|
});
|
|
40
42
|
const tool = {
|
|
41
43
|
name: 'actual_schedules_update',
|
|
42
|
-
description: `Update a schedule; supply only the fields to change. A new date replaces the stored recurrence, patterns included. Set resetNextDate: true when changing the date or recurrence.`,
|
|
44
|
+
description: `Update a schedule; supply only the fields to change. A new date replaces the stored recurrence, patterns included. Set resetNextDate: true when changing the date or recurrence. Completion and next_date are managed by Actual and cannot be set.`,
|
|
43
45
|
inputSchema: InputSchema,
|
|
44
46
|
call: async (args, _meta) => {
|
|
45
47
|
const input = InputSchema.parse(args || {});
|