actual-mcp-server 0.22.13 → 0.22.17
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 +57 -11
- package/dist/src/actualToolsManager.js +53 -1
- package/dist/src/config.js +29 -0
- package/dist/src/index.js +3 -2
- package/dist/src/lib/ActualMCPConnection.js +1 -1
- package/dist/src/lib/actual-adapter.js +284 -14
- package/dist/src/lib/errors.js +20 -0
- package/dist/src/lib/schemas/common.js +1 -1
- package/dist/src/lib/schemas/recur.js +8 -8
- package/dist/src/lib/schemas/rules.js +133 -0
- package/dist/src/lib/tool-annotations.js +5 -3
- package/dist/src/lib/toolsets.js +302 -0
- package/dist/src/server/httpServer.js +2 -5
- package/dist/src/tools/budget_updates_batch.js +35 -50
- package/dist/src/tools/index.js +1 -0
- package/dist/src/tools/query_run.js +11 -47
- package/dist/src/tools/rules_create.js +8 -131
- package/dist/src/tools/rules_create_batch.js +47 -0
- package/dist/src/tools/rules_create_or_update.js +16 -38
- package/dist/src/tools/rules_update.js +15 -31
- package/dist/src/tools/schedules_create.js +10 -10
- package/dist/src/tools/schedules_update.js +12 -12
- package/dist/src/tools/server_info.js +5 -1
- package/dist/src/tools/transactions_update_batch.js +15 -24
- package/package.json +4 -4
package/dist/src/lib/errors.js
CHANGED
|
@@ -95,6 +95,26 @@ export function isPreflightRefusal(error) {
|
|
|
95
95
|
error !== null &&
|
|
96
96
|
error[REFUSAL_BRAND] === true);
|
|
97
97
|
}
|
|
98
|
+
/**
|
|
99
|
+
* A registered tool exists but this process does not publish it (#483), so the call is
|
|
100
|
+
* refused before dispatch. Deliberately NOT a PreflightRefusal: a refusal is "the request
|
|
101
|
+
* named something that cannot be acted on, fix the request" and is treated as benign by
|
|
102
|
+
* rejection-allowlist.ts, whereas this is an operator policy decision the caller cannot
|
|
103
|
+
* fix. Decide on the type and `setting`, never on the message text.
|
|
104
|
+
*/
|
|
105
|
+
export class ToolUnavailableError extends Error {
|
|
106
|
+
tool;
|
|
107
|
+
setting;
|
|
108
|
+
constructor(tool, setting) {
|
|
109
|
+
super(setting === 'MCP_READ_ONLY'
|
|
110
|
+
? `Tool "${tool}" is not available: it can change state and this server runs with MCP_READ_ONLY=true.`
|
|
111
|
+
: `Tool "${tool}" is not available: it is not published by this server's MCP_TOOLSETS configuration.`);
|
|
112
|
+
this.name = 'ToolUnavailableError';
|
|
113
|
+
this.tool = tool;
|
|
114
|
+
this.setting = setting;
|
|
115
|
+
Object.setPrototypeOf(this, new.target.prototype);
|
|
116
|
+
}
|
|
117
|
+
}
|
|
98
118
|
/**
|
|
99
119
|
* Build a "not found" message with a next-step hint.
|
|
100
120
|
*
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
*
|
|
4
4
|
* Common validation schemas used across MCP tools for Actual Budget.
|
|
5
5
|
* These schemas provide consistent validation, better error messages,
|
|
6
|
-
* and reduce duplication across the
|
|
6
|
+
* and reduce duplication across the 83 tool definitions.
|
|
7
7
|
*/
|
|
8
8
|
import { z } from 'zod';
|
|
9
9
|
import { MAX_NAME_LENGTH, MAX_NOTES_LENGTH, DATE_PATTERN, MONTH_PATTERN, UUID_PATTERN } from '../constants.js';
|
|
@@ -6,19 +6,19 @@ import { z } from 'zod';
|
|
|
6
6
|
// actionable validation error rather than being forwarded unshaped.
|
|
7
7
|
export const RecurConfigSchema = z.object({
|
|
8
8
|
frequency: z.enum(['daily', 'weekly', 'monthly', 'yearly'])
|
|
9
|
-
.describe('How often
|
|
9
|
+
.describe('How often it repeats'),
|
|
10
10
|
start: z.string().regex(/^\d{4}-\d{2}-\d{2}$/)
|
|
11
|
-
.describe('Start date
|
|
11
|
+
.describe('Start date, YYYY-MM-DD'),
|
|
12
12
|
endMode: z.enum(['never', 'after_n_occurrences', 'on_date'])
|
|
13
|
-
.describe('When
|
|
13
|
+
.describe('When it stops'),
|
|
14
14
|
interval: z.number().int().positive().optional()
|
|
15
|
-
.describe('Every N periods. Default
|
|
15
|
+
.describe('Every N periods. Default 1'),
|
|
16
16
|
skipWeekend: z.boolean().optional()
|
|
17
|
-
.describe('
|
|
17
|
+
.describe('Move an occurrence that falls on a weekend'),
|
|
18
18
|
weekendSolveMode: z.enum(['before', 'after']).optional()
|
|
19
|
-
.describe('
|
|
19
|
+
.describe('Friday before or Monday after. Requires skipWeekend'),
|
|
20
20
|
endOccurrences: z.number().int().positive().optional()
|
|
21
|
-
.describe('
|
|
21
|
+
.describe('Required when endMode is after_n_occurrences'),
|
|
22
22
|
endDate: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional()
|
|
23
|
-
.describe('
|
|
23
|
+
.describe('YYYY-MM-DD. Required when endMode is on_date'),
|
|
24
24
|
});
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
// #485: the rule input shape and its imperative checks, shared by actual_rules_create and
|
|
3
|
+
// actual_rules_create_batch so both accept and reject exactly the same rules with the same
|
|
4
|
+
// messages. actual_rules_create_or_update keeps its own copies on purpose (out of scope).
|
|
5
|
+
// #486: describe strings shared by the rule tools. Only the TEXT is shared, never the object
|
|
6
|
+
// schemas: actual_rules_update keeps its own ActionSchema (op required, no default) and
|
|
7
|
+
// actual_rules_create_or_update keeps its own stage (no default, #342), so neither can reuse
|
|
8
|
+
// ActionSchema or RuleItemSchema without changing what it accepts.
|
|
9
|
+
export const ACTION_DESCRIBE = {
|
|
10
|
+
op: 'One of "set", "set-split-amount", "link-schedule", "prepend-notes", "append-notes"',
|
|
11
|
+
field: 'Field to modify, required for "set": category, payee, notes, cleared, account',
|
|
12
|
+
value: 'Value to assign: a UUID for category, payee and account, text for notes, a number for amounts',
|
|
13
|
+
type: 'Value type hint: "id", "string", "number" or "boolean"',
|
|
14
|
+
options: 'Additional options for the action',
|
|
15
|
+
};
|
|
16
|
+
export const STAGE_DESCRIBE = 'null is the normal stage (the UI default), "pre" runs before it, "post" after';
|
|
17
|
+
// Operators per field type, for the rule tool descriptions that document conditions.
|
|
18
|
+
export const CONDITION_OPERATORS_HELP = 'Condition operators by field: imported_payee, notes, description (text): contains, matches, doesNotContain, is, isNot. ' +
|
|
19
|
+
'payee, account, category (ids): is, isNot, oneOf, notOneOf. amount: is, gte, lte, gt, lt, isapprox. date: is, gte, lte, gt, lt.';
|
|
20
|
+
// Define the schema for rule conditions and actions
|
|
21
|
+
export const ConditionSchema = z.object({
|
|
22
|
+
field: z.string().describe('Field to match, e.g. "payee", "notes", "amount", "category"'),
|
|
23
|
+
op: z.string().describe('Operator, e.g. "is", "contains", "gte"'),
|
|
24
|
+
value: z.union([z.string(), z.number()]).describe('Value to match against'),
|
|
25
|
+
type: z.string().optional().describe('Type hint, e.g. "string", "number", "id"'),
|
|
26
|
+
});
|
|
27
|
+
export const ActionSchema = z.object({
|
|
28
|
+
op: z.string().default('set').describe(`${ACTION_DESCRIBE.op}. Default "set"`),
|
|
29
|
+
field: z.string().optional().describe(ACTION_DESCRIBE.field),
|
|
30
|
+
value: z.union([z.string(), z.number(), z.boolean(), z.object({}).passthrough()]).describe(ACTION_DESCRIBE.value),
|
|
31
|
+
type: z.string().optional().describe(ACTION_DESCRIBE.type),
|
|
32
|
+
options: z.object({}).passthrough().optional().describe(ACTION_DESCRIBE.options),
|
|
33
|
+
});
|
|
34
|
+
// Operator validation map
|
|
35
|
+
const FIELD_OPERATORS = {
|
|
36
|
+
'imported_payee': { type: 'string', operators: ['contains', 'matches', 'doesNotContain', 'is', 'isNot'] },
|
|
37
|
+
'payee': { type: 'id', operators: ['is', 'isNot', 'oneOf', 'notOneOf'] },
|
|
38
|
+
'account': { type: 'id', operators: ['is', 'isNot', 'oneOf', 'notOneOf'] },
|
|
39
|
+
'category': { type: 'id', operators: ['is', 'isNot', 'oneOf', 'notOneOf'] },
|
|
40
|
+
'notes': { type: 'string', operators: ['contains', 'matches', 'doesNotContain', 'is', 'isNot'] },
|
|
41
|
+
'description': { type: 'string', operators: ['contains', 'matches', 'doesNotContain', 'is', 'isNot'] },
|
|
42
|
+
'amount': { type: 'number', operators: ['is', 'gte', 'lte', 'gt', 'lt', 'isapprox'] },
|
|
43
|
+
'date': { type: 'date', operators: ['is', 'gte', 'lte', 'gt', 'lt'] },
|
|
44
|
+
};
|
|
45
|
+
export const RuleItemSchema = z.object({
|
|
46
|
+
// #342: three stages, and the default one is `null`, not a string.
|
|
47
|
+
//
|
|
48
|
+
// Verified against Actual's validator (transaction-rules.ts) and a live server: it
|
|
49
|
+
// accepts exactly 'pre', 'post' and null. The published API reference says
|
|
50
|
+
// pre/default/post, which is wrong (reported upstream as actualbudget/actual#8682).
|
|
51
|
+
// #485: @actual-app/api 26.10.0 changed `fromExternalRule` to convert the string
|
|
52
|
+
// "default" to null, so Actual itself no longer rejects it. That has no behaviour impact
|
|
53
|
+
// here: this enum rejects "default" first, and the tool keeps sending null for the normal
|
|
54
|
+
// stage.
|
|
55
|
+
//
|
|
56
|
+
// The default here MUST be null and must be SENT. Two traps:
|
|
57
|
+
// - Defaulting to 'pre' (what this did until #342) silently puts every
|
|
58
|
+
// MCP-created rule ahead of every rule the user made in the UI, because
|
|
59
|
+
// Actual runs stages in the order pre, default, post. No error, no warning.
|
|
60
|
+
// - Simply making it .optional() and omitting the key does NOT work: on a
|
|
61
|
+
// create the validator always runs and rejects `undefined` with
|
|
62
|
+
// `Invalid rule stage: undefined`.
|
|
63
|
+
stage: z
|
|
64
|
+
.enum(['pre', 'post'])
|
|
65
|
+
.nullable()
|
|
66
|
+
.optional()
|
|
67
|
+
.default(null)
|
|
68
|
+
.describe(`${STAGE_DESCRIBE}. Default null; leave unset unless the rule must out-rank or defer to the user's rules`),
|
|
69
|
+
conditionsOp: z.enum(['and', 'or']).optional().default('and').describe('How to combine conditions'),
|
|
70
|
+
conditions: z.array(ConditionSchema).describe('Conditions that must be met'),
|
|
71
|
+
actions: z.array(ActionSchema).describe('Actions to perform when they are met'),
|
|
72
|
+
});
|
|
73
|
+
/**
|
|
74
|
+
* The checks that Zod cannot express, run on a PARSED rule. Throws a plain Error with the
|
|
75
|
+
* message actual_rules_create has always produced. Pure: no I/O.
|
|
76
|
+
*/
|
|
77
|
+
export function validateRuleInput(input) {
|
|
78
|
+
// Validate that actions with op="set" have a field
|
|
79
|
+
for (const action of input.actions) {
|
|
80
|
+
if (action.op === 'set' && !action.field) {
|
|
81
|
+
throw new Error('Action with op="set" requires a "field" property (e.g., "category", "payee", "notes", "cleared")');
|
|
82
|
+
}
|
|
83
|
+
// Validate action field values for ID-type fields
|
|
84
|
+
if (action.op === 'set' && action.field) {
|
|
85
|
+
// Check if using category field with text value instead of ID
|
|
86
|
+
if (action.field === 'category' && typeof action.value === 'string' && !action.value.match(/^[0-9a-f-]{36}$/i)) {
|
|
87
|
+
throw new Error(`Action field "category" expects a category ID (UUID), but got text value "${action.value}". ` +
|
|
88
|
+
`Use the category UUID from your budget data. You can list categories to find the correct UUID.`);
|
|
89
|
+
}
|
|
90
|
+
// Check if using payee field with text value instead of ID
|
|
91
|
+
if (action.field === 'payee' && typeof action.value === 'string' && !action.value.match(/^[0-9a-f-]{36}$/i)) {
|
|
92
|
+
throw new Error(`Action field "payee" expects a payee ID (UUID), but got text value "${action.value}". ` +
|
|
93
|
+
`Use the payee UUID from your budget data. You can list payees to find the correct UUID.`);
|
|
94
|
+
}
|
|
95
|
+
// Check if using account field with text value instead of ID
|
|
96
|
+
if (action.field === 'account' && typeof action.value === 'string' && !action.value.match(/^[0-9a-f-]{36}$/i)) {
|
|
97
|
+
throw new Error(`Action field "account" expects an account ID (UUID), but got text value "${action.value}". ` +
|
|
98
|
+
`Use the account UUID from your budget data. You can list accounts to find the correct UUID.`);
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
// Validate append-notes and prepend-notes have string values
|
|
102
|
+
if ((action.op === 'append-notes' || action.op === 'prepend-notes') && typeof action.value !== 'string') {
|
|
103
|
+
throw new Error(`Action "${action.op}" requires a string value, but got ${typeof action.value}. ` +
|
|
104
|
+
`Example: {op: "${action.op}", value: "text to ${action.op === 'append-notes' ? 'append' : 'prepend'}"}`);
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
// Validate field usage to guide users toward correct field selection
|
|
108
|
+
for (const condition of input.conditions) {
|
|
109
|
+
const fieldInfo = FIELD_OPERATORS[condition.field];
|
|
110
|
+
// Validate operator is compatible with field type
|
|
111
|
+
if (fieldInfo && !fieldInfo.operators.includes(condition.op)) {
|
|
112
|
+
throw new Error(`Invalid operator "${condition.op}" for field "${condition.field}". ` +
|
|
113
|
+
`Field "${condition.field}" is a ${fieldInfo.type} field and only supports: ${fieldInfo.operators.join(', ')}. ` +
|
|
114
|
+
`Please use one of these operators instead.`);
|
|
115
|
+
}
|
|
116
|
+
// Check if using payee field with text value instead of ID
|
|
117
|
+
if (condition.field === 'payee' && typeof condition.value === 'string' && !condition.value.match(/^[0-9a-f-]{36}$/i)) {
|
|
118
|
+
throw new Error(`Field "payee" expects a payee ID (UUID), but got text value "${condition.value}". ` +
|
|
119
|
+
`To match payee names with text, use "imported_payee" field instead. ` +
|
|
120
|
+
`Example: {field: "imported_payee", op: "contains", value: "${condition.value}"}`);
|
|
121
|
+
}
|
|
122
|
+
// Similar validation for account and category
|
|
123
|
+
if (['account', 'category'].includes(condition.field) && typeof condition.value === 'string' && !condition.value.match(/^[0-9a-f-]{36}$/i)) {
|
|
124
|
+
throw new Error(`Field "${condition.field}" expects an ID (UUID), but got text value "${condition.value}". ` +
|
|
125
|
+
`Use the ${condition.field} UUID from your budget data. List ${condition.field === 'account' ? 'accounts' : 'categories'} to find the correct UUID.`);
|
|
126
|
+
}
|
|
127
|
+
// Validate oneOf/notOneOf operators expect array values
|
|
128
|
+
if (['oneOf', 'notOneOf'].includes(condition.op) && !Array.isArray(condition.value)) {
|
|
129
|
+
throw new Error(`Operator "${condition.op}" expects an array of values, but got ${typeof condition.value}. ` +
|
|
130
|
+
`Example: {field: "${condition.field}", op: "${condition.op}", value: ["uuid-1", "uuid-2"]}`);
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* MCP tool annotations for the
|
|
2
|
+
* MCP tool annotations for the whole tool surface (#379).
|
|
3
3
|
*
|
|
4
4
|
* The Model Context Protocol lets a server describe each tool's NATURE to clients, so a
|
|
5
5
|
* model or a UI can know which tools mutate a budget BEFORE calling one. This file is the
|
|
@@ -17,14 +17,15 @@
|
|
|
17
17
|
* So a tool that declares nothing is already treated as write-capable, destructive and
|
|
18
18
|
* open-world. The value here is therefore NOT warning about the dangerous tools; it is
|
|
19
19
|
* telling clients which tools are SAFE, and correcting `openWorldHint`, whose default is
|
|
20
|
-
* wrong for
|
|
20
|
+
* wrong for every tool but one: this server's domain is one Actual Budget instance, a
|
|
21
21
|
* CLOSED world. Only `actual_bank_sync` reaches outside it, to GoCardless or SimpleFIN.
|
|
22
22
|
*
|
|
23
23
|
* THESE ARE HINTS, NEVER A GUARD. The spec is explicit: "all properties in ToolAnnotations
|
|
24
24
|
* are hints. They are not guaranteed to provide a faithful description of tool behavior",
|
|
25
25
|
* and "Clients should never make tool use decisions based on ToolAnnotations received from
|
|
26
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.
|
|
27
|
+
* `budget-acl.ts`, and refusal stays in the adapter guards. That includes MCP_READ_ONLY (#483),
|
|
28
|
+
* which decides from the hard-coded WRITE_CAPABLE set in `toolsets.ts`, never from this table.
|
|
28
29
|
*
|
|
29
30
|
* WHY A CENTRAL TABLE rather than a field on each tool. `types/tool.d.ts` is in CLAUDE.md's
|
|
30
31
|
* do-not-modify tier, and most tools use the legacy `ToolDefinition` shape, so a per-tool
|
|
@@ -173,6 +174,7 @@ const ADDITIVE = new Set([
|
|
|
173
174
|
'actual_payees_create',
|
|
174
175
|
'actual_payees_update',
|
|
175
176
|
'actual_rules_create',
|
|
177
|
+
'actual_rules_create_batch',
|
|
176
178
|
'actual_rules_create_or_update',
|
|
177
179
|
'actual_rules_update',
|
|
178
180
|
'actual_schedules_create',
|
|
@@ -0,0 +1,302 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Server-side toolsets, presets and read-only mode (#483).
|
|
3
|
+
*
|
|
4
|
+
* An operator chooses which tools this PROCESS publishes, so a chat client with a small
|
|
5
|
+
* context window or a strict turn limit sees a focused set instead of the whole registry.
|
|
6
|
+
* The idea and the first implementation are @andycarlberg's (PR #520); the design is
|
|
7
|
+
* modelled on GitHub's MCP server (GITHUB_TOOLSETS, GITHUB_TOOLS, GITHUB_READ_ONLY).
|
|
8
|
+
*
|
|
9
|
+
* MCP_TOOLSETS comma list of toolsets and presets. Default `all` (today's behaviour).
|
|
10
|
+
* MCP_TOOLS extra individual tools published on top of MCP_TOOLSETS.
|
|
11
|
+
* MCP_READ_ONLY drops every write-capable tool, overriding the two above.
|
|
12
|
+
*
|
|
13
|
+
* Design rules, each the answer to a way this can go wrong:
|
|
14
|
+
* - ONE source. resolvePublishedToolNames is pure and runs once, in
|
|
15
|
+
* actualToolsManager.initialize(), from the validated config. It returns an immutable
|
|
16
|
+
* policy (the published set plus a per-tool hide reason). Nothing reads process.env
|
|
17
|
+
* at dispatch time.
|
|
18
|
+
* - Hide AND refuse. Omitting a tool from tools/list is not access control, so callTool
|
|
19
|
+
* refuses an unpublished tool with ToolUnavailableError.
|
|
20
|
+
* - Fail loud. An unknown toolset, preset or tool name throws, and so does a
|
|
21
|
+
* configuration that resolves to zero tools. Never silently publish none or all.
|
|
22
|
+
* - READ-ONLY NEVER READS ANNOTATIONS. Annotations are hints (AGENTS.md). Write
|
|
23
|
+
* capability is the hard-coded WRITE_CAPABLE set below, guarded by
|
|
24
|
+
* tests/unit/toolsets.test.js against the adapter call graph. It is not derived at
|
|
25
|
+
* runtime because the Docker image ships dist/ only, with no src/ to scan.
|
|
26
|
+
* - Static per process. No tools/list_changed: a changing list invalidates the
|
|
27
|
+
* provider's prompt cache and client support for the notification is patchy.
|
|
28
|
+
*/
|
|
29
|
+
import { ToolUnavailableError } from './errors.js';
|
|
30
|
+
/** Every registered tool belongs to exactly ONE of these (guarded by the unit test). */
|
|
31
|
+
export const TOOLSETS = {
|
|
32
|
+
context: [
|
|
33
|
+
'actual_get_context',
|
|
34
|
+
'actual_accounts_list',
|
|
35
|
+
'actual_categories_get',
|
|
36
|
+
'actual_category_groups_get',
|
|
37
|
+
'actual_payees_get',
|
|
38
|
+
'actual_get_id_by_name',
|
|
39
|
+
'actual_entities_search',
|
|
40
|
+
'actual_server_info',
|
|
41
|
+
'actual_server_get_version',
|
|
42
|
+
'actual_tags_list',
|
|
43
|
+
'actual_account_groups_list',
|
|
44
|
+
'actual_budgets_list_available',
|
|
45
|
+
'actual_budgets_get_all',
|
|
46
|
+
'actual_preferences_get',
|
|
47
|
+
],
|
|
48
|
+
transactions: [
|
|
49
|
+
'actual_transactions_get',
|
|
50
|
+
'actual_transactions_filter',
|
|
51
|
+
'actual_transactions_search_by_amount',
|
|
52
|
+
'actual_transactions_search_by_category',
|
|
53
|
+
'actual_transactions_search_by_month',
|
|
54
|
+
'actual_transactions_search_by_payee',
|
|
55
|
+
'actual_transactions_uncategorized',
|
|
56
|
+
'actual_transactions_create',
|
|
57
|
+
'actual_transactions_update',
|
|
58
|
+
'actual_transactions_update_batch',
|
|
59
|
+
'actual_transactions_delete',
|
|
60
|
+
'actual_transactions_import',
|
|
61
|
+
'actual_transfers_create',
|
|
62
|
+
],
|
|
63
|
+
analysis: [
|
|
64
|
+
'actual_account_flow_summary',
|
|
65
|
+
'actual_recurring_expenses_summary',
|
|
66
|
+
'actual_transactions_summary_by_category',
|
|
67
|
+
'actual_transactions_summary_by_payee',
|
|
68
|
+
'actual_transactions_aggregate',
|
|
69
|
+
'actual_accounts_get_balance',
|
|
70
|
+
'actual_payees_common_list',
|
|
71
|
+
],
|
|
72
|
+
budget: [
|
|
73
|
+
'actual_budgets_getMonth',
|
|
74
|
+
'actual_budgets_getMonths',
|
|
75
|
+
'actual_budgets_setAmount',
|
|
76
|
+
'actual_budgets_setCarryover',
|
|
77
|
+
'actual_budgets_holdForNextMonth',
|
|
78
|
+
'actual_budgets_resetHold',
|
|
79
|
+
'actual_budgets_transfer',
|
|
80
|
+
'actual_budget_updates_batch',
|
|
81
|
+
'actual_notes_get',
|
|
82
|
+
'actual_notes_update',
|
|
83
|
+
],
|
|
84
|
+
rules: [
|
|
85
|
+
'actual_rules_get',
|
|
86
|
+
'actual_payee_rules_get',
|
|
87
|
+
'actual_rules_create',
|
|
88
|
+
'actual_rules_create_batch',
|
|
89
|
+
'actual_rules_update',
|
|
90
|
+
'actual_rules_create_or_update',
|
|
91
|
+
'actual_rules_delete',
|
|
92
|
+
],
|
|
93
|
+
schedules: [
|
|
94
|
+
'actual_schedules_get',
|
|
95
|
+
'actual_schedules_create',
|
|
96
|
+
'actual_schedules_update',
|
|
97
|
+
'actual_schedules_delete',
|
|
98
|
+
],
|
|
99
|
+
structure: [
|
|
100
|
+
'actual_accounts_create',
|
|
101
|
+
'actual_accounts_update',
|
|
102
|
+
'actual_accounts_delete',
|
|
103
|
+
'actual_account_groups_create',
|
|
104
|
+
'actual_account_groups_update',
|
|
105
|
+
'actual_account_groups_delete',
|
|
106
|
+
'actual_categories_create',
|
|
107
|
+
'actual_categories_update',
|
|
108
|
+
'actual_categories_delete',
|
|
109
|
+
'actual_category_groups_create',
|
|
110
|
+
'actual_category_groups_update',
|
|
111
|
+
'actual_category_groups_delete',
|
|
112
|
+
'actual_payees_create',
|
|
113
|
+
'actual_payees_update',
|
|
114
|
+
'actual_payees_delete',
|
|
115
|
+
'actual_tags_create',
|
|
116
|
+
'actual_tags_update',
|
|
117
|
+
'actual_tags_delete',
|
|
118
|
+
'actual_accounts_close',
|
|
119
|
+
'actual_accounts_reopen',
|
|
120
|
+
'actual_payees_merge',
|
|
121
|
+
],
|
|
122
|
+
query: ['actual_query_run'],
|
|
123
|
+
admin: [
|
|
124
|
+
'actual_budgets_switch',
|
|
125
|
+
'actual_budgets_import',
|
|
126
|
+
'actual_budgets_export',
|
|
127
|
+
'actual_bank_sync',
|
|
128
|
+
'actual_session_list',
|
|
129
|
+
'actual_session_close',
|
|
130
|
+
],
|
|
131
|
+
};
|
|
132
|
+
export const TOOLSET_NAMES = Object.keys(TOOLSETS);
|
|
133
|
+
/**
|
|
134
|
+
* A PRESET is a named tool list that cuts across the groups, accepted anywhere a toolset
|
|
135
|
+
* name is. Presets are exempt from the one-group-per-tool rule.
|
|
136
|
+
*
|
|
137
|
+
* `chat` is seeded from a real day-to-day chat-assistant flow (#477): bootstrap, one read
|
|
138
|
+
* path, the batch writes, and the few single writes a chat assistant needs. It is 12 tools
|
|
139
|
+
* where the full surface is 83, roughly a 76% cut in tools/list bytes.
|
|
140
|
+
*
|
|
141
|
+
* actual_budgets_getMonth is in it on purpose (decision recorded on #483): budget_updates_batch
|
|
142
|
+
* and budgets_setAmount tell the model to read a month back with it, and query_run has no
|
|
143
|
+
* budget-month table to stand in. actual_budgets_getMonths is NOT in it: the out-of-range
|
|
144
|
+
* refusal from setAmount and the batch already states the budget's first and last month.
|
|
145
|
+
* tests/unit/toolsets.test.js scans every member's description and input schema for
|
|
146
|
+
* references to tools the preset hides, and each such reference must name a published
|
|
147
|
+
* substitute there, so a preset cannot quietly point the model at a tool it cannot call.
|
|
148
|
+
*/
|
|
149
|
+
export const PRESETS = {
|
|
150
|
+
chat: [
|
|
151
|
+
'actual_get_context',
|
|
152
|
+
'actual_query_run',
|
|
153
|
+
'actual_transactions_update_batch',
|
|
154
|
+
'actual_budget_updates_batch',
|
|
155
|
+
'actual_rules_create_batch',
|
|
156
|
+
'actual_transactions_create',
|
|
157
|
+
'actual_transactions_update',
|
|
158
|
+
'actual_transactions_delete',
|
|
159
|
+
'actual_rules_create',
|
|
160
|
+
'actual_budgets_transfer',
|
|
161
|
+
'actual_budgets_setAmount',
|
|
162
|
+
'actual_budgets_getMonth',
|
|
163
|
+
],
|
|
164
|
+
};
|
|
165
|
+
export const PRESET_NAMES = Object.keys(PRESETS);
|
|
166
|
+
/**
|
|
167
|
+
* Every tool that can change a budget or server state, for MCP_READ_ONLY.
|
|
168
|
+
*
|
|
169
|
+
* Derived by hand from the adapter call graph: the 43 tools whose adapter path reaches
|
|
170
|
+
* queueWriteOperation, plus the four that mutate WITHOUT the write queue (bank_sync imports
|
|
171
|
+
* transactions through the read path, budgets_export writes a zip to ACTUAL_EXPORT_DIR,
|
|
172
|
+
* budgets_switch changes the active budget, session_close closes a pooled connection).
|
|
173
|
+
* Never derive this from tool annotations.
|
|
174
|
+
*
|
|
175
|
+
* tests/unit/toolsets.test.js re-derives it from src/ and fails, naming the tool, when a
|
|
176
|
+
* new writer is missing here. Add a new write tool to this set in the same commit.
|
|
177
|
+
*/
|
|
178
|
+
export const WRITE_CAPABLE = new Set([
|
|
179
|
+
'actual_account_groups_create',
|
|
180
|
+
'actual_account_groups_delete',
|
|
181
|
+
'actual_account_groups_update',
|
|
182
|
+
'actual_accounts_close',
|
|
183
|
+
'actual_accounts_create',
|
|
184
|
+
'actual_accounts_delete',
|
|
185
|
+
'actual_accounts_reopen',
|
|
186
|
+
'actual_accounts_update',
|
|
187
|
+
'actual_bank_sync',
|
|
188
|
+
'actual_budget_updates_batch',
|
|
189
|
+
'actual_budgets_export',
|
|
190
|
+
'actual_budgets_holdForNextMonth',
|
|
191
|
+
'actual_budgets_import',
|
|
192
|
+
'actual_budgets_resetHold',
|
|
193
|
+
'actual_budgets_setAmount',
|
|
194
|
+
'actual_budgets_setCarryover',
|
|
195
|
+
'actual_budgets_switch',
|
|
196
|
+
'actual_budgets_transfer',
|
|
197
|
+
'actual_categories_create',
|
|
198
|
+
'actual_categories_delete',
|
|
199
|
+
'actual_categories_update',
|
|
200
|
+
'actual_category_groups_create',
|
|
201
|
+
'actual_category_groups_delete',
|
|
202
|
+
'actual_category_groups_update',
|
|
203
|
+
'actual_notes_update',
|
|
204
|
+
'actual_payees_create',
|
|
205
|
+
'actual_payees_delete',
|
|
206
|
+
'actual_payees_merge',
|
|
207
|
+
'actual_payees_update',
|
|
208
|
+
'actual_rules_create',
|
|
209
|
+
'actual_rules_create_batch',
|
|
210
|
+
'actual_rules_create_or_update',
|
|
211
|
+
'actual_rules_delete',
|
|
212
|
+
'actual_rules_update',
|
|
213
|
+
'actual_schedules_create',
|
|
214
|
+
'actual_schedules_delete',
|
|
215
|
+
'actual_schedules_update',
|
|
216
|
+
'actual_session_close',
|
|
217
|
+
'actual_tags_create',
|
|
218
|
+
'actual_tags_delete',
|
|
219
|
+
'actual_tags_update',
|
|
220
|
+
'actual_transactions_create',
|
|
221
|
+
'actual_transactions_delete',
|
|
222
|
+
'actual_transactions_import',
|
|
223
|
+
'actual_transactions_update',
|
|
224
|
+
'actual_transactions_update_batch',
|
|
225
|
+
'actual_transfers_create',
|
|
226
|
+
]);
|
|
227
|
+
const splitList = (raw) => raw.split(',').map((t) => t.trim()).filter(Boolean);
|
|
228
|
+
/**
|
|
229
|
+
* Resolve the publication policy. Pure: same inputs, same policy, no environment reads.
|
|
230
|
+
* Throws (a startup failure) on an unknown toolset, preset or tool name, on a group or
|
|
231
|
+
* preset naming an unregistered tool, and on a configuration that publishes nothing.
|
|
232
|
+
*/
|
|
233
|
+
export function resolvePublishedToolNames(registeredNames, settings) {
|
|
234
|
+
const registered = new Set(registeredNames);
|
|
235
|
+
// A table naming a tool the registry lacks would advertise something that cannot run.
|
|
236
|
+
for (const [kind, table] of [['Toolset', TOOLSETS], ['Preset', PRESETS]]) {
|
|
237
|
+
for (const [name, members] of Object.entries(table)) {
|
|
238
|
+
const missing = members.filter((m) => !registered.has(m));
|
|
239
|
+
if (missing.length > 0) {
|
|
240
|
+
throw new Error(`${kind} "${name}" names tools that are not registered: ${missing.join(', ')}`);
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
const validToolsets = ['all', ...TOOLSET_NAMES, ...PRESET_NAMES];
|
|
245
|
+
const toolsetTokens = splitList(settings.toolsets);
|
|
246
|
+
if (settings.toolsets.trim() !== '' && toolsetTokens.length === 0) {
|
|
247
|
+
throw new Error(`MCP_TOOLSETS="${settings.toolsets}" names no toolset. Valid names: ${validToolsets.join(', ')}`);
|
|
248
|
+
}
|
|
249
|
+
const requested = toolsetTokens.length > 0 ? toolsetTokens : ['all'];
|
|
250
|
+
const unknownToolsets = requested.filter((t) => !validToolsets.includes(t));
|
|
251
|
+
if (unknownToolsets.length > 0) {
|
|
252
|
+
throw new Error(`Unknown name in MCP_TOOLSETS: ${unknownToolsets.map((t) => `"${t}"`).join(', ')}. ` +
|
|
253
|
+
`Valid names: ${validToolsets.join(', ')}`);
|
|
254
|
+
}
|
|
255
|
+
const extras = splitList(settings.tools);
|
|
256
|
+
const unknownTools = extras.filter((t) => !registered.has(t));
|
|
257
|
+
if (unknownTools.length > 0) {
|
|
258
|
+
throw new Error(`Unknown tool in MCP_TOOLS: ${unknownTools.map((t) => `"${t}"`).join(', ')}. ` +
|
|
259
|
+
`Use the full registered name, for example actual_accounts_list. Registered tools: ${[...registered].sort().join(', ')}`);
|
|
260
|
+
}
|
|
261
|
+
const selected = new Set();
|
|
262
|
+
if (requested.includes('all')) {
|
|
263
|
+
registeredNames.forEach((n) => selected.add(n));
|
|
264
|
+
}
|
|
265
|
+
else {
|
|
266
|
+
for (const token of requested) {
|
|
267
|
+
const members = token in TOOLSETS ? TOOLSETS[token] : PRESETS[token];
|
|
268
|
+
members.forEach((n) => selected.add(n));
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
extras.forEach((n) => selected.add(n));
|
|
272
|
+
const published = [];
|
|
273
|
+
const hidden = new Map();
|
|
274
|
+
for (const name of registeredNames) {
|
|
275
|
+
if (!selected.has(name))
|
|
276
|
+
hidden.set(name, 'MCP_TOOLSETS');
|
|
277
|
+
else if (settings.readOnly && WRITE_CAPABLE.has(name))
|
|
278
|
+
hidden.set(name, 'MCP_READ_ONLY');
|
|
279
|
+
else
|
|
280
|
+
published.push(name);
|
|
281
|
+
}
|
|
282
|
+
if (published.length === 0) {
|
|
283
|
+
throw new Error(`No tools would be published: MCP_TOOLSETS="${settings.toolsets}", MCP_TOOLS="${settings.tools}" and ` +
|
|
284
|
+
`MCP_READ_ONLY=${settings.readOnly} resolve to 0 tools. For example, a toolset made only of writers ` +
|
|
285
|
+
`(structure) publishes nothing under MCP_READ_ONLY=true.`);
|
|
286
|
+
}
|
|
287
|
+
return Object.freeze({
|
|
288
|
+
published: Object.freeze(published),
|
|
289
|
+
hidden,
|
|
290
|
+
registered: registeredNames.length,
|
|
291
|
+
settings: Object.freeze({
|
|
292
|
+
toolsets: Object.freeze(requested),
|
|
293
|
+
tools: Object.freeze(extras),
|
|
294
|
+
readOnly: settings.readOnly,
|
|
295
|
+
}),
|
|
296
|
+
});
|
|
297
|
+
}
|
|
298
|
+
/** The typed refusal for a registered but unpublished tool, built from the stored reason. */
|
|
299
|
+
export function unavailableError(policy, tool) {
|
|
300
|
+
const setting = policy.hidden.get(tool);
|
|
301
|
+
return setting ? new ToolUnavailableError(tool, setting) : undefined;
|
|
302
|
+
}
|
|
@@ -385,11 +385,8 @@ advertisedUrl) {
|
|
|
385
385
|
content: [{ type: 'text', text: typeof result === 'string' ? result : JSON.stringify(result) }],
|
|
386
386
|
};
|
|
387
387
|
}
|
|
388
|
-
//
|
|
389
|
-
|
|
390
|
-
const r = await actualToolsManager.invoke(name, args ?? {});
|
|
391
|
-
return { content: [{ type: 'text', text: JSON.stringify(r) }] };
|
|
392
|
-
}
|
|
388
|
+
// There is deliberately no second dispatch path here (#483): actualToolsManager has
|
|
389
|
+
// no `invoke`, and every call must go through callTool, which refuses unpublished tools.
|
|
393
390
|
throw new Error(`Tool executor not available for ${name}`);
|
|
394
391
|
});
|
|
395
392
|
return { server };
|