@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.
Files changed (114) hide show
  1. package/.code/agents/0098661e-0fa3-4990-beb9-c0cbf3f123aa/status.txt +1 -0
  2. package/.code/agents/1324/exec-call_tIpx9uV1TpARbAMZonRQm8AO.txt +757 -0
  3. package/.code/agents/1572/exec-call_GjVFBFOWcY7lE0idc5nWlLNh.txt +781 -0
  4. package/.code/agents/1846/exec-call_1YNAVD18RjrMN7JnfkkQhUP3.txt +766 -0
  5. package/.code/agents/1846/exec-call_lh3lDzE4WJAh1lFiomiiZ73D.txt +766 -0
  6. package/.code/agents/2038/exec-call_DYwOukaYsL8VCONWmV2rUW5u.txt +766 -0
  7. package/.code/agents/2038/exec-call_c7fOQ7UrpVcTtvdfGBRM146V.txt +652 -0
  8. package/.code/agents/2038/exec-call_ySNyq9Mm55jWE480s54r5QcA.txt +766 -0
  9. package/.code/agents/2256/exec-call_AtPcRWPmFPMcmX6qOFm1fCEY.txt +766 -0
  10. package/.code/agents/2454/exec-call_aFJpupwjfZeOBm7ixI5Vc8z2.txt +766 -0
  11. package/.code/agents/2454/exec-call_wogZ4HfXTodTEXvdgXlVUBpv.txt +766 -0
  12. package/.code/agents/2e905864-aa07-4314-bcf9-c5b32277e4ac/result.txt +36 -0
  13. package/.code/agents/3073/exec-call_Peeagc9DxGYLgE6pNdMZhqIE.txt +766 -0
  14. package/.code/agents/3073/exec-call_d2YSE3hXF08KRSoUM3qd8Z3x.txt +766 -0
  15. package/.code/agents/335aa031-466d-4fb7-925f-3cd864e264d0/result.txt +191 -0
  16. package/.code/agents/3364/exec-call_NbhIrsM5HhyDZDmJZG5CuCYL.txt +766 -0
  17. package/.code/agents/3364/exec-call_cKtJg0NrXiwXEFwlsE3uPZRA.txt +766 -0
  18. package/.code/agents/36d98414-5cde-4d9d-9a67-a240a18c1f07/result.txt +189 -0
  19. package/.code/agents/4604e866-b7b8-44f5-992f-2f683b0a523b/status.txt +1 -0
  20. package/.code/agents/5f8dc01c-47b3-4163-b0b3-aa31be89fcdc/status.txt +1 -0
  21. package/.code/agents/7/exec-call_HltHpkDox0Zm1vGEjdksUgpE.txt +1120 -0
  22. package/.code/agents/7/exec-call_LCATrOPPAgbxW9Q1z0XaVi2E.txt +2646 -0
  23. package/.code/agents/7/exec-call_W8DeRfNG9hvbgVFvf0clBf6R.txt +2646 -0
  24. package/.code/agents/94a0ddf3-a304-4ec3-913e-3cceef509948/error.txt +1 -0
  25. package/.code/agents/e2c752b7-711d-423a-af57-f53c809deb84/result.txt +160 -0
  26. package/.code/agents/e6601719-c31f-4a0e-8c71-d70787d0ab71/status.txt +1 -0
  27. package/.code/agents/f250b7ed-5bd5-4036-aa8c-ce63caee7d61/result.txt +20 -0
  28. package/AGENTS.md +1 -36
  29. package/CLAUDE.md +131 -51
  30. package/NUL +0 -1
  31. package/README.md +27 -14
  32. package/dist/bundle/index.cjs +41 -41
  33. package/dist/server/YNABMCPServer.js +28 -381
  34. package/dist/server/config.d.ts +2 -0
  35. package/dist/server/config.js +1 -0
  36. package/dist/tools/accountTools.d.ts +2 -0
  37. package/dist/tools/accountTools.js +45 -0
  38. package/dist/tools/adapters.d.ts +12 -0
  39. package/dist/tools/adapters.js +25 -0
  40. package/dist/tools/budgetTools.d.ts +2 -0
  41. package/dist/tools/budgetTools.js +30 -0
  42. package/dist/tools/categoryTools.d.ts +2 -0
  43. package/dist/tools/categoryTools.js +45 -0
  44. package/dist/tools/monthTools.d.ts +2 -0
  45. package/dist/tools/monthTools.js +32 -0
  46. package/dist/tools/payeeTools.d.ts +2 -0
  47. package/dist/tools/payeeTools.js +32 -0
  48. package/dist/tools/reconciliation/index.d.ts +2 -0
  49. package/dist/tools/reconciliation/index.js +33 -0
  50. package/dist/tools/schemas/common.d.ts +3 -0
  51. package/dist/tools/schemas/common.js +3 -0
  52. package/dist/tools/schemas/outputs/comparisonOutputs.d.ts +1 -1
  53. package/dist/tools/transactionTools.d.ts +2 -0
  54. package/dist/tools/transactionTools.js +129 -0
  55. package/dist/tools/utilityTools.d.ts +3 -1
  56. package/dist/tools/utilityTools.js +32 -2
  57. package/dist/types/index.d.ts +1 -0
  58. package/dist/types/toolRegistration.d.ts +27 -0
  59. package/dist/types/toolRegistration.js +1 -0
  60. package/package.json +2 -2
  61. package/scripts/run-domain-integration-tests.js +4 -1
  62. package/src/__tests__/workflows.e2e.test.ts +1 -7
  63. package/src/server/YNABMCPServer.ts +33 -519
  64. package/src/server/__tests__/toolRegistration.test.ts +236 -0
  65. package/src/server/config.ts +1 -0
  66. package/src/tools/__tests__/adapters.test.ts +113 -0
  67. package/src/tools/__tests__/transactionTools.test.ts +90 -17
  68. package/src/tools/__tests__/utilityTools.test.ts +7 -7
  69. package/src/tools/accountTools.ts +53 -0
  70. package/src/tools/adapters.ts +74 -0
  71. package/src/tools/budgetTools.ts +37 -0
  72. package/src/tools/categoryTools.ts +53 -0
  73. package/src/tools/monthTools.ts +39 -0
  74. package/src/tools/payeeTools.ts +39 -0
  75. package/src/tools/reconciliation/index.ts +45 -0
  76. package/src/tools/schemas/common.ts +18 -0
  77. package/src/tools/transactionTools.ts +150 -0
  78. package/src/tools/utilityTools.ts +42 -2
  79. package/src/types/index.ts +3 -0
  80. package/src/types/toolRegistration.ts +88 -0
  81. package/.dxtignore +0 -57
  82. package/.github/workflows/pr-description-check.yml +0 -88
  83. package/CODEREVIEW_RESPONSE.md +0 -128
  84. package/SCHEMA_IMPROVEMENT_SUMMARY.md +0 -120
  85. package/TESTING_NOTES.md +0 -217
  86. package/accountactivity-merged.csv +0 -149
  87. package/bundle-analysis.html +0 -13110
  88. package/docs/README.md +0 -72
  89. package/docs/getting-started/CONFIGURATION.md +0 -175
  90. package/docs/getting-started/INSTALLATION.md +0 -333
  91. package/docs/getting-started/QUICKSTART.md +0 -282
  92. package/docs/guides/ARCHITECTURE.md +0 -533
  93. package/docs/guides/DEPLOYMENT.md +0 -189
  94. package/docs/guides/INTEGRATION_TESTING.md +0 -730
  95. package/docs/guides/TESTING.md +0 -591
  96. package/docs/plans/2025-11-20-reloadable-config-token-validation.md +0 -93
  97. package/docs/plans/2025-11-21-fix-transaction-cached-property.md +0 -362
  98. package/docs/plans/2025-11-21-reconciliation-error-handling.md +0 -90
  99. package/docs/plans/2025-11-21-v014-hardening.md +0 -153
  100. package/docs/plans/reconciliation-v2-redesign.md +0 -1571
  101. package/docs/reconciliation-flow.md +0 -83
  102. package/docs/reference/EXAMPLES.md +0 -946
  103. package/docs/reference/TOOLS.md +0 -348
  104. package/docs/reference/TROUBLESHOOTING.md +0 -481
  105. package/fix-types.sh +0 -17
  106. package/test-csv-sample.csv +0 -28
  107. package/test-exports/sample_bank_statement.csv +0 -7
  108. package/test-reconcile-autodetect.js +0 -40
  109. package/test-reconcile-tool.js +0 -152
  110. package/test-reconcile-with-csv.cjs +0 -89
  111. package/test-statement.csv +0 -8
  112. package/test_debug.js +0 -47
  113. package/test_mcp_tools.mjs +0 -75
  114. 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
+ });
@@ -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 YNAB API
44
- const mockYnabAPI = {
45
- transactions: {
46
- getTransactions: vi.fn(),
47
- getTransactionsByAccount: vi.fn(),
48
- getTransactionsByCategory: vi.fn(),
49
- getTransactionById: vi.fn(),
50
- createTransaction: vi.fn(),
51
- createTransactions: vi.fn(),
52
- updateTransaction: vi.fn(),
53
- updateTransactions: vi.fn(),
54
- deleteTransaction: vi.fn(),
55
- },
56
- accounts: {
57
- getAccountById: vi.fn(),
58
- },
59
- } as unknown as ynab.API;
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
+ };