@dizzlkheinz/ynab-mcpb 0.17.1 → 0.18.1

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 (56) hide show
  1. package/.github/workflows/ci-tests.yml +4 -4
  2. package/.github/workflows/full-integration.yml +2 -2
  3. package/.github/workflows/publish.yml +1 -1
  4. package/.github/workflows/release.yml +2 -2
  5. package/CHANGELOG.md +12 -1
  6. package/CLAUDE.md +10 -7
  7. package/README.md +6 -1
  8. package/dist/bundle/index.cjs +52 -52
  9. package/dist/server/YNABMCPServer.d.ts +7 -2
  10. package/dist/server/YNABMCPServer.js +42 -11
  11. package/dist/server/cacheManager.js +6 -5
  12. package/dist/server/completions.d.ts +25 -0
  13. package/dist/server/completions.js +160 -0
  14. package/dist/server/config.d.ts +2 -2
  15. package/dist/server/errorHandler.js +1 -0
  16. package/dist/server/rateLimiter.js +3 -1
  17. package/dist/server/resources.d.ts +1 -0
  18. package/dist/server/resources.js +33 -16
  19. package/dist/server/securityMiddleware.d.ts +2 -1
  20. package/dist/server/securityMiddleware.js +1 -0
  21. package/dist/server/toolRegistry.d.ts +9 -0
  22. package/dist/server/toolRegistry.js +11 -0
  23. package/dist/tools/adapters.d.ts +3 -1
  24. package/dist/tools/adapters.js +1 -0
  25. package/dist/tools/reconciliation/executor.d.ts +2 -0
  26. package/dist/tools/reconciliation/executor.js +26 -9
  27. package/dist/tools/reconciliation/index.d.ts +3 -2
  28. package/dist/tools/reconciliation/index.js +4 -3
  29. package/docs/reference/API.md +68 -27
  30. package/package.json +2 -2
  31. package/src/__tests__/comprehensive.integration.test.ts +4 -4
  32. package/src/__tests__/performance.test.ts +1 -2
  33. package/src/__tests__/smoke.e2e.test.ts +70 -0
  34. package/src/__tests__/testUtils.ts +2 -113
  35. package/src/server/YNABMCPServer.ts +64 -10
  36. package/src/server/__tests__/completions.integration.test.ts +117 -0
  37. package/src/server/__tests__/completions.test.ts +319 -0
  38. package/src/server/__tests__/resources.template.test.ts +3 -3
  39. package/src/server/__tests__/resources.test.ts +3 -3
  40. package/src/server/__tests__/toolRegistration.test.ts +1 -1
  41. package/src/server/cacheManager.ts +7 -6
  42. package/src/server/completions.ts +279 -0
  43. package/src/server/errorHandler.ts +1 -0
  44. package/src/server/rateLimiter.ts +4 -1
  45. package/src/server/resources.ts +49 -13
  46. package/src/server/securityMiddleware.ts +1 -0
  47. package/src/server/toolRegistry.ts +42 -0
  48. package/src/tools/adapters.ts +22 -1
  49. package/src/tools/reconciliation/__tests__/executor.integration.test.ts +12 -26
  50. package/src/tools/reconciliation/__tests__/executor.progress.test.ts +462 -0
  51. package/src/tools/reconciliation/__tests__/executor.test.ts +36 -31
  52. package/src/tools/reconciliation/executor.ts +56 -27
  53. package/src/tools/reconciliation/index.ts +7 -3
  54. package/vitest.config.ts +2 -0
  55. package/src/__tests__/delta.performance.test.ts +0 -80
  56. package/src/__tests__/workflows.e2e.test.ts +0 -1658
@@ -16,6 +16,7 @@ export declare class YNABMCPServer {
16
16
  private deltaFetcher;
17
17
  private diagnosticManager;
18
18
  private errorHandler;
19
+ private completionsManager;
19
20
  constructor(exitOnError?: boolean);
20
21
  validateToken(): Promise<boolean>;
21
22
  private isMalformedTokenResponse;
@@ -68,6 +69,7 @@ export declare class YNABMCPServer {
68
69
  src: string;
69
70
  mimeType?: string | undefined;
70
71
  sizes?: string[] | undefined;
72
+ theme?: "light" | "dark" | undefined;
71
73
  }[] | undefined;
72
74
  title?: string | undefined;
73
75
  }[];
@@ -132,6 +134,7 @@ export declare class YNABMCPServer {
132
134
  src: string;
133
135
  mimeType?: string | undefined;
134
136
  sizes?: string[] | undefined;
137
+ theme?: "light" | "dark" | undefined;
135
138
  }[] | undefined;
136
139
  title?: string | undefined;
137
140
  } | {
@@ -162,8 +165,8 @@ export declare class YNABMCPServer {
162
165
  })[];
163
166
  _meta?: {
164
167
  [x: string]: unknown;
168
+ progressToken?: string | number | undefined;
165
169
  "io.modelcontextprotocol/related-task"?: {
166
- [x: string]: unknown;
167
170
  taskId: string;
168
171
  } | undefined;
169
172
  } | undefined;
@@ -235,6 +238,7 @@ export declare class YNABMCPServer {
235
238
  src: string;
236
239
  mimeType?: string | undefined;
237
240
  sizes?: string[] | undefined;
241
+ theme?: "light" | "dark" | undefined;
238
242
  }[] | undefined;
239
243
  title?: string | undefined;
240
244
  } | {
@@ -265,8 +269,8 @@ export declare class YNABMCPServer {
265
269
  })[];
266
270
  _meta?: {
267
271
  [x: string]: unknown;
272
+ progressToken?: string | number | undefined;
268
273
  "io.modelcontextprotocol/related-task"?: {
269
- [x: string]: unknown;
270
274
  taskId: string;
271
275
  } | undefined;
272
276
  } | undefined;
@@ -311,6 +315,7 @@ export declare class YNABMCPServer {
311
315
  src: string;
312
316
  mimeType?: string | undefined;
313
317
  sizes?: string[] | undefined;
318
+ theme?: "light" | "dark" | undefined;
314
319
  }[] | undefined;
315
320
  title?: string | undefined;
316
321
  }[];
@@ -3,7 +3,7 @@ import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'
3
3
  import fs from 'fs';
4
4
  import path from 'path';
5
5
  import { z } from 'zod';
6
- import { CallToolRequestSchema, ListToolsRequestSchema, ListResourcesRequestSchema, ListResourceTemplatesRequestSchema, ListPromptsRequestSchema, ReadResourceRequestSchema, GetPromptRequestSchema, } from '@modelcontextprotocol/sdk/types.js';
6
+ import { CallToolRequestSchema, ListToolsRequestSchema, ListResourcesRequestSchema, ListResourceTemplatesRequestSchema, ListPromptsRequestSchema, ReadResourceRequestSchema, GetPromptRequestSchema, CompleteRequestSchema, ErrorCode, McpError, } from '@modelcontextprotocol/sdk/types.js';
7
7
  import * as ynab from 'ynab';
8
8
  import { AuthenticationError, ConfigurationError, ValidationError as ConfigValidationError, } from '../utils/errors.js';
9
9
  import { ValidationError } from '../types/index.js';
@@ -30,6 +30,7 @@ import { ServerKnowledgeStore } from './serverKnowledgeStore.js';
30
30
  import { DeltaCache } from './deltaCache.js';
31
31
  import { DeltaFetcher } from '../tools/deltaFetcher.js';
32
32
  import { ToolAnnotationPresets } from '../tools/toolCategories.js';
33
+ import { CompletionsManager } from './completions.js';
33
34
  export class YNABMCPServer {
34
35
  constructor(exitOnError = true) {
35
36
  this.exitOnError = exitOnError;
@@ -42,9 +43,13 @@ export class YNABMCPServer {
42
43
  version: this.serverVersion,
43
44
  }, {
44
45
  capabilities: {
45
- tools: { listChanged: true },
46
- resources: { listChanged: true },
47
- prompts: { listChanged: true },
46
+ tools: { listChanged: false },
47
+ resources: {
48
+ subscribe: false,
49
+ listChanged: false,
50
+ },
51
+ prompts: { listChanged: false },
52
+ completions: {},
48
53
  },
49
54
  });
50
55
  this.errorHandler = createErrorHandler(responseFormatter);
@@ -111,6 +116,7 @@ export class YNABMCPServer {
111
116
  serverKnowledgeStore: this.serverKnowledgeStore,
112
117
  deltaCache: this.deltaCache,
113
118
  });
119
+ this.completionsManager = new CompletionsManager(this.ynabAPI, cacheManager, () => this.defaultBudgetId);
114
120
  this.setupToolRegistry();
115
121
  this.setupHandlers();
116
122
  }
@@ -162,12 +168,7 @@ export class YNABMCPServer {
162
168
  });
163
169
  this.server.setRequestHandler(ReadResourceRequestSchema, async (request) => {
164
170
  const { uri } = request.params;
165
- try {
166
- return await this.resourceManager.readResource(uri);
167
- }
168
- catch (error) {
169
- return this.errorHandler.handleError(error, `reading resource: ${uri}`);
170
- }
171
+ return await this.resourceManager.readResource(uri);
171
172
  });
172
173
  this.server.setRequestHandler(ListPromptsRequestSchema, async () => {
173
174
  return this.promptManager.listPrompts();
@@ -182,7 +183,10 @@ export class YNABMCPServer {
182
183
  tools: this.toolRegistry.listTools(),
183
184
  };
184
185
  });
185
- this.server.setRequestHandler(CallToolRequestSchema, async (request) => {
186
+ this.server.setRequestHandler(CallToolRequestSchema, async (request, extra) => {
187
+ if (!this.toolRegistry.hasTool(request.params.name)) {
188
+ throw new McpError(ErrorCode.InvalidParams, `Unknown tool: ${request.params.name}`);
189
+ }
186
190
  const rawArgs = (request.params.arguments ?? undefined);
187
191
  const minifyOverride = this.extractMinifyOverride(rawArgs);
188
192
  const sanitizedArgs = rawArgs
@@ -202,8 +206,35 @@ export class YNABMCPServer {
202
206
  if (minifyOverride !== undefined) {
203
207
  executionOptions.minifyOverride = minifyOverride;
204
208
  }
209
+ const progressToken = request.params
210
+ ._meta?.progressToken;
211
+ if (progressToken !== undefined && extra.sendNotification) {
212
+ executionOptions.sendProgress = async (params) => {
213
+ try {
214
+ await extra.sendNotification({
215
+ method: 'notifications/progress',
216
+ params: {
217
+ progressToken,
218
+ progress: params.progress,
219
+ ...(params.total !== undefined && { total: params.total }),
220
+ ...(params.message !== undefined && { message: params.message }),
221
+ },
222
+ });
223
+ }
224
+ catch {
225
+ }
226
+ };
227
+ }
205
228
  return await this.toolRegistry.executeTool(executionOptions);
206
229
  });
230
+ this.server.setRequestHandler(CompleteRequestSchema, async (request) => {
231
+ const { argument, context } = request.params;
232
+ const completionContext = context?.arguments ? { arguments: context.arguments } : undefined;
233
+ const result = await this.completionsManager.getCompletions(argument.name, argument.value, completionContext);
234
+ return {
235
+ completion: result.completion,
236
+ };
237
+ });
207
238
  }
208
239
  setupToolRegistry() {
209
240
  const register = (definition) => {
@@ -79,14 +79,15 @@ export class CacheManager {
79
79
  else {
80
80
  const providedTtl = ttlOrOptions?.ttl;
81
81
  ttl = providedTtl !== undefined ? providedTtl : this.defaultTTL;
82
- if (ttlOrOptions && 'staleWhileRevalidate' in ttlOrOptions) {
82
+ const hasStaleWhileRevalidate = ttlOrOptions !== undefined && 'staleWhileRevalidate' in ttlOrOptions;
83
+ if (hasStaleWhileRevalidate) {
83
84
  staleWhileRevalidate = ttlOrOptions.staleWhileRevalidate;
85
+ if (staleWhileRevalidate === undefined && this.defaultStaleWindow > 0) {
86
+ staleWhileRevalidate = this.defaultStaleWindow;
87
+ }
84
88
  }
85
89
  else {
86
- staleWhileRevalidate = ttlOrOptions?.staleWhileRevalidate;
87
- }
88
- if (staleWhileRevalidate === undefined && this.defaultStaleWindow > 0) {
89
- staleWhileRevalidate = this.defaultStaleWindow;
90
+ staleWhileRevalidate = undefined;
90
91
  }
91
92
  }
92
93
  const entry = {
@@ -0,0 +1,25 @@
1
+ import type * as ynab from 'ynab';
2
+ import type { CacheManager } from './cacheManager.js';
3
+ export interface CompletionResult {
4
+ completion: {
5
+ values: string[];
6
+ total?: number;
7
+ hasMore?: boolean;
8
+ };
9
+ }
10
+ interface CompletionContext {
11
+ arguments?: Record<string, string> | undefined;
12
+ }
13
+ export declare class CompletionsManager {
14
+ private readonly ynabAPI;
15
+ private readonly cacheManager;
16
+ private readonly getDefaultBudgetId;
17
+ constructor(ynabAPI: ynab.API, cacheManager: CacheManager, getDefaultBudgetId: () => string | undefined);
18
+ getCompletions(argumentName: string, value: string, context?: CompletionContext): Promise<CompletionResult>;
19
+ private completeBudgets;
20
+ private completeAccounts;
21
+ private completeCategories;
22
+ private completePayees;
23
+ private filterAndFormat;
24
+ }
25
+ export {};
@@ -0,0 +1,160 @@
1
+ import { CACHE_TTLS } from './cacheManager.js';
2
+ const MAX_COMPLETIONS = 100;
3
+ export class CompletionsManager {
4
+ constructor(ynabAPI, cacheManager, getDefaultBudgetId) {
5
+ this.ynabAPI = ynabAPI;
6
+ this.cacheManager = cacheManager;
7
+ this.getDefaultBudgetId = getDefaultBudgetId;
8
+ }
9
+ async getCompletions(argumentName, value, context) {
10
+ const normalizedName = argumentName.toLowerCase();
11
+ switch (normalizedName) {
12
+ case 'budget_id':
13
+ return this.completeBudgets(value);
14
+ case 'account_id':
15
+ case 'account_name':
16
+ return this.completeAccounts(value, context);
17
+ case 'category':
18
+ case 'category_id':
19
+ return this.completeCategories(value, context);
20
+ case 'payee':
21
+ case 'payee_id':
22
+ return this.completePayees(value, context);
23
+ default:
24
+ return { completion: { values: [], total: 0, hasMore: false } };
25
+ }
26
+ }
27
+ async completeBudgets(value) {
28
+ const budgets = await this.cacheManager.wrap('completions:budgets', {
29
+ ttl: CACHE_TTLS.BUDGETS,
30
+ loader: async () => {
31
+ const response = await this.ynabAPI.budgets.getBudgets();
32
+ return response.data.budgets.map((b) => ({
33
+ id: b.id,
34
+ name: b.name,
35
+ }));
36
+ },
37
+ });
38
+ return this.filterAndFormat(budgets, value, (b) => [b.name, b.id]);
39
+ }
40
+ async completeAccounts(value, context) {
41
+ const budgetId = context?.arguments?.['budget_id'] ?? this.getDefaultBudgetId();
42
+ if (!budgetId) {
43
+ return { completion: { values: [], total: 0, hasMore: false } };
44
+ }
45
+ const accounts = await this.cacheManager.wrap(`completions:accounts:${budgetId}`, {
46
+ ttl: CACHE_TTLS.ACCOUNTS,
47
+ loader: async () => {
48
+ const response = await this.ynabAPI.accounts.getAccounts(budgetId);
49
+ return response.data.accounts
50
+ .filter((a) => !a.deleted && !a.closed)
51
+ .map((a) => ({
52
+ id: a.id,
53
+ name: a.name,
54
+ }));
55
+ },
56
+ });
57
+ return this.filterAndFormat(accounts, value, (a) => [a.name, a.id]);
58
+ }
59
+ async completeCategories(value, context) {
60
+ const budgetId = context?.arguments?.['budget_id'] ?? this.getDefaultBudgetId();
61
+ if (!budgetId) {
62
+ return { completion: { values: [], total: 0, hasMore: false } };
63
+ }
64
+ const categories = await this.cacheManager.wrap(`completions:categories:${budgetId}`, {
65
+ ttl: CACHE_TTLS.CATEGORIES,
66
+ loader: async () => {
67
+ const response = await this.ynabAPI.categories.getCategories(budgetId);
68
+ const result = [];
69
+ for (const group of response.data.category_groups) {
70
+ if (group.hidden || group.deleted)
71
+ continue;
72
+ for (const cat of group.categories) {
73
+ if (cat.hidden || cat.deleted)
74
+ continue;
75
+ result.push({
76
+ id: cat.id,
77
+ name: cat.name,
78
+ group: group.name,
79
+ });
80
+ }
81
+ }
82
+ return result;
83
+ },
84
+ });
85
+ return this.filterAndFormat(categories, value, (c) => [c.name, `${c.group}: ${c.name}`, c.id]);
86
+ }
87
+ async completePayees(value, context) {
88
+ const budgetId = context?.arguments?.['budget_id'] ?? this.getDefaultBudgetId();
89
+ if (!budgetId) {
90
+ return { completion: { values: [], total: 0, hasMore: false } };
91
+ }
92
+ const payees = await this.cacheManager.wrap(`completions:payees:${budgetId}`, {
93
+ ttl: CACHE_TTLS.PAYEES,
94
+ loader: async () => {
95
+ const response = await this.ynabAPI.payees.getPayees(budgetId);
96
+ return response.data.payees
97
+ .filter((p) => !p.deleted)
98
+ .map((p) => ({
99
+ id: p.id,
100
+ name: p.name,
101
+ }));
102
+ },
103
+ });
104
+ return this.filterAndFormat(payees, value, (p) => [p.name, p.id]);
105
+ }
106
+ filterAndFormat(items, value, getSearchableValues) {
107
+ const lowerValue = value.toLowerCase();
108
+ const itemCache = new Map();
109
+ const getCachedValues = (item) => {
110
+ let cached = itemCache.get(item);
111
+ if (!cached) {
112
+ const values = getSearchableValues(item);
113
+ cached = { values, lowerValues: values.map((v) => v.toLowerCase()) };
114
+ itemCache.set(item, cached);
115
+ }
116
+ return cached;
117
+ };
118
+ const matches = items.filter((item) => {
119
+ const { lowerValues } = getCachedValues(item);
120
+ return lowerValues.some((v) => v.includes(lowerValue));
121
+ });
122
+ matches.sort((a, b) => {
123
+ const aCache = getCachedValues(a);
124
+ const bCache = getCachedValues(b);
125
+ const aStartsWith = aCache.lowerValues.some((v) => v.startsWith(lowerValue));
126
+ const bStartsWith = bCache.lowerValues.some((v) => v.startsWith(lowerValue));
127
+ if (aStartsWith && !bStartsWith)
128
+ return -1;
129
+ if (!aStartsWith && bStartsWith)
130
+ return 1;
131
+ return (aCache.values[0] ?? '').localeCompare(bCache.values[0] ?? '');
132
+ });
133
+ const uniqueValues = new Set();
134
+ for (const item of matches) {
135
+ const { values, lowerValues } = getCachedValues(item);
136
+ let selectedValue;
137
+ for (let i = 0; i < values.length; i++) {
138
+ if (lowerValues[i]?.includes(lowerValue)) {
139
+ if (selectedValue === undefined || i < values.indexOf(selectedValue)) {
140
+ selectedValue = values[i];
141
+ }
142
+ break;
143
+ }
144
+ }
145
+ if (selectedValue) {
146
+ uniqueValues.add(selectedValue);
147
+ }
148
+ if (uniqueValues.size >= MAX_COMPLETIONS)
149
+ break;
150
+ }
151
+ const resultValues = Array.from(uniqueValues).slice(0, MAX_COMPLETIONS);
152
+ return {
153
+ completion: {
154
+ values: resultValues,
155
+ total: matches.length,
156
+ hasMore: matches.length > MAX_COMPLETIONS,
157
+ },
158
+ };
159
+ }
160
+ }
@@ -5,10 +5,10 @@ declare const envSchema: z.ZodObject<{
5
5
  YNAB_DEFAULT_BUDGET_ID: z.ZodOptional<z.ZodString>;
6
6
  MCP_PORT: z.ZodOptional<z.ZodCoercedNumber<unknown>>;
7
7
  LOG_LEVEL: z.ZodDefault<z.ZodEnum<{
8
+ debug: "debug";
8
9
  error: "error";
9
10
  warn: "warn";
10
11
  info: "info";
11
- debug: "debug";
12
12
  trace: "trace";
13
13
  fatal: "fatal";
14
14
  }>>;
@@ -17,7 +17,7 @@ export type AppConfig = z.infer<typeof envSchema>;
17
17
  export declare function loadConfig(env?: NodeJS.ProcessEnv): AppConfig;
18
18
  export declare const config: {
19
19
  YNAB_ACCESS_TOKEN: string;
20
- LOG_LEVEL: "error" | "warn" | "info" | "debug" | "trace" | "fatal";
20
+ LOG_LEVEL: "debug" | "error" | "warn" | "info" | "trace" | "fatal";
21
21
  YNAB_DEFAULT_BUDGET_ID?: string | undefined;
22
22
  MCP_PORT?: number | undefined;
23
23
  };
@@ -44,6 +44,7 @@ export class ErrorHandler {
44
44
  }
45
45
  }
46
46
  return {
47
+ isError: true,
47
48
  content: [
48
49
  {
49
50
  type: 'text',
@@ -78,5 +78,7 @@ export class RateLimitError extends Error {
78
78
  }
79
79
  }
80
80
  export const globalRateLimiter = new RateLimiter({
81
- enableLogging: process.env['NODE_ENV'] !== 'production',
81
+ enableLogging: process.env['RATE_LIMIT_LOGGING'] === 'true' ||
82
+ process.env['LOG_LEVEL'] === 'debug' ||
83
+ process.env['VERBOSE_TESTS'] === 'true',
82
84
  });
@@ -37,6 +37,7 @@ export declare class ResourceManager {
37
37
  readResource(uri: string): Promise<{
38
38
  contents: ResourceContents[];
39
39
  }>;
40
+ private executeResourceHandler;
40
41
  private matchTemplate;
41
42
  private validateTemplateDefinition;
42
43
  }
@@ -1,4 +1,6 @@
1
+ import { ErrorCode, McpError, } from '@modelcontextprotocol/sdk/types.js';
1
2
  import { CacheManager, CACHE_TTLS } from './cacheManager.js';
3
+ const RESOURCE_NOT_FOUND_ERROR_CODE = -32002;
2
4
  const defaultResourceHandlers = {
3
5
  'ynab://budgets': async (uri, { ynabAPI, responseFormatter, cacheManager }) => {
4
6
  const cacheKey = CacheManager.generateKey('resources', 'budgets', 'list');
@@ -79,8 +81,9 @@ const defaultResourceTemplates = [
79
81
  mimeType: 'application/json',
80
82
  handler: async (uri, params, { ynabAPI, responseFormatter, cacheManager }) => {
81
83
  const budget_id = params['budget_id'];
82
- if (!budget_id)
83
- throw new Error('Missing budget_id parameter');
84
+ if (!budget_id) {
85
+ throw new McpError(ErrorCode.InvalidParams, 'Missing budget_id parameter');
86
+ }
84
87
  const cacheKey = CacheManager.generateKey('resources', 'budgets', 'get', budget_id);
85
88
  return cacheManager.wrap(cacheKey, {
86
89
  ttl: CACHE_TTLS.BUDGETS,
@@ -110,8 +113,9 @@ const defaultResourceTemplates = [
110
113
  mimeType: 'application/json',
111
114
  handler: async (uri, params, { ynabAPI, responseFormatter, cacheManager }) => {
112
115
  const budget_id = params['budget_id'];
113
- if (!budget_id)
114
- throw new Error('Missing budget_id parameter');
116
+ if (!budget_id) {
117
+ throw new McpError(ErrorCode.InvalidParams, 'Missing budget_id parameter');
118
+ }
115
119
  const cacheKey = CacheManager.generateKey('resources', 'accounts', 'list', budget_id);
116
120
  return cacheManager.wrap(cacheKey, {
117
121
  ttl: CACHE_TTLS.ACCOUNTS,
@@ -142,10 +146,12 @@ const defaultResourceTemplates = [
142
146
  handler: async (uri, params, { ynabAPI, responseFormatter, cacheManager }) => {
143
147
  const budget_id = params['budget_id'];
144
148
  const account_id = params['account_id'];
145
- if (!budget_id)
146
- throw new Error('Missing budget_id parameter');
147
- if (!account_id)
148
- throw new Error('Missing account_id parameter');
149
+ if (!budget_id) {
150
+ throw new McpError(ErrorCode.InvalidParams, 'Missing budget_id parameter');
151
+ }
152
+ if (!account_id) {
153
+ throw new McpError(ErrorCode.InvalidParams, 'Missing account_id parameter');
154
+ }
149
155
  const cacheKey = CacheManager.generateKey('resources', 'accounts', 'get', budget_id, account_id);
150
156
  return cacheManager.wrap(cacheKey, {
151
157
  ttl: CACHE_TTLS.ACCOUNTS,
@@ -208,20 +214,31 @@ export class ResourceManager {
208
214
  async readResource(uri) {
209
215
  const handler = this.resourceHandlers[uri];
210
216
  if (handler) {
211
- return { contents: await handler(uri, this.dependencies) };
217
+ return {
218
+ contents: await this.executeResourceHandler(() => handler(uri, this.dependencies), `resource ${uri}`),
219
+ };
212
220
  }
213
221
  for (const template of this.resourceTemplates) {
214
222
  const params = this.matchTemplate(template.uriTemplate, uri);
215
223
  if (params) {
216
- try {
217
- return { contents: await template.handler(uri, params, this.dependencies) };
218
- }
219
- catch (error) {
220
- throw new Error(`Failed to resolve template resource ${uri}: ${error instanceof Error ? error.message : String(error)}`);
221
- }
224
+ return {
225
+ contents: await this.executeResourceHandler(() => template.handler(uri, params, this.dependencies), `resource ${uri}`),
226
+ };
227
+ }
228
+ }
229
+ throw new McpError(RESOURCE_NOT_FOUND_ERROR_CODE, `Resource not found: ${uri}`);
230
+ }
231
+ async executeResourceHandler(handler, label) {
232
+ try {
233
+ return await handler();
234
+ }
235
+ catch (error) {
236
+ if (error instanceof McpError) {
237
+ throw error;
222
238
  }
239
+ const message = error instanceof Error ? error.message : String(error);
240
+ throw new McpError(ErrorCode.InternalError, `Failed to read ${label}: ${message}`);
223
241
  }
224
- throw new Error(`Unknown resource: ${uri}`);
225
242
  }
226
243
  matchTemplate(template, uri) {
227
244
  if (!/^[a-z0-9:/\-_{}]+$/i.test(template)) {
@@ -74,6 +74,7 @@ export declare function withSecurityWrapper<T extends Record<string, unknown>>(t
74
74
  src: string;
75
75
  mimeType?: string | undefined;
76
76
  sizes?: string[] | undefined;
77
+ theme?: "light" | "dark" | undefined;
77
78
  }[] | undefined;
78
79
  title?: string | undefined;
79
80
  } | {
@@ -104,8 +105,8 @@ export declare function withSecurityWrapper<T extends Record<string, unknown>>(t
104
105
  })[];
105
106
  _meta?: {
106
107
  [x: string]: unknown;
108
+ progressToken?: string | number | undefined;
107
109
  "io.modelcontextprotocol/related-task"?: {
108
- [x: string]: unknown;
109
110
  taskId: string;
110
111
  } | undefined;
111
112
  } | undefined;
@@ -58,6 +58,7 @@ export class SecurityMiddleware {
58
58
  }
59
59
  static createRateLimitErrorResponse(error) {
60
60
  return {
61
+ isError: true,
61
62
  content: [
62
63
  {
63
64
  type: 'text',
@@ -32,12 +32,18 @@ export interface ToolMetadataOptions {
32
32
  inputJsonSchema?: Record<string, unknown>;
33
33
  annotations?: MCPToolAnnotations;
34
34
  }
35
+ export type ProgressCallback = (params: {
36
+ progress: number;
37
+ total?: number;
38
+ message?: string;
39
+ }) => Promise<void>;
35
40
  export interface ToolExecutionContext {
36
41
  accessToken: string;
37
42
  name: string;
38
43
  operation: string;
39
44
  rawArguments: Record<string, unknown>;
40
45
  cache?: ToolRegistryCacheHelpers;
46
+ sendProgress?: ProgressCallback;
41
47
  }
42
48
  export interface ToolExecutionPayload<TInput extends Record<string, unknown>> {
43
49
  input: TInput;
@@ -59,6 +65,7 @@ export interface ToolExecutionOptions {
59
65
  accessToken: string;
60
66
  arguments?: Record<string, unknown>;
61
67
  minifyOverride?: boolean;
68
+ sendProgress?: ProgressCallback;
62
69
  }
63
70
  export interface ToolRegistryDependencies {
64
71
  withSecurityWrapper: SecurityWrapperFactory;
@@ -74,11 +81,13 @@ export declare class ToolRegistry {
74
81
  constructor(deps: ToolRegistryDependencies);
75
82
  register<TInput extends Record<string, unknown>, TOutput extends Record<string, unknown>>(definition: ToolDefinition<TInput, TOutput>): void;
76
83
  listTools(): Tool[];
84
+ hasTool(name: string): boolean;
77
85
  getToolDefinitions(): ToolDefinition[];
78
86
  executeTool(options: ToolExecutionOptions): Promise<CallToolResult>;
79
87
  private isCallToolResult;
80
88
  private normalizeSecurityError;
81
89
  private extractMinifyOverride;
90
+ private static readonly MCP_TOOL_NAME_REGEX;
82
91
  private assertValidDefinition;
83
92
  private generateJsonSchema;
84
93
  private validateOutput;
@@ -51,6 +51,9 @@ export class ToolRegistry {
51
51
  return result;
52
52
  });
53
53
  }
54
+ hasTool(name) {
55
+ return this.tools.has(name);
56
+ }
54
57
  getToolDefinitions() {
55
58
  return Array.from(this.tools.values()).map((tool) => {
56
59
  const definition = {
@@ -128,6 +131,9 @@ export class ToolRegistry {
128
131
  if (this.deps.cacheHelpers) {
129
132
  context.cache = this.deps.cacheHelpers;
130
133
  }
134
+ if (options.sendProgress) {
135
+ context.sendProgress = options.sendProgress;
136
+ }
131
137
  const handlerResult = await tool.handler({
132
138
  input: validated,
133
139
  context,
@@ -189,6 +195,10 @@ export class ToolRegistry {
189
195
  if (!definition.name || typeof definition.name !== 'string') {
190
196
  throw new Error('Tool definition requires a non-empty name');
191
197
  }
198
+ if (!ToolRegistry.MCP_TOOL_NAME_REGEX.test(definition.name)) {
199
+ throw new Error(`Tool name '${definition.name}' violates MCP guidelines: ` +
200
+ `must be 1-128 chars using only [a-zA-Z0-9_.-]`);
201
+ }
192
202
  if (!definition.description || typeof definition.description !== 'string') {
193
203
  throw new Error(`Tool '${definition.name}' requires a description`);
194
204
  }
@@ -268,3 +278,4 @@ export class ToolRegistry {
268
278
  return output;
269
279
  }
270
280
  }
281
+ ToolRegistry.MCP_TOOL_NAME_REGEX = /^[a-zA-Z0-9_.-]{1,128}$/;
@@ -1,10 +1,12 @@
1
1
  import type { CallToolResult } from '@modelcontextprotocol/sdk/types.js';
2
- import type { ToolExecutionPayload, DefaultArgumentResolver } from '../server/toolRegistry.js';
2
+ import type { ToolExecutionPayload, DefaultArgumentResolver, ProgressCallback } from '../server/toolRegistry.js';
3
3
  import type { ToolContext, Handler, DeltaHandler, WriteHandler, NoInputHandler } from '../types/toolRegistration.js';
4
+ import type { DeltaFetcher } from './deltaFetcher.js';
4
5
  export declare function createAdapters(context: ToolContext): {
5
6
  adapt: <TInput extends Record<string, unknown>>(handler: Handler<TInput>) => ({ input }: ToolExecutionPayload<TInput>) => Promise<CallToolResult>;
6
7
  adaptNoInput: (handler: NoInputHandler) => (_payload: ToolExecutionPayload<Record<string, unknown>>) => Promise<CallToolResult>;
7
8
  adaptWithDelta: <TInput extends Record<string, unknown>>(handler: DeltaHandler<TInput>) => ({ input }: ToolExecutionPayload<TInput>) => Promise<CallToolResult>;
9
+ adaptWithDeltaAndProgress: <TInput extends Record<string, unknown>>(handler: (api: import("ynab").API, deltaFetcher: DeltaFetcher, params: TInput, sendProgress?: ProgressCallback) => Promise<CallToolResult>) => ({ input, context }: ToolExecutionPayload<TInput>) => Promise<CallToolResult>;
8
10
  adaptWrite: <TInput extends Record<string, unknown>>(handler: WriteHandler<TInput>) => ({ input }: ToolExecutionPayload<TInput>) => Promise<CallToolResult>;
9
11
  };
10
12
  export declare function createBudgetResolver(context: ToolContext): <TInput extends {
@@ -6,6 +6,7 @@ export function createAdapters(context) {
6
6
  adapt: (handler) => async ({ input }) => handler(ynabAPI, input),
7
7
  adaptNoInput: (handler) => async (_payload) => handler(ynabAPI),
8
8
  adaptWithDelta: (handler) => async ({ input }) => handler(ynabAPI, deltaFetcher, input),
9
+ adaptWithDeltaAndProgress: (handler) => async ({ input, context }) => handler(ynabAPI, deltaFetcher, input, context.sendProgress),
9
10
  adaptWrite: (handler) => async ({ input }) => handler(ynabAPI, deltaCache, serverKnowledgeStore, input),
10
11
  };
11
12
  }
@@ -1,4 +1,5 @@
1
1
  import type * as ynab from 'ynab';
2
+ import type { ProgressCallback } from '../../server/toolRegistry.js';
2
3
  import type { ReconciliationAnalysis } from './types.js';
3
4
  import type { ReconcileAccountRequest } from './index.js';
4
5
  export interface AccountSnapshot {
@@ -14,6 +15,7 @@ export interface ExecutionOptions {
14
15
  accountId: string;
15
16
  initialAccount: AccountSnapshot;
16
17
  currencyCode: string;
18
+ sendProgress?: ProgressCallback;
17
19
  }
18
20
  export interface ExecutionActionRecord {
19
21
  type: string;