@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.
Files changed (92) 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 +28 -43
  30. package/NUL +0 -1
  31. package/README.md +8 -10
  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 +124 -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__/utilityTools.test.ts +7 -7
  68. package/src/tools/accountTools.ts +53 -0
  69. package/src/tools/adapters.ts +74 -0
  70. package/src/tools/budgetTools.ts +37 -0
  71. package/src/tools/categoryTools.ts +53 -0
  72. package/src/tools/monthTools.ts +39 -0
  73. package/src/tools/payeeTools.ts +39 -0
  74. package/src/tools/reconciliation/index.ts +45 -0
  75. package/src/tools/schemas/common.ts +18 -0
  76. package/src/tools/transactionTools.ts +140 -0
  77. package/src/tools/utilityTools.ts +42 -2
  78. package/src/types/index.ts +3 -0
  79. package/src/types/toolRegistration.ts +88 -0
  80. package/.github/workflows/pr-description-check.yml +0 -88
  81. package/docs/README.md +0 -72
  82. package/docs/getting-started/CONFIGURATION.md +0 -175
  83. package/docs/getting-started/INSTALLATION.md +0 -333
  84. package/docs/getting-started/QUICKSTART.md +0 -282
  85. package/docs/guides/ARCHITECTURE.md +0 -533
  86. package/docs/guides/DEPLOYMENT.md +0 -189
  87. package/docs/guides/INTEGRATION_TESTING.md +0 -730
  88. package/docs/guides/TESTING.md +0 -591
  89. package/docs/reconciliation-flow.md +0 -83
  90. package/docs/reference/EXAMPLES.md +0 -946
  91. package/docs/reference/TOOLS.md +0 -348
  92. 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
+ });
@@ -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
+ }
@@ -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
  */