@dizzlkheinz/ynab-mcpb 0.16.1 → 0.17.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/.code/agents/0098661e-0fa3-4990-beb9-c0cbf3f123aa/status.txt +1 -0
- package/.code/agents/1324/exec-call_tIpx9uV1TpARbAMZonRQm8AO.txt +757 -0
- package/.code/agents/1572/exec-call_GjVFBFOWcY7lE0idc5nWlLNh.txt +781 -0
- package/.code/agents/1846/exec-call_1YNAVD18RjrMN7JnfkkQhUP3.txt +766 -0
- package/.code/agents/1846/exec-call_lh3lDzE4WJAh1lFiomiiZ73D.txt +766 -0
- package/.code/agents/2038/exec-call_DYwOukaYsL8VCONWmV2rUW5u.txt +766 -0
- package/.code/agents/2038/exec-call_c7fOQ7UrpVcTtvdfGBRM146V.txt +652 -0
- package/.code/agents/2038/exec-call_ySNyq9Mm55jWE480s54r5QcA.txt +766 -0
- package/.code/agents/2256/exec-call_AtPcRWPmFPMcmX6qOFm1fCEY.txt +766 -0
- package/.code/agents/2454/exec-call_aFJpupwjfZeOBm7ixI5Vc8z2.txt +766 -0
- package/.code/agents/2454/exec-call_wogZ4HfXTodTEXvdgXlVUBpv.txt +766 -0
- package/.code/agents/2e905864-aa07-4314-bcf9-c5b32277e4ac/result.txt +36 -0
- package/.code/agents/3073/exec-call_Peeagc9DxGYLgE6pNdMZhqIE.txt +766 -0
- package/.code/agents/3073/exec-call_d2YSE3hXF08KRSoUM3qd8Z3x.txt +766 -0
- package/.code/agents/335aa031-466d-4fb7-925f-3cd864e264d0/result.txt +191 -0
- package/.code/agents/3364/exec-call_NbhIrsM5HhyDZDmJZG5CuCYL.txt +766 -0
- package/.code/agents/3364/exec-call_cKtJg0NrXiwXEFwlsE3uPZRA.txt +766 -0
- package/.code/agents/36d98414-5cde-4d9d-9a67-a240a18c1f07/result.txt +189 -0
- package/.code/agents/4604e866-b7b8-44f5-992f-2f683b0a523b/status.txt +1 -0
- package/.code/agents/5f8dc01c-47b3-4163-b0b3-aa31be89fcdc/status.txt +1 -0
- package/.code/agents/7/exec-call_HltHpkDox0Zm1vGEjdksUgpE.txt +1120 -0
- package/.code/agents/7/exec-call_LCATrOPPAgbxW9Q1z0XaVi2E.txt +2646 -0
- package/.code/agents/7/exec-call_W8DeRfNG9hvbgVFvf0clBf6R.txt +2646 -0
- package/.code/agents/94a0ddf3-a304-4ec3-913e-3cceef509948/error.txt +1 -0
- package/.code/agents/e2c752b7-711d-423a-af57-f53c809deb84/result.txt +160 -0
- package/.code/agents/e6601719-c31f-4a0e-8c71-d70787d0ab71/status.txt +1 -0
- package/.code/agents/f250b7ed-5bd5-4036-aa8c-ce63caee7d61/result.txt +20 -0
- package/AGENTS.md +1 -36
- package/CLAUDE.md +28 -43
- package/NUL +0 -1
- package/README.md +8 -10
- package/dist/bundle/index.cjs +41 -41
- package/dist/server/YNABMCPServer.js +28 -381
- package/dist/server/config.d.ts +2 -0
- package/dist/server/config.js +1 -0
- package/dist/tools/accountTools.d.ts +2 -0
- package/dist/tools/accountTools.js +45 -0
- package/dist/tools/adapters.d.ts +12 -0
- package/dist/tools/adapters.js +25 -0
- package/dist/tools/budgetTools.d.ts +2 -0
- package/dist/tools/budgetTools.js +30 -0
- package/dist/tools/categoryTools.d.ts +2 -0
- package/dist/tools/categoryTools.js +45 -0
- package/dist/tools/monthTools.d.ts +2 -0
- package/dist/tools/monthTools.js +32 -0
- package/dist/tools/payeeTools.d.ts +2 -0
- package/dist/tools/payeeTools.js +32 -0
- package/dist/tools/reconciliation/index.d.ts +2 -0
- package/dist/tools/reconciliation/index.js +33 -0
- package/dist/tools/schemas/common.d.ts +3 -0
- package/dist/tools/schemas/common.js +3 -0
- package/dist/tools/schemas/outputs/comparisonOutputs.d.ts +1 -1
- package/dist/tools/transactionTools.d.ts +2 -0
- package/dist/tools/transactionTools.js +124 -0
- package/dist/tools/utilityTools.d.ts +3 -1
- package/dist/tools/utilityTools.js +32 -2
- package/dist/types/index.d.ts +1 -0
- package/dist/types/toolRegistration.d.ts +27 -0
- package/dist/types/toolRegistration.js +1 -0
- package/package.json +2 -2
- package/scripts/run-domain-integration-tests.js +4 -1
- package/src/__tests__/workflows.e2e.test.ts +1 -7
- package/src/server/YNABMCPServer.ts +33 -519
- package/src/server/__tests__/toolRegistration.test.ts +236 -0
- package/src/server/config.ts +1 -0
- package/src/tools/__tests__/adapters.test.ts +113 -0
- package/src/tools/__tests__/utilityTools.test.ts +7 -7
- package/src/tools/accountTools.ts +53 -0
- package/src/tools/adapters.ts +74 -0
- package/src/tools/budgetTools.ts +37 -0
- package/src/tools/categoryTools.ts +53 -0
- package/src/tools/monthTools.ts +39 -0
- package/src/tools/payeeTools.ts +39 -0
- package/src/tools/reconciliation/index.ts +45 -0
- package/src/tools/schemas/common.ts +18 -0
- package/src/tools/transactionTools.ts +140 -0
- package/src/tools/utilityTools.ts +42 -2
- package/src/types/index.ts +3 -0
- package/src/types/toolRegistration.ts +88 -0
- package/.github/workflows/pr-description-check.yml +0 -88
- package/docs/README.md +0 -72
- package/docs/getting-started/CONFIGURATION.md +0 -175
- package/docs/getting-started/INSTALLATION.md +0 -333
- package/docs/getting-started/QUICKSTART.md +0 -282
- package/docs/guides/ARCHITECTURE.md +0 -533
- package/docs/guides/DEPLOYMENT.md +0 -189
- package/docs/guides/INTEGRATION_TESTING.md +0 -730
- package/docs/guides/TESTING.md +0 -591
- package/docs/reconciliation-flow.md +0 -83
- package/docs/reference/EXAMPLES.md +0 -946
- package/docs/reference/TOOLS.md +0 -348
- package/docs/reference/TROUBLESHOOTING.md +0 -481
|
@@ -0,0 +1,236 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Unit tests for tool registration verification.
|
|
3
|
+
*
|
|
4
|
+
* Verifies that all expected tools are registered through the factory pattern
|
|
5
|
+
* and that tool metadata (annotations, schemas) are properly configured.
|
|
6
|
+
*/
|
|
7
|
+
import { describe, expect, it, vi } from 'vitest';
|
|
8
|
+
|
|
9
|
+
// Mock config before importing YNABMCPServer
|
|
10
|
+
vi.mock('../config.js', () => ({
|
|
11
|
+
loadConfig: () => ({
|
|
12
|
+
YNAB_ACCESS_TOKEN: 'test-token-for-registration-tests',
|
|
13
|
+
YNAB_DEFAULT_BUDGET_ID: undefined,
|
|
14
|
+
LOG_LEVEL: 'info',
|
|
15
|
+
}),
|
|
16
|
+
config: {
|
|
17
|
+
YNAB_ACCESS_TOKEN: 'test-token-for-registration-tests',
|
|
18
|
+
YNAB_DEFAULT_BUDGET_ID: undefined,
|
|
19
|
+
LOG_LEVEL: 'info',
|
|
20
|
+
},
|
|
21
|
+
}));
|
|
22
|
+
|
|
23
|
+
import { YNABMCPServer } from '../YNABMCPServer.js';
|
|
24
|
+
|
|
25
|
+
const DEFAULT_BUDGET_ID = '11111111-1111-1111-1111-111111111111';
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Expected tool names organized by domain.
|
|
29
|
+
* This serves as the authoritative list of all 30 registered tools.
|
|
30
|
+
*/
|
|
31
|
+
const EXPECTED_TOOLS_BY_DOMAIN = {
|
|
32
|
+
budget: ['list_budgets', 'get_budget'],
|
|
33
|
+
account: ['list_accounts', 'get_account', 'create_account'],
|
|
34
|
+
transaction: [
|
|
35
|
+
'list_transactions',
|
|
36
|
+
'export_transactions',
|
|
37
|
+
'get_transaction',
|
|
38
|
+
'create_transaction',
|
|
39
|
+
'create_transactions',
|
|
40
|
+
'update_transaction',
|
|
41
|
+
'update_transactions',
|
|
42
|
+
'delete_transaction',
|
|
43
|
+
'create_receipt_split_transaction',
|
|
44
|
+
],
|
|
45
|
+
category: ['list_categories', 'get_category', 'update_category'],
|
|
46
|
+
payee: ['list_payees', 'get_payee'],
|
|
47
|
+
month: ['get_month', 'list_months'],
|
|
48
|
+
reconciliation: ['compare_transactions', 'reconcile_account'],
|
|
49
|
+
utility: ['get_user', 'convert_amount'],
|
|
50
|
+
server: [
|
|
51
|
+
'set_default_budget',
|
|
52
|
+
'get_default_budget',
|
|
53
|
+
'clear_cache',
|
|
54
|
+
'diagnostic_info',
|
|
55
|
+
'set_output_format',
|
|
56
|
+
],
|
|
57
|
+
} as const;
|
|
58
|
+
|
|
59
|
+
/** Flat list of all expected tool names */
|
|
60
|
+
const ALL_EXPECTED_TOOLS = Object.values(EXPECTED_TOOLS_BY_DOMAIN).flat();
|
|
61
|
+
|
|
62
|
+
/** Expected total tool count */
|
|
63
|
+
const EXPECTED_TOOL_COUNT = 30;
|
|
64
|
+
|
|
65
|
+
describe('Tool Registration', () => {
|
|
66
|
+
// Config is mocked at module level, no env setup needed
|
|
67
|
+
|
|
68
|
+
describe('Tool Count Verification', () => {
|
|
69
|
+
it('registers exactly 30 tools', () => {
|
|
70
|
+
const server = new YNABMCPServer(false);
|
|
71
|
+
const tools = server.getToolRegistry().listTools();
|
|
72
|
+
expect(tools).toHaveLength(EXPECTED_TOOL_COUNT);
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it('registers all expected tools with correct names', () => {
|
|
76
|
+
const server = new YNABMCPServer(false);
|
|
77
|
+
const tools = server.getToolRegistry().listTools();
|
|
78
|
+
const toolNames = tools.map((t) => t.name).sort();
|
|
79
|
+
const expectedNames = [...ALL_EXPECTED_TOOLS].sort();
|
|
80
|
+
|
|
81
|
+
expect(toolNames).toEqual(expectedNames);
|
|
82
|
+
});
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
describe('Domain Tool Registration', () => {
|
|
86
|
+
it.each([
|
|
87
|
+
['budget', EXPECTED_TOOLS_BY_DOMAIN.budget],
|
|
88
|
+
['account', EXPECTED_TOOLS_BY_DOMAIN.account],
|
|
89
|
+
['transaction', EXPECTED_TOOLS_BY_DOMAIN.transaction],
|
|
90
|
+
['category', EXPECTED_TOOLS_BY_DOMAIN.category],
|
|
91
|
+
['payee', EXPECTED_TOOLS_BY_DOMAIN.payee],
|
|
92
|
+
['month', EXPECTED_TOOLS_BY_DOMAIN.month],
|
|
93
|
+
['reconciliation', EXPECTED_TOOLS_BY_DOMAIN.reconciliation],
|
|
94
|
+
['utility', EXPECTED_TOOLS_BY_DOMAIN.utility],
|
|
95
|
+
['server', EXPECTED_TOOLS_BY_DOMAIN.server],
|
|
96
|
+
])('registers all %s domain tools', (domain: string, expectedTools: readonly string[]) => {
|
|
97
|
+
const server = new YNABMCPServer(false);
|
|
98
|
+
const tools = server.getToolRegistry().listTools();
|
|
99
|
+
const toolNames = tools.map((t) => t.name);
|
|
100
|
+
|
|
101
|
+
for (const toolName of expectedTools) {
|
|
102
|
+
expect(toolNames, `Missing ${domain} tool: ${toolName}`).toContain(toolName);
|
|
103
|
+
}
|
|
104
|
+
});
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
describe('Tool Metadata Verification', () => {
|
|
108
|
+
it('has descriptions for all tools', () => {
|
|
109
|
+
const server = new YNABMCPServer(false);
|
|
110
|
+
const tools = server.getToolRegistry().listTools();
|
|
111
|
+
|
|
112
|
+
for (const tool of tools) {
|
|
113
|
+
expect(tool.description, `${tool.name} missing description`).toBeDefined();
|
|
114
|
+
expect(tool.description.length, `${tool.name} has empty description`).toBeGreaterThan(0);
|
|
115
|
+
}
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
it('has input schemas for all tools', () => {
|
|
119
|
+
const server = new YNABMCPServer(false);
|
|
120
|
+
const tools = server.getToolRegistry().listTools();
|
|
121
|
+
|
|
122
|
+
for (const tool of tools) {
|
|
123
|
+
expect(tool.inputSchema, `${tool.name} missing inputSchema`).toBeDefined();
|
|
124
|
+
expect(tool.inputSchema.type, `${tool.name} inputSchema not object type`).toBe('object');
|
|
125
|
+
}
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
it('has annotations for all tools', () => {
|
|
129
|
+
const server = new YNABMCPServer(false);
|
|
130
|
+
const tools = server.getToolRegistry().listTools();
|
|
131
|
+
|
|
132
|
+
for (const tool of tools) {
|
|
133
|
+
expect(tool.annotations, `${tool.name} missing annotations`).toBeDefined();
|
|
134
|
+
expect(tool.annotations, `${tool.name} missing title`).toHaveProperty('title');
|
|
135
|
+
expect(tool.annotations, `${tool.name} missing readOnlyHint`).toHaveProperty(
|
|
136
|
+
'readOnlyHint',
|
|
137
|
+
);
|
|
138
|
+
expect(tool.annotations, `${tool.name} missing openWorldHint`).toHaveProperty(
|
|
139
|
+
'openWorldHint',
|
|
140
|
+
);
|
|
141
|
+
}
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
it('has YNAB prefix in all tool titles', () => {
|
|
145
|
+
const server = new YNABMCPServer(false);
|
|
146
|
+
const tools = server.getToolRegistry().listTools();
|
|
147
|
+
|
|
148
|
+
for (const tool of tools) {
|
|
149
|
+
expect(tool.annotations?.title, `${tool.name} title should start with "YNAB:"`).toMatch(
|
|
150
|
+
/^YNAB:/,
|
|
151
|
+
);
|
|
152
|
+
}
|
|
153
|
+
});
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
describe('No Duplicate Tools', () => {
|
|
157
|
+
it('does not have duplicate tool names', () => {
|
|
158
|
+
const server = new YNABMCPServer(false);
|
|
159
|
+
const tools = server.getToolRegistry().listTools();
|
|
160
|
+
const toolNames = tools.map((t) => t.name);
|
|
161
|
+
const uniqueNames = new Set(toolNames);
|
|
162
|
+
|
|
163
|
+
expect(uniqueNames.size).toBe(toolNames.length);
|
|
164
|
+
});
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
describe('Factory Registration Completeness', () => {
|
|
168
|
+
it('does not have unexpected tools registered', () => {
|
|
169
|
+
const server = new YNABMCPServer(false);
|
|
170
|
+
const tools = server.getToolRegistry().listTools();
|
|
171
|
+
const toolNames = tools.map((t) => t.name);
|
|
172
|
+
|
|
173
|
+
const unexpectedTools = toolNames.filter((name) => !ALL_EXPECTED_TOOLS.includes(name));
|
|
174
|
+
expect(unexpectedTools, 'Unexpected tools found').toEqual([]);
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
it('does not have missing expected tools', () => {
|
|
178
|
+
const server = new YNABMCPServer(false);
|
|
179
|
+
const tools = server.getToolRegistry().listTools();
|
|
180
|
+
const toolNames = tools.map((t) => t.name);
|
|
181
|
+
|
|
182
|
+
const missingTools = ALL_EXPECTED_TOOLS.filter((name) => !toolNames.includes(name));
|
|
183
|
+
expect(missingTools, 'Missing expected tools').toEqual([]);
|
|
184
|
+
});
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
describe('Default Argument Resolution', () => {
|
|
188
|
+
it('applies defaultArgumentResolver when budget_id is omitted', () => {
|
|
189
|
+
const server = new YNABMCPServer(false);
|
|
190
|
+
server.setDefaultBudget(DEFAULT_BUDGET_ID);
|
|
191
|
+
|
|
192
|
+
const listAccounts = server
|
|
193
|
+
.getToolRegistry()
|
|
194
|
+
.getToolDefinitions()
|
|
195
|
+
.find((tool) => tool.name === 'list_accounts');
|
|
196
|
+
|
|
197
|
+
expect(listAccounts?.defaultArgumentResolver).toBeDefined();
|
|
198
|
+
|
|
199
|
+
const resolved = listAccounts?.defaultArgumentResolver?.({
|
|
200
|
+
name: 'list_accounts',
|
|
201
|
+
accessToken: 'token',
|
|
202
|
+
rawArguments: {},
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
expect(resolved).toEqual({ budget_id: DEFAULT_BUDGET_ID });
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
it('budget-dependent tools have defaultArgumentResolver', () => {
|
|
209
|
+
const server = new YNABMCPServer(false);
|
|
210
|
+
const definitions = server.getToolRegistry().getToolDefinitions();
|
|
211
|
+
|
|
212
|
+
// Tools that require budget_id should have resolvers
|
|
213
|
+
const budgetDependentTools = [
|
|
214
|
+
'list_accounts',
|
|
215
|
+
'get_account',
|
|
216
|
+
'create_account',
|
|
217
|
+
'list_transactions',
|
|
218
|
+
'get_transaction',
|
|
219
|
+
'list_categories',
|
|
220
|
+
'get_category',
|
|
221
|
+
'list_payees',
|
|
222
|
+
'get_payee',
|
|
223
|
+
'get_month',
|
|
224
|
+
'list_months',
|
|
225
|
+
];
|
|
226
|
+
|
|
227
|
+
for (const toolName of budgetDependentTools) {
|
|
228
|
+
const tool = definitions.find((t) => t.name === toolName);
|
|
229
|
+
expect(
|
|
230
|
+
tool?.defaultArgumentResolver,
|
|
231
|
+
`${toolName} should have defaultArgumentResolver`,
|
|
232
|
+
).toBeDefined();
|
|
233
|
+
}
|
|
234
|
+
});
|
|
235
|
+
});
|
|
236
|
+
});
|
package/src/server/config.ts
CHANGED
|
@@ -5,6 +5,7 @@ import { ValidationError } from '../utils/errors.js';
|
|
|
5
5
|
|
|
6
6
|
const envSchema = z.object({
|
|
7
7
|
YNAB_ACCESS_TOKEN: z.string().trim().min(1, 'YNAB_ACCESS_TOKEN must be a non-empty string'),
|
|
8
|
+
YNAB_DEFAULT_BUDGET_ID: z.string().uuid('YNAB_DEFAULT_BUDGET_ID must be a valid UUID').optional(),
|
|
8
9
|
MCP_PORT: z.coerce.number().int().positive().optional(),
|
|
9
10
|
LOG_LEVEL: z.enum(['trace', 'debug', 'info', 'warn', 'error', 'fatal']).default('info'),
|
|
10
11
|
});
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
import { describe, expect, it, vi } from 'vitest';
|
|
2
|
+
import { DefaultArgumentResolutionError } from '../../server/toolRegistry.js';
|
|
3
|
+
import type { ToolContext } from '../../types/toolRegistration.js';
|
|
4
|
+
import { createAdapters, createBudgetResolver } from '../adapters.js';
|
|
5
|
+
|
|
6
|
+
const createMockContext = (overrides: Partial<ToolContext> = {}): ToolContext => {
|
|
7
|
+
return {
|
|
8
|
+
ynabAPI: (overrides.ynabAPI ?? ({ budgets: {} } as unknown as any)) as ToolContext['ynabAPI'],
|
|
9
|
+
deltaFetcher: overrides.deltaFetcher ?? ({} as any),
|
|
10
|
+
deltaCache: overrides.deltaCache ?? ({} as any),
|
|
11
|
+
serverKnowledgeStore: overrides.serverKnowledgeStore ?? ({} as any),
|
|
12
|
+
getDefaultBudgetId:
|
|
13
|
+
overrides.getDefaultBudgetId ?? vi.fn(() => '123e4567-e89b-12d3-a456-426614174000'),
|
|
14
|
+
setDefaultBudget: overrides.setDefaultBudget ?? vi.fn(),
|
|
15
|
+
cacheManager: overrides.cacheManager ?? ({} as any),
|
|
16
|
+
diagnosticManager: overrides.diagnosticManager,
|
|
17
|
+
} satisfies ToolContext;
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
describe('createAdapters', () => {
|
|
21
|
+
it('adapt passes api and params to handler', async () => {
|
|
22
|
+
const context = createMockContext();
|
|
23
|
+
const { adapt } = createAdapters(context);
|
|
24
|
+
const handler = vi.fn().mockResolvedValue({ content: [] });
|
|
25
|
+
|
|
26
|
+
const adapted = adapt(handler);
|
|
27
|
+
await adapted({ input: { foo: 'bar' }, context: {} as any });
|
|
28
|
+
|
|
29
|
+
expect(handler).toHaveBeenCalledWith(context.ynabAPI, { foo: 'bar' });
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it('adaptNoInput passes api without params', async () => {
|
|
33
|
+
const context = createMockContext();
|
|
34
|
+
const { adaptNoInput } = createAdapters(context);
|
|
35
|
+
const handler = vi.fn().mockResolvedValue({ content: [] });
|
|
36
|
+
|
|
37
|
+
const adapted = adaptNoInput(handler);
|
|
38
|
+
await adapted({ input: {}, context: {} as any });
|
|
39
|
+
|
|
40
|
+
expect(handler).toHaveBeenCalledWith(context.ynabAPI);
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it('adaptWithDelta passes deltaFetcher to handler', async () => {
|
|
44
|
+
const deltaFetcher = { fetch: vi.fn() } as any;
|
|
45
|
+
const context = createMockContext({ deltaFetcher });
|
|
46
|
+
const { adaptWithDelta } = createAdapters(context);
|
|
47
|
+
const handler = vi.fn().mockResolvedValue({ content: [] });
|
|
48
|
+
|
|
49
|
+
const adapted = adaptWithDelta(handler);
|
|
50
|
+
await adapted({ input: { a: 1 }, context: {} as any });
|
|
51
|
+
|
|
52
|
+
expect(handler).toHaveBeenCalledWith(context.ynabAPI, deltaFetcher, { a: 1 });
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it('adaptWrite passes cache and knowledge store to handler', async () => {
|
|
56
|
+
const deltaCache = { invalidate: vi.fn() } as any;
|
|
57
|
+
const serverKnowledgeStore = { update: vi.fn() } as any;
|
|
58
|
+
const context = createMockContext({ deltaCache, serverKnowledgeStore });
|
|
59
|
+
const { adaptWrite } = createAdapters(context);
|
|
60
|
+
const handler = vi.fn().mockResolvedValue({ content: [] });
|
|
61
|
+
|
|
62
|
+
const adapted = adaptWrite(handler);
|
|
63
|
+
await adapted({ input: { a: 2 }, context: {} as any });
|
|
64
|
+
|
|
65
|
+
expect(handler).toHaveBeenCalledWith(context.ynabAPI, deltaCache, serverKnowledgeStore, {
|
|
66
|
+
a: 2,
|
|
67
|
+
});
|
|
68
|
+
});
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
describe('createBudgetResolver', () => {
|
|
72
|
+
it('returns provided budget_id when supplied', () => {
|
|
73
|
+
const context = createMockContext({ getDefaultBudgetId: () => 'default-budget' });
|
|
74
|
+
const resolverFactory = createBudgetResolver(context);
|
|
75
|
+
const resolver = resolverFactory<{ budget_id?: string }>();
|
|
76
|
+
|
|
77
|
+
const result = resolver({
|
|
78
|
+
name: 'tool',
|
|
79
|
+
accessToken: 'token',
|
|
80
|
+
rawArguments: { budget_id: '123e4567-e89b-12d3-a456-426614174000' },
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
expect(result).toEqual({ budget_id: '123e4567-e89b-12d3-a456-426614174000' });
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
it('falls back to default budget id when not provided', () => {
|
|
87
|
+
const context = createMockContext({
|
|
88
|
+
getDefaultBudgetId: () => '89abcdef-0123-4567-89ab-cdef01234567',
|
|
89
|
+
});
|
|
90
|
+
const resolver = createBudgetResolver(context)<{ budget_id?: string }>();
|
|
91
|
+
|
|
92
|
+
const result = resolver({
|
|
93
|
+
name: 'tool',
|
|
94
|
+
accessToken: 'token',
|
|
95
|
+
rawArguments: {},
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
expect(result).toEqual({ budget_id: '89abcdef-0123-4567-89ab-cdef01234567' });
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
it('throws DefaultArgumentResolutionError when no budget id available', () => {
|
|
102
|
+
const context = createMockContext({ getDefaultBudgetId: () => undefined });
|
|
103
|
+
const resolver = createBudgetResolver(context)<{ budget_id?: string }>();
|
|
104
|
+
|
|
105
|
+
expect(() =>
|
|
106
|
+
resolver({
|
|
107
|
+
name: 'tool',
|
|
108
|
+
accessToken: 'token',
|
|
109
|
+
rawArguments: {},
|
|
110
|
+
}),
|
|
111
|
+
).toThrow(DefaultArgumentResolutionError);
|
|
112
|
+
});
|
|
113
|
+
});
|
|
@@ -85,7 +85,7 @@ describe('Utility Tools', () => {
|
|
|
85
85
|
it('should convert dollars to milliunits correctly', async () => {
|
|
86
86
|
const params = { amount: 10.5, to_milliunits: true };
|
|
87
87
|
|
|
88
|
-
const result = await handleConvertAmount(params);
|
|
88
|
+
const result = await handleConvertAmount(mockYnabAPI, params);
|
|
89
89
|
const response = JSON.parse(result.content[0].text);
|
|
90
90
|
|
|
91
91
|
expect(response.conversion.original_amount).toBe(10.5);
|
|
@@ -97,7 +97,7 @@ describe('Utility Tools', () => {
|
|
|
97
97
|
it('should convert milliunits to dollars correctly', async () => {
|
|
98
98
|
const params = { amount: 10500, to_milliunits: false };
|
|
99
99
|
|
|
100
|
-
const result = await handleConvertAmount(params);
|
|
100
|
+
const result = await handleConvertAmount(mockYnabAPI, params);
|
|
101
101
|
const response = JSON.parse(result.content[0].text);
|
|
102
102
|
|
|
103
103
|
expect(response.conversion.original_amount).toBe(10500);
|
|
@@ -109,7 +109,7 @@ describe('Utility Tools', () => {
|
|
|
109
109
|
it('should handle zero amounts', async () => {
|
|
110
110
|
const params = { amount: 0, to_milliunits: true };
|
|
111
111
|
|
|
112
|
-
const result = await handleConvertAmount(params);
|
|
112
|
+
const result = await handleConvertAmount(mockYnabAPI, params);
|
|
113
113
|
const response = JSON.parse(result.content[0].text);
|
|
114
114
|
|
|
115
115
|
expect(response.conversion.original_amount).toBe(0);
|
|
@@ -120,7 +120,7 @@ describe('Utility Tools', () => {
|
|
|
120
120
|
it('should handle negative amounts', async () => {
|
|
121
121
|
const params = { amount: -5.25, to_milliunits: true };
|
|
122
122
|
|
|
123
|
-
const result = await handleConvertAmount(params);
|
|
123
|
+
const result = await handleConvertAmount(mockYnabAPI, params);
|
|
124
124
|
const response = JSON.parse(result.content[0].text);
|
|
125
125
|
|
|
126
126
|
expect(response.conversion.original_amount).toBe(-5.25);
|
|
@@ -131,7 +131,7 @@ describe('Utility Tools', () => {
|
|
|
131
131
|
it('should handle floating-point precision correctly', async () => {
|
|
132
132
|
const params = { amount: 0.01, to_milliunits: true };
|
|
133
133
|
|
|
134
|
-
const result = await handleConvertAmount(params);
|
|
134
|
+
const result = await handleConvertAmount(mockYnabAPI, params);
|
|
135
135
|
const response = JSON.parse(result.content[0].text);
|
|
136
136
|
|
|
137
137
|
expect(response.conversion.converted_amount).toBe(10);
|
|
@@ -140,7 +140,7 @@ describe('Utility Tools', () => {
|
|
|
140
140
|
it('should handle large amounts', async () => {
|
|
141
141
|
const params = { amount: 999999.99, to_milliunits: true };
|
|
142
142
|
|
|
143
|
-
const result = await handleConvertAmount(params);
|
|
143
|
+
const result = await handleConvertAmount(mockYnabAPI, params);
|
|
144
144
|
const response = JSON.parse(result.content[0].text);
|
|
145
145
|
|
|
146
146
|
expect(response.conversion.converted_amount).toBe(999999990);
|
|
@@ -149,7 +149,7 @@ describe('Utility Tools', () => {
|
|
|
149
149
|
it('should round to nearest milliunit when converting from dollars', async () => {
|
|
150
150
|
const params = { amount: 10.5555, to_milliunits: true };
|
|
151
151
|
|
|
152
|
-
const result = await handleConvertAmount(params);
|
|
152
|
+
const result = await handleConvertAmount(mockYnabAPI, params);
|
|
153
153
|
const response = JSON.parse(result.content[0].text);
|
|
154
154
|
|
|
155
155
|
expect(response.conversion.converted_amount).toBe(10556); // Rounded from 10555.5
|
|
@@ -10,6 +10,9 @@ import type { DeltaCache } from '../server/deltaCache.js';
|
|
|
10
10
|
import type { ServerKnowledgeStore } from '../server/serverKnowledgeStore.js';
|
|
11
11
|
import { CacheKeys } from '../server/cacheKeys.js';
|
|
12
12
|
import { resolveDeltaFetcherArgs, resolveDeltaWriteArgs } from './deltaSupport.js';
|
|
13
|
+
import type { ToolFactory } from '../types/toolRegistration.js';
|
|
14
|
+
import { createAdapters, createBudgetResolver } from './adapters.js';
|
|
15
|
+
import { ToolAnnotationPresets } from './toolCategories.js';
|
|
13
16
|
|
|
14
17
|
/**
|
|
15
18
|
* Schema for ynab:list_accounts tool parameters
|
|
@@ -286,3 +289,53 @@ export async function handleCreateAccount(
|
|
|
286
289
|
'creating account',
|
|
287
290
|
);
|
|
288
291
|
}
|
|
292
|
+
|
|
293
|
+
/**
|
|
294
|
+
* Registers all account-related tools with the registry.
|
|
295
|
+
*/
|
|
296
|
+
export const registerAccountTools: ToolFactory = (registry, context) => {
|
|
297
|
+
const { adapt, adaptWithDelta, adaptWrite } = createAdapters(context);
|
|
298
|
+
const budgetResolver = createBudgetResolver(context);
|
|
299
|
+
|
|
300
|
+
registry.register({
|
|
301
|
+
name: 'list_accounts',
|
|
302
|
+
description: 'List all accounts for a specific budget',
|
|
303
|
+
inputSchema: ListAccountsSchema,
|
|
304
|
+
handler: adaptWithDelta(handleListAccounts),
|
|
305
|
+
defaultArgumentResolver: budgetResolver<z.infer<typeof ListAccountsSchema>>(),
|
|
306
|
+
metadata: {
|
|
307
|
+
annotations: {
|
|
308
|
+
...ToolAnnotationPresets.READ_ONLY_EXTERNAL,
|
|
309
|
+
title: 'YNAB: List Accounts',
|
|
310
|
+
},
|
|
311
|
+
},
|
|
312
|
+
});
|
|
313
|
+
|
|
314
|
+
registry.register({
|
|
315
|
+
name: 'get_account',
|
|
316
|
+
description: 'Get detailed information for a specific account',
|
|
317
|
+
inputSchema: GetAccountSchema,
|
|
318
|
+
handler: adapt(handleGetAccount),
|
|
319
|
+
defaultArgumentResolver: budgetResolver<z.infer<typeof GetAccountSchema>>(),
|
|
320
|
+
metadata: {
|
|
321
|
+
annotations: {
|
|
322
|
+
...ToolAnnotationPresets.READ_ONLY_EXTERNAL,
|
|
323
|
+
title: 'YNAB: Get Account Details',
|
|
324
|
+
},
|
|
325
|
+
},
|
|
326
|
+
});
|
|
327
|
+
|
|
328
|
+
registry.register({
|
|
329
|
+
name: 'create_account',
|
|
330
|
+
description: 'Create a new account in the specified budget',
|
|
331
|
+
inputSchema: CreateAccountSchema,
|
|
332
|
+
handler: adaptWrite(handleCreateAccount),
|
|
333
|
+
defaultArgumentResolver: budgetResolver<z.infer<typeof CreateAccountSchema>>(),
|
|
334
|
+
metadata: {
|
|
335
|
+
annotations: {
|
|
336
|
+
...ToolAnnotationPresets.WRITE_EXTERNAL_CREATE,
|
|
337
|
+
title: 'YNAB: Create Account',
|
|
338
|
+
},
|
|
339
|
+
},
|
|
340
|
+
});
|
|
341
|
+
};
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @fileoverview Adapter utilities for tool factory functions.
|
|
3
|
+
* Provides createAdapters() to reduce boilerplate when registering tools,
|
|
4
|
+
* and createBudgetResolver() for consistent budget ID resolution.
|
|
5
|
+
* @module tools/adapters
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import type { CallToolResult } from '@modelcontextprotocol/sdk/types.js';
|
|
9
|
+
import type { ToolExecutionPayload, DefaultArgumentResolver } from '../server/toolRegistry.js';
|
|
10
|
+
import { BudgetResolver } from '../server/budgetResolver.js';
|
|
11
|
+
import { DefaultArgumentResolutionError } from '../server/toolRegistry.js';
|
|
12
|
+
import type {
|
|
13
|
+
ToolContext,
|
|
14
|
+
Handler,
|
|
15
|
+
DeltaHandler,
|
|
16
|
+
WriteHandler,
|
|
17
|
+
NoInputHandler,
|
|
18
|
+
} from '../types/toolRegistration.js';
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Creates adapter functions bound to the provided context. These helpers reduce
|
|
22
|
+
* boilerplate inside tool factory modules by partially applying shared
|
|
23
|
+
* dependencies to handlers.
|
|
24
|
+
*/
|
|
25
|
+
export function createAdapters(context: ToolContext) {
|
|
26
|
+
const { ynabAPI, deltaFetcher, deltaCache, serverKnowledgeStore } = context;
|
|
27
|
+
|
|
28
|
+
return {
|
|
29
|
+
adapt:
|
|
30
|
+
<TInput extends Record<string, unknown>>(handler: Handler<TInput>) =>
|
|
31
|
+
async ({ input }: ToolExecutionPayload<TInput>): Promise<CallToolResult> =>
|
|
32
|
+
handler(ynabAPI, input),
|
|
33
|
+
|
|
34
|
+
adaptNoInput:
|
|
35
|
+
(handler: NoInputHandler) =>
|
|
36
|
+
async (_payload: ToolExecutionPayload<Record<string, unknown>>): Promise<CallToolResult> =>
|
|
37
|
+
handler(ynabAPI),
|
|
38
|
+
|
|
39
|
+
adaptWithDelta:
|
|
40
|
+
<TInput extends Record<string, unknown>>(handler: DeltaHandler<TInput>) =>
|
|
41
|
+
async ({ input }: ToolExecutionPayload<TInput>): Promise<CallToolResult> =>
|
|
42
|
+
handler(ynabAPI, deltaFetcher, input),
|
|
43
|
+
|
|
44
|
+
adaptWrite:
|
|
45
|
+
<TInput extends Record<string, unknown>>(handler: WriteHandler<TInput>) =>
|
|
46
|
+
async ({ input }: ToolExecutionPayload<TInput>): Promise<CallToolResult> =>
|
|
47
|
+
handler(ynabAPI, deltaCache, serverKnowledgeStore, input),
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Creates a budget ID resolver bound to the provided context. The returned
|
|
53
|
+
* resolver matches the ToolRegistry defaultArgumentResolver signature.
|
|
54
|
+
*/
|
|
55
|
+
export function createBudgetResolver(
|
|
56
|
+
context: ToolContext,
|
|
57
|
+
): <TInput extends { budget_id?: string | undefined }>() => DefaultArgumentResolver<TInput> {
|
|
58
|
+
return <TInput extends { budget_id?: string | undefined }>(): DefaultArgumentResolver<TInput> => {
|
|
59
|
+
return ({ rawArguments }) => {
|
|
60
|
+
const provided =
|
|
61
|
+
typeof rawArguments['budget_id'] === 'string' && rawArguments['budget_id'].length > 0
|
|
62
|
+
? rawArguments['budget_id']
|
|
63
|
+
: undefined;
|
|
64
|
+
|
|
65
|
+
const result = BudgetResolver.resolveBudgetId(provided, context.getDefaultBudgetId());
|
|
66
|
+
|
|
67
|
+
if (typeof result === 'string') {
|
|
68
|
+
return { budget_id: result } as Partial<TInput>;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
throw new DefaultArgumentResolutionError(result);
|
|
72
|
+
};
|
|
73
|
+
};
|
|
74
|
+
}
|
package/src/tools/budgetTools.ts
CHANGED
|
@@ -5,6 +5,10 @@ import { withToolErrorHandling } from '../types/index.js';
|
|
|
5
5
|
import { responseFormatter } from '../server/responseFormatter.js';
|
|
6
6
|
import type { DeltaFetcher } from './deltaFetcher.js';
|
|
7
7
|
import { resolveDeltaFetcherArgs } from './deltaSupport.js';
|
|
8
|
+
import type { ToolFactory } from '../types/toolRegistration.js';
|
|
9
|
+
import { createAdapters } from './adapters.js';
|
|
10
|
+
import { ToolAnnotationPresets } from './toolCategories.js';
|
|
11
|
+
import { emptyObjectSchema } from './schemas/common.js';
|
|
8
12
|
|
|
9
13
|
/**
|
|
10
14
|
* Schema for ynab:get_budget tool parameters
|
|
@@ -110,3 +114,36 @@ export async function handleGetBudget(
|
|
|
110
114
|
'getting budget details',
|
|
111
115
|
);
|
|
112
116
|
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Registers all budget-related tools with the provided registry.
|
|
120
|
+
*/
|
|
121
|
+
export const registerBudgetTools: ToolFactory = (registry, context) => {
|
|
122
|
+
const { adapt, adaptWithDelta } = createAdapters(context);
|
|
123
|
+
|
|
124
|
+
registry.register({
|
|
125
|
+
name: 'list_budgets',
|
|
126
|
+
description: "List all budgets associated with the user's account",
|
|
127
|
+
inputSchema: emptyObjectSchema,
|
|
128
|
+
handler: adaptWithDelta(handleListBudgets),
|
|
129
|
+
metadata: {
|
|
130
|
+
annotations: {
|
|
131
|
+
...ToolAnnotationPresets.READ_ONLY_EXTERNAL,
|
|
132
|
+
title: 'YNAB: List Budgets',
|
|
133
|
+
},
|
|
134
|
+
},
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
registry.register({
|
|
138
|
+
name: 'get_budget',
|
|
139
|
+
description: 'Get detailed information for a specific budget',
|
|
140
|
+
inputSchema: GetBudgetSchema,
|
|
141
|
+
handler: adapt(handleGetBudget),
|
|
142
|
+
metadata: {
|
|
143
|
+
annotations: {
|
|
144
|
+
...ToolAnnotationPresets.READ_ONLY_EXTERNAL,
|
|
145
|
+
title: 'YNAB: Get Budget Details',
|
|
146
|
+
},
|
|
147
|
+
},
|
|
148
|
+
});
|
|
149
|
+
};
|
|
@@ -10,6 +10,9 @@ import type { DeltaCache } from '../server/deltaCache.js';
|
|
|
10
10
|
import type { ServerKnowledgeStore } from '../server/serverKnowledgeStore.js';
|
|
11
11
|
import { CacheKeys } from '../server/cacheKeys.js';
|
|
12
12
|
import { resolveDeltaFetcherArgs, resolveDeltaWriteArgs } from './deltaSupport.js';
|
|
13
|
+
import type { ToolFactory } from '../types/toolRegistration.js';
|
|
14
|
+
import { createAdapters, createBudgetResolver } from './adapters.js';
|
|
15
|
+
import { ToolAnnotationPresets } from './toolCategories.js';
|
|
13
16
|
|
|
14
17
|
/**
|
|
15
18
|
* Schema for ynab:list_categories tool parameters
|
|
@@ -336,6 +339,56 @@ export async function handleUpdateCategory(
|
|
|
336
339
|
}
|
|
337
340
|
}
|
|
338
341
|
|
|
342
|
+
/**
|
|
343
|
+
* Registers all category-related tools with the registry.
|
|
344
|
+
*/
|
|
345
|
+
export const registerCategoryTools: ToolFactory = (registry, context) => {
|
|
346
|
+
const { adapt, adaptWithDelta, adaptWrite } = createAdapters(context);
|
|
347
|
+
const budgetResolver = createBudgetResolver(context);
|
|
348
|
+
|
|
349
|
+
registry.register({
|
|
350
|
+
name: 'list_categories',
|
|
351
|
+
description: 'List all categories for a specific budget',
|
|
352
|
+
inputSchema: ListCategoriesSchema,
|
|
353
|
+
handler: adaptWithDelta(handleListCategories),
|
|
354
|
+
defaultArgumentResolver: budgetResolver<ListCategoriesParams>(),
|
|
355
|
+
metadata: {
|
|
356
|
+
annotations: {
|
|
357
|
+
...ToolAnnotationPresets.READ_ONLY_EXTERNAL,
|
|
358
|
+
title: 'YNAB: List Categories',
|
|
359
|
+
},
|
|
360
|
+
},
|
|
361
|
+
});
|
|
362
|
+
|
|
363
|
+
registry.register({
|
|
364
|
+
name: 'get_category',
|
|
365
|
+
description: 'Get detailed information for a specific category',
|
|
366
|
+
inputSchema: GetCategorySchema,
|
|
367
|
+
handler: adapt(handleGetCategory),
|
|
368
|
+
defaultArgumentResolver: budgetResolver<GetCategoryParams>(),
|
|
369
|
+
metadata: {
|
|
370
|
+
annotations: {
|
|
371
|
+
...ToolAnnotationPresets.READ_ONLY_EXTERNAL,
|
|
372
|
+
title: 'YNAB: Get Category Details',
|
|
373
|
+
},
|
|
374
|
+
},
|
|
375
|
+
});
|
|
376
|
+
|
|
377
|
+
registry.register({
|
|
378
|
+
name: 'update_category',
|
|
379
|
+
description: 'Update the budgeted amount for a category in the current month',
|
|
380
|
+
inputSchema: UpdateCategorySchema,
|
|
381
|
+
handler: adaptWrite(handleUpdateCategory),
|
|
382
|
+
defaultArgumentResolver: budgetResolver<UpdateCategoryParams>(),
|
|
383
|
+
metadata: {
|
|
384
|
+
annotations: {
|
|
385
|
+
...ToolAnnotationPresets.WRITE_EXTERNAL_UPDATE,
|
|
386
|
+
title: 'YNAB: Update Category Budget',
|
|
387
|
+
},
|
|
388
|
+
},
|
|
389
|
+
});
|
|
390
|
+
};
|
|
391
|
+
|
|
339
392
|
/**
|
|
340
393
|
* Handles errors from category-related API calls
|
|
341
394
|
*/
|