actual-mcp-server 0.14.0 → 0.15.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md
CHANGED
|
@@ -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.15.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
|
}
|
|
@@ -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
|
});
|
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.15.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/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/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",
|