@dizzlkheinz/ynab-mcpb 0.17.1 → 0.18.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.github/workflows/ci-tests.yml +4 -4
- package/.github/workflows/full-integration.yml +2 -2
- package/.github/workflows/publish.yml +1 -1
- package/.github/workflows/release.yml +2 -2
- package/CHANGELOG.md +10 -1
- package/CLAUDE.md +9 -6
- package/README.md +6 -1
- package/dist/bundle/index.cjs +52 -52
- package/dist/server/YNABMCPServer.d.ts +7 -2
- package/dist/server/YNABMCPServer.js +42 -11
- package/dist/server/cacheManager.js +6 -5
- package/dist/server/completions.d.ts +25 -0
- package/dist/server/completions.js +160 -0
- package/dist/server/config.d.ts +2 -2
- package/dist/server/errorHandler.js +1 -0
- package/dist/server/rateLimiter.js +3 -1
- package/dist/server/resources.d.ts +1 -0
- package/dist/server/resources.js +33 -16
- package/dist/server/securityMiddleware.d.ts +2 -1
- package/dist/server/securityMiddleware.js +1 -0
- package/dist/server/toolRegistry.d.ts +9 -0
- package/dist/server/toolRegistry.js +11 -0
- package/dist/tools/adapters.d.ts +3 -1
- package/dist/tools/adapters.js +1 -0
- package/dist/tools/reconciliation/executor.d.ts +2 -0
- package/dist/tools/reconciliation/executor.js +26 -1
- package/dist/tools/reconciliation/index.d.ts +3 -2
- package/dist/tools/reconciliation/index.js +4 -3
- package/docs/reference/API.md +68 -27
- package/package.json +2 -2
- package/src/__tests__/comprehensive.integration.test.ts +4 -4
- package/src/__tests__/performance.test.ts +1 -2
- package/src/__tests__/smoke.e2e.test.ts +70 -0
- package/src/__tests__/testUtils.ts +2 -113
- package/src/server/YNABMCPServer.ts +64 -10
- package/src/server/__tests__/completions.integration.test.ts +117 -0
- package/src/server/__tests__/completions.test.ts +319 -0
- package/src/server/__tests__/resources.template.test.ts +3 -3
- package/src/server/__tests__/resources.test.ts +3 -3
- package/src/server/__tests__/toolRegistration.test.ts +1 -1
- package/src/server/cacheManager.ts +7 -6
- package/src/server/completions.ts +279 -0
- package/src/server/errorHandler.ts +1 -0
- package/src/server/rateLimiter.ts +4 -1
- package/src/server/resources.ts +49 -13
- package/src/server/securityMiddleware.ts +1 -0
- package/src/server/toolRegistry.ts +42 -0
- package/src/tools/adapters.ts +22 -1
- package/src/tools/reconciliation/__tests__/executor.progress.test.ts +462 -0
- package/src/tools/reconciliation/executor.ts +55 -1
- package/src/tools/reconciliation/index.ts +7 -3
- package/vitest.config.ts +2 -0
- package/src/__tests__/delta.performance.test.ts +0 -80
- package/src/__tests__/workflows.e2e.test.ts +0 -1658
|
@@ -0,0 +1,279 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @fileoverview MCP Completions Manager
|
|
3
|
+
* Provides autocomplete suggestions for prompts and resource templates.
|
|
4
|
+
* @module server/completions
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import type * as ynab from 'ynab';
|
|
8
|
+
import type { CacheManager } from './cacheManager.js';
|
|
9
|
+
import { CACHE_TTLS } from './cacheManager.js';
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Completion result structure following MCP spec
|
|
13
|
+
*/
|
|
14
|
+
export interface CompletionResult {
|
|
15
|
+
completion: {
|
|
16
|
+
values: string[];
|
|
17
|
+
total?: number;
|
|
18
|
+
hasMore?: boolean;
|
|
19
|
+
};
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Arguments that can be completed
|
|
24
|
+
*/
|
|
25
|
+
type CompletableArgument =
|
|
26
|
+
| 'budget_id'
|
|
27
|
+
| 'account_id'
|
|
28
|
+
| 'account_name'
|
|
29
|
+
| 'category'
|
|
30
|
+
| 'category_id'
|
|
31
|
+
| 'payee'
|
|
32
|
+
| 'payee_id';
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Context for completions - previously resolved arguments
|
|
36
|
+
* The arguments property may be undefined when no prior context exists
|
|
37
|
+
*/
|
|
38
|
+
interface CompletionContext {
|
|
39
|
+
arguments?: Record<string, string> | undefined;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Maximum number of completion values to return (per MCP spec)
|
|
44
|
+
*/
|
|
45
|
+
const MAX_COMPLETIONS = 100;
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* CompletionsManager handles autocomplete requests for YNAB entities.
|
|
49
|
+
* Provides completions for budgets, accounts, categories, and payees.
|
|
50
|
+
*/
|
|
51
|
+
export class CompletionsManager {
|
|
52
|
+
constructor(
|
|
53
|
+
private readonly ynabAPI: ynab.API,
|
|
54
|
+
private readonly cacheManager: CacheManager,
|
|
55
|
+
private readonly getDefaultBudgetId: () => string | undefined,
|
|
56
|
+
) {}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Get completions for an argument based on the current value
|
|
60
|
+
*/
|
|
61
|
+
async getCompletions(
|
|
62
|
+
argumentName: string,
|
|
63
|
+
value: string,
|
|
64
|
+
context?: CompletionContext,
|
|
65
|
+
): Promise<CompletionResult> {
|
|
66
|
+
const normalizedName = argumentName.toLowerCase() as CompletableArgument;
|
|
67
|
+
|
|
68
|
+
switch (normalizedName) {
|
|
69
|
+
case 'budget_id':
|
|
70
|
+
return this.completeBudgets(value);
|
|
71
|
+
|
|
72
|
+
case 'account_id':
|
|
73
|
+
case 'account_name':
|
|
74
|
+
return this.completeAccounts(value, context);
|
|
75
|
+
|
|
76
|
+
case 'category':
|
|
77
|
+
case 'category_id':
|
|
78
|
+
return this.completeCategories(value, context);
|
|
79
|
+
|
|
80
|
+
case 'payee':
|
|
81
|
+
case 'payee_id':
|
|
82
|
+
return this.completePayees(value, context);
|
|
83
|
+
|
|
84
|
+
default:
|
|
85
|
+
return { completion: { values: [], total: 0, hasMore: false } };
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Complete budget names/IDs
|
|
91
|
+
*/
|
|
92
|
+
private async completeBudgets(value: string): Promise<CompletionResult> {
|
|
93
|
+
const budgets = await this.cacheManager.wrap('completions:budgets', {
|
|
94
|
+
ttl: CACHE_TTLS.BUDGETS,
|
|
95
|
+
loader: async () => {
|
|
96
|
+
const response = await this.ynabAPI.budgets.getBudgets();
|
|
97
|
+
return response.data.budgets.map((b) => ({
|
|
98
|
+
id: b.id,
|
|
99
|
+
name: b.name,
|
|
100
|
+
}));
|
|
101
|
+
},
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
return this.filterAndFormat(budgets, value, (b) => [b.name, b.id]);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Complete account names/IDs within a budget
|
|
109
|
+
*/
|
|
110
|
+
private async completeAccounts(
|
|
111
|
+
value: string,
|
|
112
|
+
context?: CompletionContext,
|
|
113
|
+
): Promise<CompletionResult> {
|
|
114
|
+
const budgetId = context?.arguments?.['budget_id'] ?? this.getDefaultBudgetId();
|
|
115
|
+
if (!budgetId) {
|
|
116
|
+
return { completion: { values: [], total: 0, hasMore: false } };
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
const accounts = await this.cacheManager.wrap(`completions:accounts:${budgetId}`, {
|
|
120
|
+
ttl: CACHE_TTLS.ACCOUNTS,
|
|
121
|
+
loader: async () => {
|
|
122
|
+
const response = await this.ynabAPI.accounts.getAccounts(budgetId);
|
|
123
|
+
return response.data.accounts
|
|
124
|
+
.filter((a) => !a.deleted && !a.closed)
|
|
125
|
+
.map((a) => ({
|
|
126
|
+
id: a.id,
|
|
127
|
+
name: a.name,
|
|
128
|
+
}));
|
|
129
|
+
},
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
return this.filterAndFormat(accounts, value, (a) => [a.name, a.id]);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Complete category names/IDs within a budget
|
|
137
|
+
*/
|
|
138
|
+
private async completeCategories(
|
|
139
|
+
value: string,
|
|
140
|
+
context?: CompletionContext,
|
|
141
|
+
): Promise<CompletionResult> {
|
|
142
|
+
const budgetId = context?.arguments?.['budget_id'] ?? this.getDefaultBudgetId();
|
|
143
|
+
if (!budgetId) {
|
|
144
|
+
return { completion: { values: [], total: 0, hasMore: false } };
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
const categories = await this.cacheManager.wrap(`completions:categories:${budgetId}`, {
|
|
148
|
+
ttl: CACHE_TTLS.CATEGORIES,
|
|
149
|
+
loader: async () => {
|
|
150
|
+
const response = await this.ynabAPI.categories.getCategories(budgetId);
|
|
151
|
+
const result: { id: string; name: string; group: string }[] = [];
|
|
152
|
+
for (const group of response.data.category_groups) {
|
|
153
|
+
if (group.hidden || group.deleted) continue;
|
|
154
|
+
for (const cat of group.categories) {
|
|
155
|
+
if (cat.hidden || cat.deleted) continue;
|
|
156
|
+
result.push({
|
|
157
|
+
id: cat.id,
|
|
158
|
+
name: cat.name,
|
|
159
|
+
group: group.name,
|
|
160
|
+
});
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
return result;
|
|
164
|
+
},
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
// For categories, include group name in display for clarity
|
|
168
|
+
return this.filterAndFormat(categories, value, (c) => [c.name, `${c.group}: ${c.name}`, c.id]);
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* Complete payee names/IDs within a budget
|
|
173
|
+
*/
|
|
174
|
+
private async completePayees(
|
|
175
|
+
value: string,
|
|
176
|
+
context?: CompletionContext,
|
|
177
|
+
): Promise<CompletionResult> {
|
|
178
|
+
const budgetId = context?.arguments?.['budget_id'] ?? this.getDefaultBudgetId();
|
|
179
|
+
if (!budgetId) {
|
|
180
|
+
return { completion: { values: [], total: 0, hasMore: false } };
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
const payees = await this.cacheManager.wrap(`completions:payees:${budgetId}`, {
|
|
184
|
+
ttl: CACHE_TTLS.PAYEES,
|
|
185
|
+
loader: async () => {
|
|
186
|
+
const response = await this.ynabAPI.payees.getPayees(budgetId);
|
|
187
|
+
return response.data.payees
|
|
188
|
+
.filter((p) => !p.deleted)
|
|
189
|
+
.map((p) => ({
|
|
190
|
+
id: p.id,
|
|
191
|
+
name: p.name,
|
|
192
|
+
}));
|
|
193
|
+
},
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
return this.filterAndFormat(payees, value, (p) => [p.name, p.id]);
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/**
|
|
200
|
+
* Filter items by value match and format as completion result.
|
|
201
|
+
* Searches case-insensitively across all searchable fields.
|
|
202
|
+
* Caches lowercased values to avoid repeated toLowerCase() calls.
|
|
203
|
+
*/
|
|
204
|
+
private filterAndFormat<T>(
|
|
205
|
+
items: T[],
|
|
206
|
+
value: string,
|
|
207
|
+
getSearchableValues: (item: T) => string[],
|
|
208
|
+
): CompletionResult {
|
|
209
|
+
const lowerValue = value.toLowerCase();
|
|
210
|
+
|
|
211
|
+
// Cache lowercased values to avoid repeated toLowerCase() calls during filtering and sorting
|
|
212
|
+
const itemCache = new Map<T, { values: string[]; lowerValues: string[] }>();
|
|
213
|
+
const getCachedValues = (item: T) => {
|
|
214
|
+
let cached = itemCache.get(item);
|
|
215
|
+
if (!cached) {
|
|
216
|
+
const values = getSearchableValues(item);
|
|
217
|
+
cached = { values, lowerValues: values.map((v) => v.toLowerCase()) };
|
|
218
|
+
itemCache.set(item, cached);
|
|
219
|
+
}
|
|
220
|
+
return cached;
|
|
221
|
+
};
|
|
222
|
+
|
|
223
|
+
// Filter items that match the search value
|
|
224
|
+
const matches = items.filter((item) => {
|
|
225
|
+
const { lowerValues } = getCachedValues(item);
|
|
226
|
+
return lowerValues.some((v) => v.includes(lowerValue));
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
// Sort by relevance (exact prefix matches first, then contains)
|
|
230
|
+
matches.sort((a, b) => {
|
|
231
|
+
const aCache = getCachedValues(a);
|
|
232
|
+
const bCache = getCachedValues(b);
|
|
233
|
+
|
|
234
|
+
const aStartsWith = aCache.lowerValues.some((v) => v.startsWith(lowerValue));
|
|
235
|
+
const bStartsWith = bCache.lowerValues.some((v) => v.startsWith(lowerValue));
|
|
236
|
+
|
|
237
|
+
if (aStartsWith && !bStartsWith) return -1;
|
|
238
|
+
if (!aStartsWith && bStartsWith) return 1;
|
|
239
|
+
|
|
240
|
+
// Secondary sort by first value (name)
|
|
241
|
+
return (aCache.values[0] ?? '').localeCompare(bCache.values[0] ?? '');
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
// Get unique display values, prioritizing names over IDs
|
|
245
|
+
// The first value in the array should be the human-readable name
|
|
246
|
+
const uniqueValues = new Set<string>();
|
|
247
|
+
for (const item of matches) {
|
|
248
|
+
const { values, lowerValues } = getCachedValues(item);
|
|
249
|
+
// Find the first (name) value if it matches, otherwise find any matching value
|
|
250
|
+
// This ensures we prefer "Groceries" over the UUID when both match
|
|
251
|
+
let selectedValue: string | undefined;
|
|
252
|
+
for (let i = 0; i < values.length; i++) {
|
|
253
|
+
if (lowerValues[i]?.includes(lowerValue)) {
|
|
254
|
+
// Prefer the first matching value (typically the name), not the ID
|
|
255
|
+
// Only select this value if we haven't found a better one yet
|
|
256
|
+
if (selectedValue === undefined || i < values.indexOf(selectedValue)) {
|
|
257
|
+
selectedValue = values[i];
|
|
258
|
+
}
|
|
259
|
+
// Stop at first match - prioritizes name over ID since name comes first
|
|
260
|
+
break;
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
if (selectedValue) {
|
|
264
|
+
uniqueValues.add(selectedValue);
|
|
265
|
+
}
|
|
266
|
+
if (uniqueValues.size >= MAX_COMPLETIONS) break;
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
const resultValues = Array.from(uniqueValues).slice(0, MAX_COMPLETIONS);
|
|
270
|
+
|
|
271
|
+
return {
|
|
272
|
+
completion: {
|
|
273
|
+
values: resultValues,
|
|
274
|
+
total: matches.length,
|
|
275
|
+
hasMore: matches.length > MAX_COMPLETIONS,
|
|
276
|
+
},
|
|
277
|
+
};
|
|
278
|
+
}
|
|
279
|
+
}
|
|
@@ -152,5 +152,8 @@ export class RateLimitError extends Error {
|
|
|
152
152
|
* Global rate limiter instance
|
|
153
153
|
*/
|
|
154
154
|
export const globalRateLimiter = new RateLimiter({
|
|
155
|
-
enableLogging:
|
|
155
|
+
enableLogging:
|
|
156
|
+
process.env['RATE_LIMIT_LOGGING'] === 'true' ||
|
|
157
|
+
process.env['LOG_LEVEL'] === 'debug' ||
|
|
158
|
+
process.env['VERBOSE_TESTS'] === 'true',
|
|
156
159
|
});
|
package/src/server/resources.ts
CHANGED
|
@@ -10,9 +10,18 @@ import {
|
|
|
10
10
|
ResourceTemplate as MCPResourceTemplate,
|
|
11
11
|
Resource as MCPResource,
|
|
12
12
|
ResourceContents,
|
|
13
|
+
ErrorCode,
|
|
14
|
+
McpError,
|
|
13
15
|
} from '@modelcontextprotocol/sdk/types.js';
|
|
14
16
|
import { CacheManager, CACHE_TTLS } from './cacheManager.js';
|
|
15
17
|
|
|
18
|
+
/**
|
|
19
|
+
* Custom MCP error code for resource not found.
|
|
20
|
+
* Uses JSON-RPC reserved range (-32000 to -32099) for server errors.
|
|
21
|
+
* @see https://www.jsonrpc.org/specification#error_object
|
|
22
|
+
*/
|
|
23
|
+
const RESOURCE_NOT_FOUND_ERROR_CODE = -32002;
|
|
24
|
+
|
|
16
25
|
/**
|
|
17
26
|
* Response formatter interface to avoid direct dependency on concrete implementation
|
|
18
27
|
*/
|
|
@@ -155,7 +164,9 @@ const defaultResourceTemplates: ResourceTemplateDefinition[] = [
|
|
|
155
164
|
mimeType: 'application/json',
|
|
156
165
|
handler: async (uri, params, { ynabAPI, responseFormatter, cacheManager }) => {
|
|
157
166
|
const budget_id = params['budget_id'];
|
|
158
|
-
if (!budget_id)
|
|
167
|
+
if (!budget_id) {
|
|
168
|
+
throw new McpError(ErrorCode.InvalidParams, 'Missing budget_id parameter');
|
|
169
|
+
}
|
|
159
170
|
const cacheKey = CacheManager.generateKey('resources', 'budgets', 'get', budget_id);
|
|
160
171
|
return cacheManager.wrap<ResourceContents[]>(cacheKey, {
|
|
161
172
|
ttl: CACHE_TTLS.BUDGETS,
|
|
@@ -184,7 +195,9 @@ const defaultResourceTemplates: ResourceTemplateDefinition[] = [
|
|
|
184
195
|
mimeType: 'application/json',
|
|
185
196
|
handler: async (uri, params, { ynabAPI, responseFormatter, cacheManager }) => {
|
|
186
197
|
const budget_id = params['budget_id'];
|
|
187
|
-
if (!budget_id)
|
|
198
|
+
if (!budget_id) {
|
|
199
|
+
throw new McpError(ErrorCode.InvalidParams, 'Missing budget_id parameter');
|
|
200
|
+
}
|
|
188
201
|
const cacheKey = CacheManager.generateKey('resources', 'accounts', 'list', budget_id);
|
|
189
202
|
return cacheManager.wrap<ResourceContents[]>(cacheKey, {
|
|
190
203
|
ttl: CACHE_TTLS.ACCOUNTS,
|
|
@@ -214,8 +227,12 @@ const defaultResourceTemplates: ResourceTemplateDefinition[] = [
|
|
|
214
227
|
handler: async (uri, params, { ynabAPI, responseFormatter, cacheManager }) => {
|
|
215
228
|
const budget_id = params['budget_id'];
|
|
216
229
|
const account_id = params['account_id'];
|
|
217
|
-
if (!budget_id)
|
|
218
|
-
|
|
230
|
+
if (!budget_id) {
|
|
231
|
+
throw new McpError(ErrorCode.InvalidParams, 'Missing budget_id parameter');
|
|
232
|
+
}
|
|
233
|
+
if (!account_id) {
|
|
234
|
+
throw new McpError(ErrorCode.InvalidParams, 'Missing account_id parameter');
|
|
235
|
+
}
|
|
219
236
|
const cacheKey = CacheManager.generateKey(
|
|
220
237
|
'resources',
|
|
221
238
|
'accounts',
|
|
@@ -317,24 +334,43 @@ export class ResourceManager {
|
|
|
317
334
|
// 1. Try exact match first
|
|
318
335
|
const handler = this.resourceHandlers[uri];
|
|
319
336
|
if (handler) {
|
|
320
|
-
return {
|
|
337
|
+
return {
|
|
338
|
+
contents: await this.executeResourceHandler(
|
|
339
|
+
() => handler(uri, this.dependencies),
|
|
340
|
+
`resource ${uri}`,
|
|
341
|
+
),
|
|
342
|
+
};
|
|
321
343
|
}
|
|
322
344
|
|
|
323
345
|
// 2. Try template matching
|
|
324
346
|
for (const template of this.resourceTemplates) {
|
|
325
347
|
const params = this.matchTemplate(template.uriTemplate, uri);
|
|
326
348
|
if (params) {
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
}
|
|
349
|
+
return {
|
|
350
|
+
contents: await this.executeResourceHandler(
|
|
351
|
+
() => template.handler(uri, params, this.dependencies),
|
|
352
|
+
`resource ${uri}`,
|
|
353
|
+
),
|
|
354
|
+
};
|
|
334
355
|
}
|
|
335
356
|
}
|
|
336
357
|
|
|
337
|
-
throw new
|
|
358
|
+
throw new McpError(RESOURCE_NOT_FOUND_ERROR_CODE, `Resource not found: ${uri}`);
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
private async executeResourceHandler(
|
|
362
|
+
handler: () => Promise<ResourceContents[]>,
|
|
363
|
+
label: string,
|
|
364
|
+
): Promise<ResourceContents[]> {
|
|
365
|
+
try {
|
|
366
|
+
return await handler();
|
|
367
|
+
} catch (error) {
|
|
368
|
+
if (error instanceof McpError) {
|
|
369
|
+
throw error;
|
|
370
|
+
}
|
|
371
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
372
|
+
throw new McpError(ErrorCode.InternalError, `Failed to read ${label}: ${message}`);
|
|
373
|
+
}
|
|
338
374
|
}
|
|
339
375
|
|
|
340
376
|
/**
|
|
@@ -54,12 +54,27 @@ export interface ToolMetadataOptions {
|
|
|
54
54
|
annotations?: MCPToolAnnotations;
|
|
55
55
|
}
|
|
56
56
|
|
|
57
|
+
/**
|
|
58
|
+
* Progress notification callback for long-running operations.
|
|
59
|
+
* Follows MCP spec: notifications/progress
|
|
60
|
+
*/
|
|
61
|
+
export type ProgressCallback = (params: {
|
|
62
|
+
progress: number;
|
|
63
|
+
total?: number;
|
|
64
|
+
message?: string;
|
|
65
|
+
}) => Promise<void>;
|
|
66
|
+
|
|
57
67
|
export interface ToolExecutionContext {
|
|
58
68
|
accessToken: string;
|
|
59
69
|
name: string;
|
|
60
70
|
operation: string;
|
|
61
71
|
rawArguments: Record<string, unknown>;
|
|
62
72
|
cache?: ToolRegistryCacheHelpers;
|
|
73
|
+
/**
|
|
74
|
+
* Optional progress callback for emitting MCP progress notifications.
|
|
75
|
+
* Available when the client provides a progressToken in the request.
|
|
76
|
+
*/
|
|
77
|
+
sendProgress?: ProgressCallback;
|
|
63
78
|
}
|
|
64
79
|
|
|
65
80
|
export interface ToolExecutionPayload<TInput extends Record<string, unknown>> {
|
|
@@ -97,6 +112,11 @@ export interface ToolExecutionOptions {
|
|
|
97
112
|
accessToken: string;
|
|
98
113
|
arguments?: Record<string, unknown>;
|
|
99
114
|
minifyOverride?: boolean;
|
|
115
|
+
/**
|
|
116
|
+
* Optional progress callback for emitting MCP progress notifications.
|
|
117
|
+
* Should be provided when the request includes a progressToken.
|
|
118
|
+
*/
|
|
119
|
+
sendProgress?: ProgressCallback;
|
|
100
120
|
}
|
|
101
121
|
|
|
102
122
|
export interface ToolRegistryDependencies {
|
|
@@ -176,6 +196,10 @@ export class ToolRegistry {
|
|
|
176
196
|
});
|
|
177
197
|
}
|
|
178
198
|
|
|
199
|
+
hasTool(name: string): boolean {
|
|
200
|
+
return this.tools.has(name);
|
|
201
|
+
}
|
|
202
|
+
|
|
179
203
|
getToolDefinitions(): ToolDefinition[] {
|
|
180
204
|
return Array.from(this.tools.values()).map((tool) => {
|
|
181
205
|
const definition: ToolDefinition = {
|
|
@@ -269,6 +293,9 @@ export class ToolRegistry {
|
|
|
269
293
|
if (this.deps.cacheHelpers) {
|
|
270
294
|
context.cache = this.deps.cacheHelpers;
|
|
271
295
|
}
|
|
296
|
+
if (options.sendProgress) {
|
|
297
|
+
context.sendProgress = options.sendProgress;
|
|
298
|
+
}
|
|
272
299
|
const handlerResult = await tool.handler({
|
|
273
300
|
input: validated,
|
|
274
301
|
context,
|
|
@@ -353,6 +380,13 @@ export class ToolRegistry {
|
|
|
353
380
|
return undefined;
|
|
354
381
|
}
|
|
355
382
|
|
|
383
|
+
/**
|
|
384
|
+
* Regex pattern for MCP-compliant tool names.
|
|
385
|
+
* Tool names SHOULD be 1-128 chars, case-sensitive, only [a-zA-Z0-9_.-]
|
|
386
|
+
* @see https://spec.modelcontextprotocol.io/specification/2024-11-05/server/tools/
|
|
387
|
+
*/
|
|
388
|
+
private static readonly MCP_TOOL_NAME_REGEX = /^[a-zA-Z0-9_.-]{1,128}$/;
|
|
389
|
+
|
|
356
390
|
private assertValidDefinition<
|
|
357
391
|
TInput extends Record<string, unknown>,
|
|
358
392
|
TOutput extends Record<string, unknown>,
|
|
@@ -365,6 +399,14 @@ export class ToolRegistry {
|
|
|
365
399
|
throw new Error('Tool definition requires a non-empty name');
|
|
366
400
|
}
|
|
367
401
|
|
|
402
|
+
// Validate tool name follows MCP specification guidelines
|
|
403
|
+
if (!ToolRegistry.MCP_TOOL_NAME_REGEX.test(definition.name)) {
|
|
404
|
+
throw new Error(
|
|
405
|
+
`Tool name '${definition.name}' violates MCP guidelines: ` +
|
|
406
|
+
`must be 1-128 chars using only [a-zA-Z0-9_.-]`,
|
|
407
|
+
);
|
|
408
|
+
}
|
|
409
|
+
|
|
368
410
|
if (!definition.description || typeof definition.description !== 'string') {
|
|
369
411
|
throw new Error(`Tool '${definition.name}' requires a description`);
|
|
370
412
|
}
|
package/src/tools/adapters.ts
CHANGED
|
@@ -6,7 +6,11 @@
|
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
8
|
import type { CallToolResult } from '@modelcontextprotocol/sdk/types.js';
|
|
9
|
-
import type {
|
|
9
|
+
import type {
|
|
10
|
+
ToolExecutionPayload,
|
|
11
|
+
DefaultArgumentResolver,
|
|
12
|
+
ProgressCallback,
|
|
13
|
+
} from '../server/toolRegistry.js';
|
|
10
14
|
import { BudgetResolver } from '../server/budgetResolver.js';
|
|
11
15
|
import { DefaultArgumentResolutionError } from '../server/toolRegistry.js';
|
|
12
16
|
import type {
|
|
@@ -16,6 +20,7 @@ import type {
|
|
|
16
20
|
WriteHandler,
|
|
17
21
|
NoInputHandler,
|
|
18
22
|
} from '../types/toolRegistration.js';
|
|
23
|
+
import type { DeltaFetcher } from './deltaFetcher.js';
|
|
19
24
|
|
|
20
25
|
/**
|
|
21
26
|
* Creates adapter functions bound to the provided context. These helpers reduce
|
|
@@ -41,6 +46,22 @@ export function createAdapters(context: ToolContext) {
|
|
|
41
46
|
async ({ input }: ToolExecutionPayload<TInput>): Promise<CallToolResult> =>
|
|
42
47
|
handler(ynabAPI, deltaFetcher, input),
|
|
43
48
|
|
|
49
|
+
/**
|
|
50
|
+
* Adapter for delta operations that may emit progress notifications.
|
|
51
|
+
* Passes the optional sendProgress callback from the execution context.
|
|
52
|
+
*/
|
|
53
|
+
adaptWithDeltaAndProgress:
|
|
54
|
+
<TInput extends Record<string, unknown>>(
|
|
55
|
+
handler: (
|
|
56
|
+
api: typeof ynabAPI,
|
|
57
|
+
deltaFetcher: DeltaFetcher,
|
|
58
|
+
params: TInput,
|
|
59
|
+
sendProgress?: ProgressCallback,
|
|
60
|
+
) => Promise<CallToolResult>,
|
|
61
|
+
) =>
|
|
62
|
+
async ({ input, context }: ToolExecutionPayload<TInput>): Promise<CallToolResult> =>
|
|
63
|
+
handler(ynabAPI, deltaFetcher, input, context.sendProgress),
|
|
64
|
+
|
|
44
65
|
adaptWrite:
|
|
45
66
|
<TInput extends Record<string, unknown>>(handler: WriteHandler<TInput>) =>
|
|
46
67
|
async ({ input }: ToolExecutionPayload<TInput>): Promise<CallToolResult> =>
|