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.14.0 | **Tool Count:** 74 (verified LibreChat-compatible)
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
- /** Called by the MCP client to fetch current capabilities */
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
- name: tool.name,
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.map((name) => {
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.map((name) => {
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.map((name) => {
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
- const tools = toolsList.map((name) => {
36
- const schemaFromParam = toolSchemas && toolSchemas[name];
37
- const schemaFromManager = actualToolsManager?.getToolSchema?.(name);
38
- const schema = schemaFromParam || schemaFromManager;
39
- const inputSchema = schema && typeof schema === 'object' && Object.keys(schema).length > 0
40
- ? schema
41
- : { type: 'object', properties: {}, additionalProperties: false };
42
- const tool = actualToolsManager.getTool(name);
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.14.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",