actual-mcp-server 0.14.1 → 0.16.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 +1 -1
- package/dist/src/lib/ActualMCPConnection.js +15 -4
- package/dist/src/lib/schemas/common.js +13 -0
- package/dist/src/lib/tool-annotations.js +278 -0
- package/dist/src/lib/tool-list-entry.js +39 -0
- package/dist/src/server/httpServer.js +13 -49
- package/dist/src/server/stdioServer.js +9 -11
- package/dist/src/tools/accounts_close.js +7 -9
- package/dist/src/tools/accounts_create.js +9 -2
- package/dist/src/tools/accounts_delete.js +2 -1
- package/dist/src/tools/accounts_get_balance.js +2 -1
- package/dist/src/tools/accounts_reopen.js +7 -4
- package/dist/src/tools/budget_updates_batch.js +2 -1
- package/dist/src/tools/budgets_setAmount.js +2 -1
- package/dist/src/tools/budgets_setCarryover.js +2 -1
- package/dist/src/tools/budgets_transfer.js +3 -4
- package/dist/src/tools/categories_delete.js +2 -1
- package/dist/src/tools/categories_update.js +3 -2
- package/dist/src/tools/category_groups_delete.js +2 -1
- package/dist/src/tools/category_groups_update.js +2 -1
- package/dist/src/tools/payee_rules_get.js +2 -1
- package/dist/src/tools/rules_delete.js +6 -3
- package/dist/src/tools/rules_update.js +2 -1
- package/dist/src/tools/schedules_create.js +3 -3
- package/dist/src/tools/schedules_delete.js +2 -3
- package/dist/src/tools/schedules_update.js +4 -5
- package/dist/src/tools/transactions_delete.js +2 -1
- package/dist/src/tools/transactions_update.js +9 -5
- package/dist/src/tools/transactions_update_batch.js +2 -1
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -774,7 +774,7 @@ The software is provided **as-is**, without warranty of any kind. The author acc
|
|
|
774
774
|
|
|
775
775
|
---
|
|
776
776
|
|
|
777
|
-
**Version:** 0.
|
|
777
|
+
**Version:** 0.16.0 | **Tool Count:** 74 (verified LibreChat-compatible)
|
|
778
778
|
|
|
779
779
|
## Sponsor
|
|
780
780
|
|
|
@@ -4,6 +4,7 @@ import * as actual from '@actual-app/api';
|
|
|
4
4
|
import actualToolsManager from '../actualToolsManager.js';
|
|
5
5
|
import adapter from './actual-adapter.js';
|
|
6
6
|
import { z } from 'zod';
|
|
7
|
+
import { buildToolListEntries } from './tool-list-entry.js';
|
|
7
8
|
/**
|
|
8
9
|
* MCPConnection implementation for Actual Finance bridge.
|
|
9
10
|
*/
|
|
@@ -30,7 +31,16 @@ export class ActualMCPConnection extends EventEmitter {
|
|
|
30
31
|
// ignore if adapter doesn't expose notifications yet
|
|
31
32
|
}
|
|
32
33
|
}
|
|
33
|
-
/**
|
|
34
|
+
/**
|
|
35
|
+
* Called by the MCP client to fetch current capabilities.
|
|
36
|
+
*
|
|
37
|
+
* NOTE (#379): this has NO call sites in src, tests, scripts or bin. It is not the
|
|
38
|
+
* `tools/list` handler (those live in `httpServer.ts` and `stdioServer.ts` and all go
|
|
39
|
+
* through `buildToolListEntries`), and nothing this returns reaches a client today. It is
|
|
40
|
+
* routed through the same builder anyway so it cannot drift into a fifth, differently
|
|
41
|
+
* shaped tool list. Whether to delete it outright is tracked separately: it is a public
|
|
42
|
+
* method on an exported class, so removal is a breaking change for any external importer.
|
|
43
|
+
*/
|
|
34
44
|
async fetchCapabilities() {
|
|
35
45
|
// If actualToolsManager is not ready, return demo tools
|
|
36
46
|
let tools;
|
|
@@ -43,10 +53,11 @@ export class ActualMCPConnection extends EventEmitter {
|
|
|
43
53
|
}
|
|
44
54
|
// Add examples if present on the tool
|
|
45
55
|
return {
|
|
46
|
-
|
|
56
|
+
...buildToolListEntries([tool.name], () => ({
|
|
57
|
+
description: tool.description,
|
|
58
|
+
schema: tool.inputSchema ? z.toJSONSchema(tool.inputSchema) : undefined,
|
|
59
|
+
}))[0],
|
|
47
60
|
title: tool.name,
|
|
48
|
-
description: tool.description,
|
|
49
|
-
inputSchema: tool.inputSchema ? z.toJSONSchema(tool.inputSchema) : { type: 'object' },
|
|
50
61
|
};
|
|
51
62
|
});
|
|
52
63
|
}
|
|
@@ -66,6 +66,18 @@ export const tagIdSchema = z
|
|
|
66
66
|
.string()
|
|
67
67
|
.regex(UUID_PATTERN, 'Invalid tag ID format (expected UUID)')
|
|
68
68
|
.describe('Tag UUID');
|
|
69
|
+
/**
|
|
70
|
+
* Schedule UUID validation
|
|
71
|
+
* Used for: schedule management operations (update, delete)
|
|
72
|
+
*
|
|
73
|
+
* #380: added because `schedules_update` and `schedules_delete` were inlining
|
|
74
|
+
* `UUID_PATTERN` directly, which is the same rule expressed twice and is how a fifth way
|
|
75
|
+
* of describing an id appears.
|
|
76
|
+
*/
|
|
77
|
+
export const scheduleIdSchema = z
|
|
78
|
+
.string()
|
|
79
|
+
.regex(UUID_PATTERN, 'Invalid schedule ID format (expected UUID)')
|
|
80
|
+
.describe('Schedule UUID');
|
|
69
81
|
// ============================================================================
|
|
70
82
|
// DATE SCHEMAS
|
|
71
83
|
// ============================================================================
|
|
@@ -214,6 +226,7 @@ export const CommonSchemas = {
|
|
|
214
226
|
payeeId: payeeIdSchema,
|
|
215
227
|
ruleId: ruleIdSchema,
|
|
216
228
|
tagId: tagIdSchema,
|
|
229
|
+
scheduleId: scheduleIdSchema,
|
|
217
230
|
// Dates
|
|
218
231
|
date: dateSchema,
|
|
219
232
|
monthYear: monthYearSchema,
|
|
@@ -0,0 +1,278 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* MCP tool annotations for the 74-tool surface (#379).
|
|
3
|
+
*
|
|
4
|
+
* The Model Context Protocol lets a server describe each tool's NATURE to clients, so a
|
|
5
|
+
* model or a UI can know which tools mutate a budget BEFORE calling one. This file is the
|
|
6
|
+
* single source of that classification, and `ActualMCPConnection` attaches it to every
|
|
7
|
+
* `tools/list` entry.
|
|
8
|
+
*
|
|
9
|
+
* THE SPEC'S DEFAULTS ARE ALREADY CONSERVATIVE, which shapes what is worth declaring.
|
|
10
|
+
* From `schema/2025-06-18/schema.ts`:
|
|
11
|
+
*
|
|
12
|
+
* readOnlyHint default false "the tool does not modify its environment"
|
|
13
|
+
* destructiveHint default TRUE "may perform destructive updates"; only meaningful when readOnlyHint is false
|
|
14
|
+
* idempotentHint default false "calling repeatedly with the same arguments has no additional effect"
|
|
15
|
+
* openWorldHint default TRUE "may interact with an open world of external entities"
|
|
16
|
+
*
|
|
17
|
+
* So a tool that declares nothing is already treated as write-capable, destructive and
|
|
18
|
+
* open-world. The value here is therefore NOT warning about the dangerous tools; it is
|
|
19
|
+
* telling clients which tools are SAFE, and correcting `openWorldHint`, whose default is
|
|
20
|
+
* wrong for 73 of our 74 tools: this server's domain is one Actual Budget instance, a
|
|
21
|
+
* CLOSED world. Only `actual_bank_sync` reaches outside it, to GoCardless or SimpleFIN.
|
|
22
|
+
*
|
|
23
|
+
* THESE ARE HINTS, NEVER A GUARD. The spec is explicit: "all properties in ToolAnnotations
|
|
24
|
+
* are hints. They are not guaranteed to provide a faithful description of tool behavior",
|
|
25
|
+
* and "Clients should never make tool use decisions based on ToolAnnotations received from
|
|
26
|
+
* untrusted servers." Nothing in `src/` may branch on an annotation. Authorisation stays in
|
|
27
|
+
* `budget-acl.ts`, and refusal stays in the adapter guards.
|
|
28
|
+
*
|
|
29
|
+
* WHY A CENTRAL TABLE rather than a field on each tool. `types/tool.d.ts` is in CLAUDE.md's
|
|
30
|
+
* do-not-modify tier, and most tools use the legacy `ToolDefinition` shape, so a per-tool
|
|
31
|
+
* `annotations` field needs that permission first. A table also puts the whole
|
|
32
|
+
* classification on one screen next to the reasoning, which is what makes it auditable
|
|
33
|
+
* against `docs/audit/write-effect-audit.md`, and `tests/unit/tool_annotations.test.js`
|
|
34
|
+
* makes omission impossible. Moving to per-tool declarations later is mechanical.
|
|
35
|
+
*/
|
|
36
|
+
/**
|
|
37
|
+
* The ONLY tool that reaches outside this Actual instance. Bank sync calls a third-party
|
|
38
|
+
* aggregator (GoCardless / SimpleFIN), which is the textbook "open world".
|
|
39
|
+
*/
|
|
40
|
+
const OPEN_WORLD = new Set(['actual_bank_sync']);
|
|
41
|
+
/**
|
|
42
|
+
* Tools that provably change NOTHING. Verified mechanically by
|
|
43
|
+
* `tests/unit/tool_annotations.test.js`, which resolves each tool's `adapter.*` calls and
|
|
44
|
+
* fails if one of these reaches `queueWriteOperation`.
|
|
45
|
+
*
|
|
46
|
+
* Claimed only where it is certain. Three read-path tools are deliberately absent because
|
|
47
|
+
* "does not modify its environment" is false for them, and a wrong `true` here is worse
|
|
48
|
+
* than no annotation at all:
|
|
49
|
+
*
|
|
50
|
+
* actual_bank_sync runs through `withActualApi` (the read path) but IMPORTS
|
|
51
|
+
* transactions, so the call graph says read and reality says
|
|
52
|
+
* write. Reality wins.
|
|
53
|
+
* actual_budgets_export writes a zip into the server's data directory.
|
|
54
|
+
* actual_budgets_switch changes the session's active budget and persists a preference.
|
|
55
|
+
* actual_session_close closes a pooled connection: server state, not budget data.
|
|
56
|
+
*/
|
|
57
|
+
const READ_ONLY = new Set([
|
|
58
|
+
'actual_accounts_get_balance',
|
|
59
|
+
'actual_accounts_list',
|
|
60
|
+
'actual_budgets_get_all',
|
|
61
|
+
'actual_budgets_getMonth',
|
|
62
|
+
'actual_budgets_getMonths',
|
|
63
|
+
'actual_budgets_list_available',
|
|
64
|
+
'actual_categories_get',
|
|
65
|
+
'actual_category_groups_get',
|
|
66
|
+
'actual_entities_search',
|
|
67
|
+
'actual_get_id_by_name',
|
|
68
|
+
'actual_notes_get',
|
|
69
|
+
'actual_payee_rules_get',
|
|
70
|
+
'actual_payees_common_list',
|
|
71
|
+
'actual_payees_get',
|
|
72
|
+
'actual_preferences_get',
|
|
73
|
+
'actual_query_run',
|
|
74
|
+
'actual_rules_get',
|
|
75
|
+
'actual_schedules_get',
|
|
76
|
+
'actual_server_get_version',
|
|
77
|
+
'actual_server_info',
|
|
78
|
+
'actual_session_list',
|
|
79
|
+
'actual_tags_list',
|
|
80
|
+
'actual_transactions_filter',
|
|
81
|
+
'actual_transactions_get',
|
|
82
|
+
'actual_transactions_search_by_amount',
|
|
83
|
+
'actual_transactions_search_by_category',
|
|
84
|
+
'actual_transactions_search_by_month',
|
|
85
|
+
'actual_transactions_search_by_payee',
|
|
86
|
+
'actual_transactions_summary_by_category',
|
|
87
|
+
'actual_transactions_summary_by_payee',
|
|
88
|
+
'actual_transactions_uncategorized',
|
|
89
|
+
]);
|
|
90
|
+
/**
|
|
91
|
+
* Write tools that REMOVE data or make an irreversible change. Everything else that writes
|
|
92
|
+
* is additive or an in-place field update, which the spec calls not-destructive.
|
|
93
|
+
*
|
|
94
|
+
* `accounts_close` is in this list and it is the interesting one: Actual REMOVES an account
|
|
95
|
+
* that has no transactions rather than closing it (documented in the tool's own description
|
|
96
|
+
* and in #357), so a close can be a delete.
|
|
97
|
+
*/
|
|
98
|
+
const DESTRUCTIVE = new Set([
|
|
99
|
+
'actual_accounts_close',
|
|
100
|
+
'actual_accounts_delete',
|
|
101
|
+
// Upstream `reconcileTransactions` overwrites fields on transactions that ALREADY exist
|
|
102
|
+
// (imported_id, imported_payee, cleared, raw_synced_data) and propagates cleared and date
|
|
103
|
+
// onto split children.
|
|
104
|
+
//
|
|
105
|
+
// NOTE that `actual_transactions_import` calls the same upstream function and is NOT
|
|
106
|
+
// marked destructive, which needs a reason rather than an accident. The difference is the
|
|
107
|
+
// `date` rewrite: bank sync honours the per-account `sync-update-dates-<id>` preference,
|
|
108
|
+
// so a user who enabled it gets EXISTING transaction dates rewritten by a tool they did
|
|
109
|
+
// not point at those rows. An explicit import only touches what the caller handed it.
|
|
110
|
+
// Under the policy above (a field overwrite is additive) that is the line, and it is a
|
|
111
|
+
// judgement call rather than a derivation.
|
|
112
|
+
'actual_bank_sync',
|
|
113
|
+
'actual_budgets_export',
|
|
114
|
+
'actual_budgets_import',
|
|
115
|
+
'actual_categories_delete',
|
|
116
|
+
'actual_category_groups_delete',
|
|
117
|
+
'actual_payees_delete',
|
|
118
|
+
'actual_payees_merge',
|
|
119
|
+
'actual_rules_delete',
|
|
120
|
+
'actual_schedules_delete',
|
|
121
|
+
'actual_tags_delete',
|
|
122
|
+
'actual_transactions_delete',
|
|
123
|
+
// THE NON-OBVIOUS ONE. `fields.subtransactions` (CommonSchemas, `.strict()`) has no `id`,
|
|
124
|
+
// so upstream's `makeChild` mints a fresh UUID for every child and `diffItems` tombstones
|
|
125
|
+
// every existing one. Editing one amount on a six-way split DELETES all six rows and
|
|
126
|
+
// creates six new ones with new ids, breaking any external reference to them. That also
|
|
127
|
+
// makes it non-idempotent, which is why it is absent from IDEMPOTENT below.
|
|
128
|
+
'actual_transactions_update',
|
|
129
|
+
// Removes a live pooled connection. With no `sessionId` it closes the OLDEST IDLE
|
|
130
|
+
// session, so a client that auto-approves non-destructive tools could let a model loop it
|
|
131
|
+
// and drain the pool, reproducing the per-operation re-login burst #134 exists to prevent.
|
|
132
|
+
'actual_session_close',
|
|
133
|
+
]);
|
|
134
|
+
/**
|
|
135
|
+
* Write tools that only ADD or set a field in place: not destructive.
|
|
136
|
+
*
|
|
137
|
+
* This set exists to make the classification TOTAL. Without it, a tool nobody classified
|
|
138
|
+
* fell through every set and `annotationsFor` published `destructiveHint: false` and
|
|
139
|
+
* `openWorldHint: false`, which INVERTS the spec's conservative defaults (both default to
|
|
140
|
+
* true) into positive safety claims about a tool nobody had looked at. Silence has to be
|
|
141
|
+
* impossible here, not merely discouraged, and `tests/unit/tool_annotations.test.js`
|
|
142
|
+
* asserts every registered tool appears in exactly one of READ_ONLY, DESTRUCTIVE or
|
|
143
|
+
* ADDITIVE.
|
|
144
|
+
*
|
|
145
|
+
* THE POLICY, stated so the boundary is a decision rather than an omission: overwriting a
|
|
146
|
+
* FIELD on an existing row is additive; removing or replacing a ROW is destructive. So
|
|
147
|
+
* `budgets_setAmount` and `accounts_update` are additive, while `transactions_update` is
|
|
148
|
+
* not, because on the split path it deletes and recreates child rows.
|
|
149
|
+
*/
|
|
150
|
+
const ADDITIVE = new Set([
|
|
151
|
+
'actual_accounts_create',
|
|
152
|
+
'actual_accounts_reopen',
|
|
153
|
+
'actual_accounts_update',
|
|
154
|
+
'actual_budgets_switch',
|
|
155
|
+
'actual_budgets_holdForNextMonth',
|
|
156
|
+
'actual_budgets_resetHold',
|
|
157
|
+
'actual_budgets_setAmount',
|
|
158
|
+
'actual_budgets_setCarryover',
|
|
159
|
+
'actual_budgets_transfer',
|
|
160
|
+
'actual_budget_updates_batch',
|
|
161
|
+
'actual_categories_create',
|
|
162
|
+
'actual_categories_update',
|
|
163
|
+
'actual_category_groups_create',
|
|
164
|
+
'actual_category_groups_update',
|
|
165
|
+
'actual_payees_create',
|
|
166
|
+
'actual_payees_update',
|
|
167
|
+
'actual_rules_create',
|
|
168
|
+
'actual_rules_create_or_update',
|
|
169
|
+
'actual_rules_update',
|
|
170
|
+
'actual_schedules_create',
|
|
171
|
+
'actual_schedules_update',
|
|
172
|
+
'actual_transactions_create',
|
|
173
|
+
'actual_transactions_import',
|
|
174
|
+
'actual_transactions_update_batch',
|
|
175
|
+
'actual_transfers_create',
|
|
176
|
+
'actual_tags_create',
|
|
177
|
+
'actual_tags_update',
|
|
178
|
+
'actual_notes_update',
|
|
179
|
+
]);
|
|
180
|
+
/**
|
|
181
|
+
* Write tools where repeating the SAME call leaves the same STATE.
|
|
182
|
+
*
|
|
183
|
+
* THE STANDARD USED, because it is the judgement call in this file. The spec says
|
|
184
|
+
* idempotent means "calling the tool repeatedly with the same arguments will have no
|
|
185
|
+
* additional effect on the its environment". That is about STATE, not about the response,
|
|
186
|
+
* so a tool that refuses the second call while changing nothing still qualifies. This
|
|
187
|
+
* matters most for the delete family: since #376/#377 they THROW a NotFoundRefusal for an
|
|
188
|
+
* absent id rather than reporting success, but the second call writes nothing, so the state
|
|
189
|
+
* after one delete and after five is identical. A client deciding whether a timed-out write
|
|
190
|
+
* is safe to retry cares about the state, which is exactly what this flag answers.
|
|
191
|
+
*
|
|
192
|
+
* Values are taken from `docs/audit/write-effect-audit.md` rather than guessed, so each one
|
|
193
|
+
* has a history behind it.
|
|
194
|
+
*
|
|
195
|
+
* The exclusions are the point:
|
|
196
|
+
* most `*_create` each call makes ANOTHER entity, so the state differs
|
|
197
|
+
* budgets_holdForNextMonth upstream ADDS to the buffer, so a retry holds twice (#355)
|
|
198
|
+
* budgets_transfer moves money again on each call
|
|
199
|
+
* payees_merge the sources are gone after the first
|
|
200
|
+
* bank_sync imports whatever is new upstream
|
|
201
|
+
* transactions_import adds rows; de-dup is a bank-sync property, not a promise here
|
|
202
|
+
*
|
|
203
|
+
* `actual_tags_create` is the create that IS idempotent, and it is easy to miss: it upserts
|
|
204
|
+
* on the tag word, so a repeat updates the existing tag and returns the same id. Its own
|
|
205
|
+
* description says so and `docker-all-tools.e2e.spec.ts` asserts it. `categories_create` is
|
|
206
|
+
* deliberately NOT here: it REJECTS a duplicate rather than upserting, and whether upstream
|
|
207
|
+
* truly refuses every duplicate is not something this file should assume.
|
|
208
|
+
*/
|
|
209
|
+
const IDEMPOTENT = new Set([
|
|
210
|
+
// NOT the same question as #165's "safe to re-issue". `tests/unit/adapter_nonidempotent_no_retry.test.js`
|
|
211
|
+
// pins several of these as non-idempotent for RETRY purposes, and both are right: the MCP
|
|
212
|
+
// spec asks whether repeating leaves the same STATE (a second delete writes nothing),
|
|
213
|
+
// while #165 asks whether re-issuing an in-flight write can corrupt or mislead. A client
|
|
214
|
+
// reading this flag to decide "auto-retry after a timeout?" should still respect #165, so
|
|
215
|
+
// the two lists disagreeing on paper is expected and is recorded here rather than left to
|
|
216
|
+
// be re-litigated.
|
|
217
|
+
//
|
|
218
|
+
// `actual_session_close` is deliberately ABSENT: called with no sessionId it closes the
|
|
219
|
+
// OLDEST IDLE session, so repeating it closes a DIFFERENT session each time until the
|
|
220
|
+
// pool is empty. That is the literal negation of the flag.
|
|
221
|
+
'actual_accounts_close',
|
|
222
|
+
'actual_accounts_delete',
|
|
223
|
+
'actual_accounts_reopen',
|
|
224
|
+
'actual_accounts_update',
|
|
225
|
+
'actual_budget_updates_batch',
|
|
226
|
+
'actual_budgets_resetHold',
|
|
227
|
+
'actual_budgets_setAmount',
|
|
228
|
+
'actual_budgets_setCarryover',
|
|
229
|
+
'actual_budgets_switch',
|
|
230
|
+
'actual_categories_delete',
|
|
231
|
+
'actual_categories_update',
|
|
232
|
+
'actual_category_groups_delete',
|
|
233
|
+
'actual_category_groups_update',
|
|
234
|
+
'actual_notes_update',
|
|
235
|
+
'actual_payees_delete',
|
|
236
|
+
'actual_payees_update',
|
|
237
|
+
'actual_rules_create_or_update',
|
|
238
|
+
'actual_rules_delete',
|
|
239
|
+
'actual_rules_update',
|
|
240
|
+
'actual_schedules_delete',
|
|
241
|
+
'actual_schedules_update',
|
|
242
|
+
'actual_tags_create',
|
|
243
|
+
'actual_tags_delete',
|
|
244
|
+
'actual_tags_update',
|
|
245
|
+
'actual_transactions_delete',
|
|
246
|
+
'actual_transactions_update_batch',
|
|
247
|
+
]);
|
|
248
|
+
/** The sets, exported so the guard can check them without re-deriving the rules. */
|
|
249
|
+
export const _ANNOTATION_SETS = { OPEN_WORLD, READ_ONLY, DESTRUCTIVE, ADDITIVE, IDEMPOTENT };
|
|
250
|
+
/**
|
|
251
|
+
* Annotations for one tool.
|
|
252
|
+
*
|
|
253
|
+
* Every field is declared explicitly, including where the value equals the spec default.
|
|
254
|
+
* Silence and "declared false" are indistinguishable to a client, but they are very
|
|
255
|
+
* different to the guard: it must be able to tell "this tool was classified as a write"
|
|
256
|
+
* from "somebody forgot to classify it".
|
|
257
|
+
*/
|
|
258
|
+
export function annotationsFor(toolName) {
|
|
259
|
+
const readOnly = READ_ONLY.has(toolName);
|
|
260
|
+
const classified = readOnly || DESTRUCTIVE.has(toolName) || ADDITIVE.has(toolName);
|
|
261
|
+
if (!classified) {
|
|
262
|
+
// A tool nobody classified. OMIT `destructiveHint` and `openWorldHint` so the spec's
|
|
263
|
+
// own defaults apply, which are `true` for both: unknown reads as destructive and
|
|
264
|
+
// open-world, the conservative answer. Returning `false` here would have published a
|
|
265
|
+
// positive SAFETY claim about code nobody had looked at, which is strictly worse than
|
|
266
|
+
// saying nothing. The guard fails the build on this case; the shape here is what
|
|
267
|
+
// protects clients in the window before someone notices.
|
|
268
|
+
return { readOnlyHint: false, idempotentHint: false };
|
|
269
|
+
}
|
|
270
|
+
return {
|
|
271
|
+
readOnlyHint: readOnly,
|
|
272
|
+
// Meaningful only when readOnlyHint is false; declared either way so the table round
|
|
273
|
+
// trips, and a read-only tool is trivially non-destructive.
|
|
274
|
+
destructiveHint: readOnly ? false : DESTRUCTIVE.has(toolName),
|
|
275
|
+
idempotentHint: readOnly ? true : IDEMPOTENT.has(toolName),
|
|
276
|
+
openWorldHint: OPEN_WORLD.has(toolName),
|
|
277
|
+
};
|
|
278
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The single builder for a `tools/list` entry (#379).
|
|
3
|
+
*
|
|
4
|
+
* WHY THIS EXISTS. The `tools/list` payload was assembled in THREE near-identical places:
|
|
5
|
+
* the SDK request handler in `httpServer.ts`, the no-session LobeChat compatibility path a
|
|
6
|
+
* hundred lines below it, and the stdio handler in `stdioServer.ts`. Each resolved the
|
|
7
|
+
* schema the same way and built the same object shape, independently.
|
|
8
|
+
*
|
|
9
|
+
* That went unnoticed until annotations were added: patching one site would have shipped
|
|
10
|
+
* annotations to HTTP clients and silently left stdio users (Claude Desktop) without them,
|
|
11
|
+
* which is the transport half this project has been bitten by before (#280). Anything that
|
|
12
|
+
* belongs in the published tool surface has to be added HERE, once, or it reaches some
|
|
13
|
+
* clients and not others.
|
|
14
|
+
*/
|
|
15
|
+
import { annotationsFor } from './tool-annotations.js';
|
|
16
|
+
/** An empty-but-valid JSON Schema, for a tool whose schema cannot be resolved. */
|
|
17
|
+
const EMPTY_SCHEMA = { type: 'object', properties: {}, additionalProperties: false };
|
|
18
|
+
/**
|
|
19
|
+
* Build the published entries for a list of tool names.
|
|
20
|
+
*
|
|
21
|
+
* @param toolNames the registered tool names, in registry order
|
|
22
|
+
* @param resolve resolves a tool's description and JSON Schema (call sites differ in
|
|
23
|
+
* where those come from, so it is injected rather than imported, which
|
|
24
|
+
* also keeps this module free of a cycle back through the tool registry)
|
|
25
|
+
*/
|
|
26
|
+
export function buildToolListEntries(toolNames, resolve) {
|
|
27
|
+
return toolNames.map((name) => {
|
|
28
|
+
const { description, schema } = resolve(name);
|
|
29
|
+
const inputSchema = schema && typeof schema === 'object' && Object.keys(schema).length > 0 ? schema : EMPTY_SCHEMA;
|
|
30
|
+
return {
|
|
31
|
+
name,
|
|
32
|
+
description: description || `Tool ${name}`,
|
|
33
|
+
inputSchema,
|
|
34
|
+
// Advisory metadata for clients, never a guard: the MCP spec says clients must treat
|
|
35
|
+
// annotations as untrusted, and nothing in src/ may branch on one.
|
|
36
|
+
annotations: annotationsFor(name),
|
|
37
|
+
};
|
|
38
|
+
});
|
|
39
|
+
}
|
|
@@ -24,6 +24,7 @@ import * as fs from 'node:fs';
|
|
|
24
24
|
// Re-exported here for backward compatibility with any callers that imported
|
|
25
25
|
// `requestContext` from this module.
|
|
26
26
|
import { requestContext } from '../lib/requestContext.js';
|
|
27
|
+
import { buildToolListEntries } from '../lib/tool-list-entry.js';
|
|
27
28
|
export { requestContext };
|
|
28
29
|
// Resolve the authenticated principal for the per-principal budget preference
|
|
29
30
|
// (#189). OIDC: the verified JWT subject. Static-bearer: a single shared
|
|
@@ -176,6 +177,15 @@ advertisedUrl) {
|
|
|
176
177
|
const sessionInitPromises = new Map(); // Track session init completion
|
|
177
178
|
// safe fallback if index didn't provide implementedTools
|
|
178
179
|
const toolsList = Array.isArray(implementedTools) ? implementedTools : [];
|
|
180
|
+
// #379: one resolver, one builder, for all THREE tools/list paths below: the SDK handler,
|
|
181
|
+
// the no-session LobeChat compatibility path, and the expired-session LobeChat discovery
|
|
182
|
+
// shim. They each assembled the payload independently, so anything added to the published
|
|
183
|
+
// surface had to be added three times, and the shim was in fact missed on the first pass.
|
|
184
|
+
const resolveToolMeta = (name) => ({
|
|
185
|
+
description: actualToolsManager.getTool(name)?.description,
|
|
186
|
+
schema: (toolSchemas && toolSchemas[name]) ||
|
|
187
|
+
actualToolsManager?.getToolSchema?.(name),
|
|
188
|
+
});
|
|
179
189
|
// Session liveness and idle timing are owned solely by the connection pool
|
|
180
190
|
// (#167). httpServer no longer runs its own idle timer or activity map; it
|
|
181
191
|
// owns only the transport objects. When the pool removes a session (idle
|
|
@@ -269,23 +279,7 @@ advertisedUrl) {
|
|
|
269
279
|
server.setRequestHandler(ListToolsRequestSchema, async () => {
|
|
270
280
|
logger.debug('[TOOLS LIST] Listing available tools');
|
|
271
281
|
logger.debug(`[TOOLS LIST] toolsList length: ${toolsList.length}`);
|
|
272
|
-
const tools = toolsList
|
|
273
|
-
const schemaFromParam = toolSchemas && toolSchemas[name];
|
|
274
|
-
const schemaFromManager = actualToolsManager?.getToolSchema?.(name);
|
|
275
|
-
const schema = schemaFromParam || schemaFromManager;
|
|
276
|
-
// Ensure inputSchema is a valid JSON Schema object with required properties
|
|
277
|
-
const inputSchema = schema && typeof schema === 'object' && Object.keys(schema).length > 0
|
|
278
|
-
? schema
|
|
279
|
-
: { type: 'object', properties: {}, additionalProperties: false };
|
|
280
|
-
// Get the actual tool description from the tool definition
|
|
281
|
-
const tool = actualToolsManager.getTool(name);
|
|
282
|
-
const description = tool?.description || `Tool ${name}`;
|
|
283
|
-
return {
|
|
284
|
-
name,
|
|
285
|
-
description,
|
|
286
|
-
inputSchema,
|
|
287
|
-
};
|
|
288
|
-
});
|
|
282
|
+
const tools = buildToolListEntries(toolsList, resolveToolMeta);
|
|
289
283
|
logger.debug(`[TOOLS LIST] Returning ${tools.length} tools`);
|
|
290
284
|
return { tools };
|
|
291
285
|
});
|
|
@@ -355,23 +349,7 @@ advertisedUrl) {
|
|
|
355
349
|
// Handle this case directly without going through the transport
|
|
356
350
|
if (!sessionId && method === 'tools/list') {
|
|
357
351
|
logger.debug('[LOBECHAT COMPAT] Handling tools/list without session directly');
|
|
358
|
-
const tools = toolsList
|
|
359
|
-
const schemaFromParam = toolSchemas && toolSchemas[name];
|
|
360
|
-
const schemaFromManager = actualToolsManager?.getToolSchema?.(name);
|
|
361
|
-
const schema = schemaFromParam || schemaFromManager;
|
|
362
|
-
// Ensure inputSchema is a valid JSON Schema object with required properties
|
|
363
|
-
const inputSchema = schema && typeof schema === 'object' && Object.keys(schema).length > 0
|
|
364
|
-
? schema
|
|
365
|
-
: { type: 'object', properties: {}, additionalProperties: false };
|
|
366
|
-
// Get the actual tool description from the tool definition
|
|
367
|
-
const tool = actualToolsManager.getTool(name);
|
|
368
|
-
const description = tool?.description || `Tool ${name}`;
|
|
369
|
-
return {
|
|
370
|
-
name,
|
|
371
|
-
description,
|
|
372
|
-
inputSchema,
|
|
373
|
-
};
|
|
374
|
-
});
|
|
352
|
+
const tools = buildToolListEntries(toolsList, resolveToolMeta);
|
|
375
353
|
res.json({
|
|
376
354
|
jsonrpc: '2.0',
|
|
377
355
|
id: payload?.id ?? null,
|
|
@@ -516,21 +494,7 @@ advertisedUrl) {
|
|
|
516
494
|
// This allows LobeChat's backend to discover available tools even with expired sessions
|
|
517
495
|
if (method === 'tools/list') {
|
|
518
496
|
logger.debug('[LOBECHAT COMPAT] Handling tools/list with expired/invalid session - returning tools for discovery');
|
|
519
|
-
const tools = toolsList
|
|
520
|
-
const schemaFromParam = toolSchemas && toolSchemas[name];
|
|
521
|
-
const schemaFromManager = actualToolsManager?.getToolSchema?.(name);
|
|
522
|
-
const schema = schemaFromParam || schemaFromManager;
|
|
523
|
-
const inputSchema = schema && typeof schema === 'object' && Object.keys(schema).length > 0
|
|
524
|
-
? schema
|
|
525
|
-
: { type: 'object', properties: {}, additionalProperties: false };
|
|
526
|
-
const tool = actualToolsManager.getTool(name);
|
|
527
|
-
const description = tool?.description || `Tool ${name}`;
|
|
528
|
-
return {
|
|
529
|
-
name,
|
|
530
|
-
description,
|
|
531
|
-
inputSchema,
|
|
532
|
-
};
|
|
533
|
-
});
|
|
497
|
+
const tools = buildToolListEntries(toolsList, resolveToolMeta);
|
|
534
498
|
res.json({
|
|
535
499
|
jsonrpc: '2.0',
|
|
536
500
|
id: payload?.id ?? null,
|
|
@@ -6,6 +6,7 @@ import { randomUUID } from 'node:crypto';
|
|
|
6
6
|
import logger from '../logger.js';
|
|
7
7
|
import actualToolsManager from '../actualToolsManager.js';
|
|
8
8
|
import { requestContext } from '../lib/requestContext.js';
|
|
9
|
+
import { buildToolListEntries } from '../lib/tool-list-entry.js';
|
|
9
10
|
export async function startStdioServer(mcp, capabilities, implementedTools, serverDescription, serverInstructions, toolSchemas, version) {
|
|
10
11
|
const toolsList = Array.isArray(implementedTools) ? implementedTools : [];
|
|
11
12
|
// #348: a synthetic session identity for this stdio process.
|
|
@@ -32,17 +33,14 @@ export async function startStdioServer(mcp, capabilities, implementedTools, serv
|
|
|
32
33
|
const server = new Server({ name: serverDescription || 'actual-mcp-server', version: version || '0.1.0' }, { capabilities, instructions: serverInstructions });
|
|
33
34
|
// List tools handler — mirrors createServerInstance() in httpServer.ts
|
|
34
35
|
server.setRequestHandler(ListToolsRequestSchema, async () => {
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
const description = tool?.description || `Tool ${name}`;
|
|
44
|
-
return { name, description, inputSchema };
|
|
45
|
-
});
|
|
36
|
+
// #379: the SAME builder the HTTP paths use. stdio is the transport Claude Desktop
|
|
37
|
+
// runs, and it previously assembled this payload independently, so an addition to the
|
|
38
|
+
// published surface could reach HTTP clients and silently miss stdio ones.
|
|
39
|
+
const tools = buildToolListEntries(toolsList, (name) => ({
|
|
40
|
+
description: actualToolsManager.getTool(name)?.description,
|
|
41
|
+
schema: (toolSchemas && toolSchemas[name]) ||
|
|
42
|
+
actualToolsManager?.getToolSchema?.(name),
|
|
43
|
+
}));
|
|
46
44
|
logger.debug(`[STDIO] tools/list → ${tools.length} tools`);
|
|
47
45
|
return { tools };
|
|
48
46
|
});
|
|
@@ -1,21 +1,19 @@
|
|
|
1
1
|
import { z } from 'zod';
|
|
2
2
|
import adapter from '../lib/actual-adapter.js';
|
|
3
|
+
import { CommonSchemas } from '../lib/schemas/common.js';
|
|
3
4
|
const InputSchema = z
|
|
4
5
|
.object({
|
|
5
|
-
id:
|
|
6
|
+
id: CommonSchemas.accountId.describe('Account ID to close'),
|
|
6
7
|
// #357: documented by the Actual API reference as required when the account's
|
|
7
8
|
// balance is non-zero, and previously not exposed at all, which made such an
|
|
8
9
|
// account impossible to close through this server.
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
10
|
+
// #380: the same entity type as `id` at the top of this object, so it gets the same schema. It was
|
|
11
|
+
// left on a bounded string because the drift guard could not SEE it: its detector
|
|
12
|
+
// required `z.` on the field's own line, and this declaration breaks after `z`.
|
|
13
|
+
transferAccountId: CommonSchemas.accountId
|
|
13
14
|
.optional()
|
|
14
15
|
.describe('Account ID to move the remaining balance to. REQUIRED when the account has a non-zero balance.'),
|
|
15
|
-
transferCategoryId:
|
|
16
|
-
.string()
|
|
17
|
-
.min(1)
|
|
18
|
-
.max(64)
|
|
16
|
+
transferCategoryId: CommonSchemas.categoryId
|
|
19
17
|
.optional()
|
|
20
18
|
.describe('Category to assign the balancing transaction to. Optional, used with transferAccountId.'),
|
|
21
19
|
})
|
|
@@ -6,12 +6,19 @@ export default createTool({
|
|
|
6
6
|
name: 'actual_accounts_create',
|
|
7
7
|
description: 'Create a new account in Actual Budget',
|
|
8
8
|
schema: z.object({
|
|
9
|
-
|
|
9
|
+
// #380: the `id` field was REMOVED rather than tightened. Upstream ignores it:
|
|
10
|
+
// `handlers['api/account-create']` destructures only name, offbudget, closed and the
|
|
11
|
+
// balance, so `insertWithUUID` always mints a fresh UUID (verified against the
|
|
12
|
+
// @actual-app/api source shipped in dist/index.js.map). A caller who supplied an id got
|
|
13
|
+
// `{ success: true }` and an account with a DIFFERENT id, then a not-found from
|
|
14
|
+
// actual_accounts_get_balance. That is the same success-lie family as #350, so
|
|
15
|
+
// publishing the field at all was the defect; typing it would only have made an
|
|
16
|
+
// unusable field look deliberate.
|
|
10
17
|
name: CommonSchemas.name,
|
|
11
18
|
balance: CommonSchemas.optionalAmountCents
|
|
12
19
|
}),
|
|
13
20
|
handler: async (input) => {
|
|
14
|
-
const accountPayload = {
|
|
21
|
+
const accountPayload = { name: input.name, balance: input.balance };
|
|
15
22
|
return await adapter.createAccount(accountPayload, input.balance);
|
|
16
23
|
},
|
|
17
24
|
examples: [
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import { z } from 'zod';
|
|
2
2
|
import adapter from '../lib/actual-adapter.js';
|
|
3
|
+
import { CommonSchemas } from '../lib/schemas/common.js';
|
|
3
4
|
const InputSchema = z.object({
|
|
4
|
-
id:
|
|
5
|
+
id: CommonSchemas.accountId.describe('Account ID to delete'),
|
|
5
6
|
});
|
|
6
7
|
/**
|
|
7
8
|
* #347: this tool used to return `{success: true}` unconditionally, which was a
|
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
import { z } from 'zod';
|
|
2
2
|
import adapter from '../lib/actual-adapter.js';
|
|
3
|
+
import { CommonSchemas } from '../lib/schemas/common.js';
|
|
3
4
|
import { notFoundMsg } from '../lib/errors.js';
|
|
4
5
|
const InputSchema = z.object({
|
|
5
|
-
id:
|
|
6
|
+
id: CommonSchemas.accountId.describe('The UUID of the account'),
|
|
6
7
|
cutoff: z.string().optional().describe('Optional cutoff date (YYYY-MM-DD format)')
|
|
7
8
|
}).strict();
|
|
8
9
|
const tool = {
|
|
@@ -1,10 +1,13 @@
|
|
|
1
1
|
import { z } from 'zod';
|
|
2
2
|
import adapter from '../lib/actual-adapter.js';
|
|
3
|
+
import { CommonSchemas } from '../lib/schemas/common.js';
|
|
3
4
|
const InputSchema = z.object({
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
//
|
|
7
|
-
|
|
5
|
+
// #380: tightened to the shared account schema. This comment previously said the
|
|
6
|
+
// opposite ("not tightened here: several fixtures use short non-UUID ids"), which was
|
|
7
|
+
// true until that sweep happened and would have read as an invitation to revert. The
|
|
8
|
+
// echo-into-logs concern it raised is still real and is now served by the UUID pattern,
|
|
9
|
+
// which is strictly tighter than the length bound it replaced.
|
|
10
|
+
id: CommonSchemas.accountId.describe('Account ID to reopen'),
|
|
8
11
|
});
|
|
9
12
|
/**
|
|
10
13
|
* #358. The guard lives in `adapter.reopenAccount`, which reads, writes and re-reads inside
|
|
@@ -1,11 +1,12 @@
|
|
|
1
1
|
import { z } from 'zod';
|
|
2
2
|
import adapter from '../lib/actual-adapter.js';
|
|
3
|
+
import { CommonSchemas } from '../lib/schemas/common.js';
|
|
3
4
|
import api from '@actual-app/api';
|
|
4
5
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
5
6
|
const { setBudgetAmount: rawSetBudgetAmount, setBudgetCarryover: rawSetBudgetCarryover } = api;
|
|
6
7
|
const BudgetOperationSchema = z.object({
|
|
7
8
|
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:
|
|
9
|
+
categoryId: CommonSchemas.categoryId.describe('Category ID'),
|
|
9
10
|
amount: z.number().optional().describe('Budget amount in cents (if setting amount)'),
|
|
10
11
|
carryover: z.boolean().optional().describe('Carryover flag (if setting carryover)'),
|
|
11
12
|
});
|
|
@@ -1,12 +1,13 @@
|
|
|
1
1
|
import { z } from 'zod';
|
|
2
2
|
import adapter from '../lib/actual-adapter.js';
|
|
3
|
+
import { CommonSchemas } from '../lib/schemas/common.js';
|
|
3
4
|
import { isPreflightRefusal } from '../lib/errors.js';
|
|
4
5
|
const InputSchema = z.object({
|
|
5
6
|
// #361: this was a bare z.string().min(1), so 'banana' parsed and reached the API. The
|
|
6
7
|
// regex matches actual_budgets_holdForNextMonth and the other month-taking tools; the
|
|
7
8
|
// adapter additionally checks the month is one this budget actually has.
|
|
8
9
|
month: z.string().regex(/^\d{4}-(0[1-9]|1[0-2])$/, 'month must be in YYYY-MM format'),
|
|
9
|
-
categoryId:
|
|
10
|
+
categoryId: CommonSchemas.categoryId,
|
|
10
11
|
amount: z.number().int('amount must be an integer (cents)'),
|
|
11
12
|
});
|
|
12
13
|
const tool = {
|
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
import { z } from 'zod';
|
|
2
2
|
import adapter from '../lib/actual-adapter.js';
|
|
3
|
+
import { CommonSchemas } from '../lib/schemas/common.js';
|
|
3
4
|
const InputSchema = z.object({
|
|
4
5
|
month: z.string().describe('Budget month in YYYY-MM format'),
|
|
5
|
-
categoryId:
|
|
6
|
+
categoryId: CommonSchemas.categoryId.describe('Category ID'),
|
|
6
7
|
flag: z.boolean().describe('Whether to carry over unused budget to next month (true) or not (false)'),
|
|
7
8
|
});
|
|
8
9
|
const tool = {
|
|
@@ -1,14 +1,13 @@
|
|
|
1
1
|
import { z } from 'zod';
|
|
2
2
|
import adapter from '../lib/actual-adapter.js';
|
|
3
|
+
import { CommonSchemas } from '../lib/schemas/common.js';
|
|
3
4
|
const InputSchema = z.object({
|
|
4
5
|
month: z.string()
|
|
5
6
|
.regex(/^\d{4}-\d{2}$/, 'month must be YYYY-MM')
|
|
6
7
|
.describe('Month in YYYY-MM format'),
|
|
7
|
-
fromCategoryId:
|
|
8
|
-
.min(1, 'fromCategoryId is required')
|
|
8
|
+
fromCategoryId: CommonSchemas.categoryId
|
|
9
9
|
.describe('Source category ID to transfer from'),
|
|
10
|
-
toCategoryId:
|
|
11
|
-
.min(1, 'toCategoryId is required')
|
|
10
|
+
toCategoryId: CommonSchemas.categoryId
|
|
12
11
|
.describe('Target category ID to transfer to'),
|
|
13
12
|
amount: z.number()
|
|
14
13
|
.int('amount must be an integer (cents)')
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import { z } from 'zod';
|
|
2
2
|
import adapter from '../lib/actual-adapter.js';
|
|
3
|
+
import { CommonSchemas } from '../lib/schemas/common.js';
|
|
3
4
|
const InputSchema = z.object({
|
|
4
|
-
id:
|
|
5
|
+
id: CommonSchemas.categoryId.describe('Category ID to delete'),
|
|
5
6
|
});
|
|
6
7
|
const tool = {
|
|
7
8
|
name: 'actual_categories_delete',
|
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
import { z } from 'zod';
|
|
2
2
|
import adapter from '../lib/actual-adapter.js';
|
|
3
|
+
import { CommonSchemas } from '../lib/schemas/common.js';
|
|
3
4
|
const InputSchema = z.object({
|
|
4
|
-
id:
|
|
5
|
+
id: CommonSchemas.categoryId.describe('Category ID to update'),
|
|
5
6
|
fields: z.object({
|
|
6
7
|
name: z.string().optional().describe('New category name'),
|
|
7
|
-
group_id:
|
|
8
|
+
group_id: CommonSchemas.categoryGroupId.optional().describe('Category group ID to move this category to'),
|
|
8
9
|
is_income: z.boolean().optional().describe('Whether this is an income category'),
|
|
9
10
|
hidden: z.boolean().optional().describe('Whether this category is hidden'),
|
|
10
11
|
}).describe('Fields to update'),
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import { z } from 'zod';
|
|
2
2
|
import adapter from '../lib/actual-adapter.js';
|
|
3
|
+
import { CommonSchemas } from '../lib/schemas/common.js';
|
|
3
4
|
const InputSchema = z.object({
|
|
4
|
-
id:
|
|
5
|
+
id: CommonSchemas.categoryGroupId.describe('Category group ID to delete'),
|
|
5
6
|
});
|
|
6
7
|
/**
|
|
7
8
|
* #376: the existence guard lives in `adapter.deleteCategoryGroup`, which reads and writes
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import { z } from 'zod';
|
|
2
2
|
import adapter from '../lib/actual-adapter.js';
|
|
3
|
+
import { CommonSchemas } from '../lib/schemas/common.js';
|
|
3
4
|
const InputSchema = z.object({
|
|
4
|
-
id:
|
|
5
|
+
id: CommonSchemas.categoryGroupId.describe('Category group ID to update'),
|
|
5
6
|
fields: z.object({
|
|
6
7
|
name: z.string().optional().describe('New group name'),
|
|
7
8
|
is_income: z.boolean().optional().describe('Whether this is an income category group'),
|
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
import { z } from 'zod';
|
|
2
2
|
import adapter from '../lib/actual-adapter.js';
|
|
3
|
+
import { CommonSchemas } from '../lib/schemas/common.js';
|
|
3
4
|
import { notFoundMsg } from '../lib/errors.js';
|
|
4
5
|
const InputSchema = z.object({
|
|
5
|
-
payeeId:
|
|
6
|
+
payeeId: CommonSchemas.payeeId.describe('ID of the payee to get rules for'),
|
|
6
7
|
});
|
|
7
8
|
const tool = {
|
|
8
9
|
name: 'actual_payee_rules_get',
|
|
@@ -1,9 +1,12 @@
|
|
|
1
1
|
import { z } from 'zod';
|
|
2
2
|
import adapter from '../lib/actual-adapter.js';
|
|
3
|
+
import { CommonSchemas } from '../lib/schemas/common.js';
|
|
3
4
|
const InputSchema = z.object({
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
|
|
5
|
+
// #380: the shared rule schema. The reason from #356 still holds and is why it is not
|
|
6
|
+
// simply a bare string: this value is echoed into the not-found message, into the
|
|
7
|
+
// schedule-owned refusal, and into logger.error, so it must be bounded. The UUID pattern
|
|
8
|
+
// bounds it more tightly than the .max(64) it replaced.
|
|
9
|
+
id: CommonSchemas.ruleId.describe('Rule ID to delete'),
|
|
7
10
|
});
|
|
8
11
|
/**
|
|
9
12
|
* #355 fixed two defects here (a silent no-op on an unknown id, and success reported for a
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { z } from 'zod';
|
|
2
2
|
import adapter from '../lib/actual-adapter.js';
|
|
3
|
+
import { CommonSchemas } from '../lib/schemas/common.js';
|
|
3
4
|
// Define the schema for rule conditions and actions (same as create)
|
|
4
5
|
const ConditionSchema = z.object({
|
|
5
6
|
field: z.string().describe('Field to match (e.g., "payee", "notes", "amount", "category")'),
|
|
@@ -26,7 +27,7 @@ const FIELD_OPERATORS = {
|
|
|
26
27
|
'date': { type: 'date', operators: ['is', 'gte', 'lte', 'gt', 'lt'] },
|
|
27
28
|
};
|
|
28
29
|
const InputSchema = z.object({
|
|
29
|
-
id:
|
|
30
|
+
id: CommonSchemas.ruleId.describe('Rule ID to update'),
|
|
30
31
|
fields: z.object({
|
|
31
32
|
// #342: null is Actual's DEFAULT stage; the literal "default" is rejected by
|
|
32
33
|
// its validator. Deliberately NO .default() here: this is a partial update, so
|
|
@@ -1,13 +1,13 @@
|
|
|
1
1
|
import { z } from 'zod';
|
|
2
2
|
import adapter from '../lib/actual-adapter.js';
|
|
3
|
-
import {
|
|
3
|
+
import { CommonSchemas } from '../lib/schemas/common.js';
|
|
4
4
|
import { RecurConfigSchema } from '../lib/schemas/recur.js';
|
|
5
5
|
const InputSchema = z.object({
|
|
6
6
|
name: z.string().optional()
|
|
7
7
|
.describe('Unique display name for the schedule'),
|
|
8
|
-
payee:
|
|
8
|
+
payee: CommonSchemas.payeeId.optional()
|
|
9
9
|
.describe('Payee UUID (from actual_payees_get)'),
|
|
10
|
-
account:
|
|
10
|
+
account: CommonSchemas.accountId.optional()
|
|
11
11
|
.describe('Account UUID (from actual_accounts_list). If omitted the schedule is not tied to an account'),
|
|
12
12
|
amount: z.number().int().optional()
|
|
13
13
|
.describe('Amount in cents. Negative = expense (e.g. -5000 = -$50.00), positive = income'),
|
|
@@ -1,9 +1,8 @@
|
|
|
1
1
|
import { z } from 'zod';
|
|
2
2
|
import adapter from '../lib/actual-adapter.js';
|
|
3
|
-
import {
|
|
3
|
+
import { CommonSchemas } from '../lib/schemas/common.js';
|
|
4
4
|
const InputSchema = z.object({
|
|
5
|
-
id:
|
|
6
|
-
.describe('UUID of the schedule to delete (from actual_schedules_get)'),
|
|
5
|
+
id: CommonSchemas.scheduleId.describe('UUID of the schedule to delete (from actual_schedules_get)'),
|
|
7
6
|
});
|
|
8
7
|
/**
|
|
9
8
|
* #376: the existence guard and the SQLite constraint-error translation live in
|
|
@@ -1,15 +1,14 @@
|
|
|
1
1
|
import { z } from 'zod';
|
|
2
2
|
import adapter from '../lib/actual-adapter.js';
|
|
3
|
-
import {
|
|
3
|
+
import { CommonSchemas } from '../lib/schemas/common.js';
|
|
4
4
|
import { RecurConfigSchema } from '../lib/schemas/recur.js';
|
|
5
5
|
const InputSchema = z.object({
|
|
6
|
-
id:
|
|
7
|
-
.describe('UUID of the schedule to update (from actual_schedules_get)'),
|
|
6
|
+
id: CommonSchemas.scheduleId.describe('UUID of the schedule to update (from actual_schedules_get)'),
|
|
8
7
|
name: z.string().optional()
|
|
9
8
|
.describe('New display name for the schedule'),
|
|
10
|
-
payee:
|
|
9
|
+
payee: CommonSchemas.payeeId.nullable().optional()
|
|
11
10
|
.describe('New payee UUID, or null to clear'),
|
|
12
|
-
account:
|
|
11
|
+
account: CommonSchemas.accountId.nullable().optional()
|
|
13
12
|
.describe('New account UUID, or null to clear'),
|
|
14
13
|
amount: z.number().int().optional()
|
|
15
14
|
.describe('New amount in cents. Negative = expense, positive = income'),
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import { z } from 'zod';
|
|
2
2
|
import adapter from '../lib/actual-adapter.js';
|
|
3
|
+
import { CommonSchemas } from '../lib/schemas/common.js';
|
|
3
4
|
const InputSchema = z.object({
|
|
4
|
-
id:
|
|
5
|
+
id: CommonSchemas.transactionId.describe('Transaction ID to delete'),
|
|
5
6
|
});
|
|
6
7
|
const tool = {
|
|
7
8
|
name: 'actual_transactions_delete',
|
|
@@ -2,7 +2,15 @@ import { z } from 'zod';
|
|
|
2
2
|
import adapter from '../lib/actual-adapter.js';
|
|
3
3
|
import { CommonSchemas } from '../lib/schemas/common.js';
|
|
4
4
|
const InputSchema = z.object({
|
|
5
|
-
|
|
5
|
+
// #380: REQUIRED, and typed. It was `.optional()` with the describe "optional for smoke
|
|
6
|
+
// tests, required for actual usage": a published contract weakened to suit a test. The
|
|
7
|
+
// handler then returned `{ success: true }` for a call with no id, writing nothing, which
|
|
8
|
+
// is the #350 failure this project has spent three releases removing. A model that omitted
|
|
9
|
+
// the id was told its edit had succeeded.
|
|
10
|
+
//
|
|
11
|
+
// The smoke test does supply an id (generated_tools.smoke.test.js), so the escape hatch
|
|
12
|
+
// was not even load bearing when it was removed.
|
|
13
|
+
id: CommonSchemas.transactionId.describe('Transaction ID to update'),
|
|
6
14
|
fields: z.object({
|
|
7
15
|
account: z.string().nullable().optional().describe('Account ID'),
|
|
8
16
|
date: z.string().nullable().optional().describe('Transaction date (YYYY-MM-DD)'),
|
|
@@ -32,10 +40,6 @@ const tool = {
|
|
|
32
40
|
inputSchema: InputSchema,
|
|
33
41
|
call: async (args, _meta) => {
|
|
34
42
|
const input = InputSchema.parse(args || {});
|
|
35
|
-
// For smoke tests, return early if no id provided
|
|
36
|
-
if (!input.id) {
|
|
37
|
-
return { success: true };
|
|
38
|
-
}
|
|
39
43
|
await adapter.updateTransaction(input.id, input.fields);
|
|
40
44
|
return { success: true };
|
|
41
45
|
},
|
|
@@ -13,6 +13,7 @@
|
|
|
13
13
|
*/
|
|
14
14
|
import { z } from 'zod';
|
|
15
15
|
import adapter from '../lib/actual-adapter.js';
|
|
16
|
+
import { CommonSchemas } from '../lib/schemas/common.js';
|
|
16
17
|
const FieldsSchema = z.object({
|
|
17
18
|
account: z.string().nullable().optional().describe('Account ID'),
|
|
18
19
|
date: z.string().nullable().optional().describe('Transaction date (YYYY-MM-DD)'),
|
|
@@ -25,7 +26,7 @@ const FieldsSchema = z.object({
|
|
|
25
26
|
cleared: z.boolean().nullable().optional().describe('Whether transaction is cleared'),
|
|
26
27
|
});
|
|
27
28
|
const UpdateItemSchema = z.object({
|
|
28
|
-
id:
|
|
29
|
+
id: CommonSchemas.transactionId.describe('Transaction ID to update'),
|
|
29
30
|
fields: FieldsSchema.describe('Fields to update for this transaction'),
|
|
30
31
|
});
|
|
31
32
|
const InputSchema = z.object({
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "actual-mcp-server",
|
|
3
3
|
"displayName": "Actual MCP Server",
|
|
4
|
-
"version": "0.
|
|
4
|
+
"version": "0.16.0",
|
|
5
5
|
"engines": {
|
|
6
6
|
"node": ">=22.0.0",
|
|
7
7
|
"npm": ">=10.0.0"
|
|
@@ -30,7 +30,7 @@
|
|
|
30
30
|
"verify-tools": "npm run build && node scripts/verify-tools.js",
|
|
31
31
|
"check:coverage": "node scripts/list-actual-api-methods.mjs",
|
|
32
32
|
"direct-sync": "node scripts/direct-sync/bank-sync-direct.mjs",
|
|
33
|
-
"test:unit-js": "node tests/unit/transactions_create.test.js && node tests/unit/generated_tools.smoke.test.js && 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/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/budget_acl_enforcement.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/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/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/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/budget_acl_enforcement.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/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",
|