@dizzlkheinz/ynab-mcpb 0.16.0 → 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 +131 -51
- package/NUL +0 -1
- package/README.md +27 -14
- 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 +129 -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__/transactionTools.test.ts +90 -17
- 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 +150 -0
- package/src/tools/utilityTools.ts +42 -2
- package/src/types/index.ts +3 -0
- package/src/types/toolRegistration.ts +88 -0
- package/.dxtignore +0 -57
- package/.github/workflows/pr-description-check.yml +0 -88
- package/CODEREVIEW_RESPONSE.md +0 -128
- package/SCHEMA_IMPROVEMENT_SUMMARY.md +0 -120
- package/TESTING_NOTES.md +0 -217
- package/accountactivity-merged.csv +0 -149
- package/bundle-analysis.html +0 -13110
- 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/plans/2025-11-20-reloadable-config-token-validation.md +0 -93
- package/docs/plans/2025-11-21-fix-transaction-cached-property.md +0 -362
- package/docs/plans/2025-11-21-reconciliation-error-handling.md +0 -90
- package/docs/plans/2025-11-21-v014-hardening.md +0 -153
- package/docs/plans/reconciliation-v2-redesign.md +0 -1571
- 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
- package/fix-types.sh +0 -17
- package/test-csv-sample.csv +0 -28
- package/test-exports/sample_bank_statement.csv +0 -7
- package/test-reconcile-autodetect.js +0 -40
- package/test-reconcile-tool.js +0 -152
- package/test-reconcile-with-csv.cjs +0 -89
- package/test-statement.csv +0 -8
- package/test_debug.js +0 -47
- package/test_mcp_tools.mjs +0 -75
- package/test_simple.mjs +0 -16
|
@@ -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
|
+
});
|
|
@@ -19,6 +19,25 @@ import {
|
|
|
19
19
|
DeleteTransactionSchema,
|
|
20
20
|
} from '../transactionTools.js';
|
|
21
21
|
|
|
22
|
+
// Mock the YNAB API - declare first so it can be used in deltaSupport mock
|
|
23
|
+
const mockYnabAPI = {
|
|
24
|
+
transactions: {
|
|
25
|
+
getTransactions: vi.fn(),
|
|
26
|
+
getTransactionsByAccount: vi.fn(),
|
|
27
|
+
getTransactionsByCategory: vi.fn(),
|
|
28
|
+
getTransactionById: vi.fn(),
|
|
29
|
+
createTransaction: vi.fn(),
|
|
30
|
+
createTransactions: vi.fn(),
|
|
31
|
+
updateTransaction: vi.fn(),
|
|
32
|
+
updateTransactions: vi.fn(),
|
|
33
|
+
deleteTransaction: vi.fn(),
|
|
34
|
+
},
|
|
35
|
+
accounts: {
|
|
36
|
+
getAccountById: vi.fn(),
|
|
37
|
+
getAccounts: vi.fn(),
|
|
38
|
+
},
|
|
39
|
+
} as unknown as ynab.API;
|
|
40
|
+
|
|
22
41
|
// Mock the cache manager
|
|
23
42
|
vi.mock('../../server/cacheManager.js', () => ({
|
|
24
43
|
cacheManager: {
|
|
@@ -40,23 +59,62 @@ vi.mock('../../server/cacheManager.js', () => ({
|
|
|
40
59
|
},
|
|
41
60
|
}));
|
|
42
61
|
|
|
43
|
-
// Mock the
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
}
|
|
62
|
+
// Mock deltaSupport to create a simple DeltaFetcher that calls the API directly
|
|
63
|
+
vi.mock('../deltaSupport.js', async (importOriginal) => {
|
|
64
|
+
const original = await importOriginal<typeof import('../deltaSupport.js')>();
|
|
65
|
+
return {
|
|
66
|
+
...original,
|
|
67
|
+
resolveDeltaFetcherArgs: vi.fn((_ynabAPI, _deltaFetcherOrParams, maybeParams) => {
|
|
68
|
+
const params = maybeParams ?? _deltaFetcherOrParams;
|
|
69
|
+
// Create a simple mock delta fetcher that calls the API directly
|
|
70
|
+
const mockDeltaFetcher = {
|
|
71
|
+
fetchAccounts: vi.fn(async (budgetId: string) => {
|
|
72
|
+
const response = await mockYnabAPI.accounts.getAccounts(budgetId);
|
|
73
|
+
return {
|
|
74
|
+
data: response.data.accounts,
|
|
75
|
+
wasCached: false,
|
|
76
|
+
usedDelta: false,
|
|
77
|
+
serverKnowledge: response.data.server_knowledge ?? 0,
|
|
78
|
+
};
|
|
79
|
+
}),
|
|
80
|
+
fetchTransactions: vi.fn(async (budgetId: string, sinceDate?: string, type?: string) => {
|
|
81
|
+
// Pass all 4 arguments to match YNAB API signature
|
|
82
|
+
const response = await mockYnabAPI.transactions.getTransactions(
|
|
83
|
+
budgetId,
|
|
84
|
+
sinceDate,
|
|
85
|
+
type,
|
|
86
|
+
undefined,
|
|
87
|
+
);
|
|
88
|
+
return {
|
|
89
|
+
data: response.data.transactions,
|
|
90
|
+
wasCached: false,
|
|
91
|
+
usedDelta: false,
|
|
92
|
+
serverKnowledge: response.data.server_knowledge ?? 0,
|
|
93
|
+
};
|
|
94
|
+
}),
|
|
95
|
+
fetchTransactionsByAccount: vi.fn(
|
|
96
|
+
async (budgetId: string, accountId: string, sinceDate?: string) => {
|
|
97
|
+
// Pass all 5 arguments to match YNAB API signature
|
|
98
|
+
const response = await mockYnabAPI.transactions.getTransactionsByAccount(
|
|
99
|
+
budgetId,
|
|
100
|
+
accountId,
|
|
101
|
+
sinceDate,
|
|
102
|
+
undefined,
|
|
103
|
+
undefined,
|
|
104
|
+
);
|
|
105
|
+
return {
|
|
106
|
+
data: response.data.transactions,
|
|
107
|
+
wasCached: false,
|
|
108
|
+
usedDelta: false,
|
|
109
|
+
serverKnowledge: response.data.server_knowledge ?? 0,
|
|
110
|
+
};
|
|
111
|
+
},
|
|
112
|
+
),
|
|
113
|
+
};
|
|
114
|
+
return { deltaFetcher: mockDeltaFetcher, params };
|
|
115
|
+
}),
|
|
116
|
+
};
|
|
117
|
+
});
|
|
60
118
|
|
|
61
119
|
// Import mocked cache manager
|
|
62
120
|
const { cacheManager, CacheManager } = await import('../../server/cacheManager.js');
|
|
@@ -238,12 +296,19 @@ describe('transactionTools', () => {
|
|
|
238
296
|
});
|
|
239
297
|
|
|
240
298
|
it('should filter by account_id when provided', async () => {
|
|
299
|
+
const mockAccountsResponse = {
|
|
300
|
+
data: {
|
|
301
|
+
accounts: [{ id: 'account-456', name: 'Test Account', deleted: false }],
|
|
302
|
+
server_knowledge: 100,
|
|
303
|
+
},
|
|
304
|
+
};
|
|
241
305
|
const mockResponse = {
|
|
242
306
|
data: {
|
|
243
307
|
transactions: [mockTransaction],
|
|
244
308
|
},
|
|
245
309
|
};
|
|
246
310
|
|
|
311
|
+
(mockYnabAPI.accounts.getAccounts as any).mockResolvedValue(mockAccountsResponse);
|
|
247
312
|
(mockYnabAPI.transactions.getTransactionsByAccount as any).mockResolvedValue(mockResponse);
|
|
248
313
|
|
|
249
314
|
const params = {
|
|
@@ -252,6 +317,7 @@ describe('transactionTools', () => {
|
|
|
252
317
|
};
|
|
253
318
|
const result = await handleListTransactions(mockYnabAPI, params);
|
|
254
319
|
|
|
320
|
+
expect(mockYnabAPI.accounts.getAccounts).toHaveBeenCalledWith('budget-123');
|
|
255
321
|
expect(mockYnabAPI.transactions.getTransactionsByAccount).toHaveBeenCalledWith(
|
|
256
322
|
'budget-123',
|
|
257
323
|
'account-456',
|
|
@@ -383,12 +449,19 @@ describe('transactionTools', () => {
|
|
|
383
449
|
} as ynab.TransactionDetail);
|
|
384
450
|
}
|
|
385
451
|
|
|
452
|
+
const mockAccountsResponse = {
|
|
453
|
+
data: {
|
|
454
|
+
accounts: [{ id: 'test-account', name: 'Test Account', deleted: false }],
|
|
455
|
+
server_knowledge: 100,
|
|
456
|
+
},
|
|
457
|
+
};
|
|
386
458
|
const mockResponse = {
|
|
387
459
|
data: {
|
|
388
460
|
transactions: largeTransactionList,
|
|
389
461
|
},
|
|
390
462
|
};
|
|
391
463
|
|
|
464
|
+
(mockYnabAPI.accounts.getAccounts as any).mockResolvedValue(mockAccountsResponse);
|
|
392
465
|
(mockYnabAPI.transactions.getTransactionsByAccount as any).mockResolvedValue(mockResponse);
|
|
393
466
|
|
|
394
467
|
const result = await handleListTransactions(mockYnabAPI, {
|
|
@@ -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
|
+
};
|