mcp-grocy 2.7.0 → 2.7.2

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 (39) hide show
  1. package/CHANGELOG.md +14 -0
  2. package/README.md +15 -1
  3. package/build/api/client.js +119 -0
  4. package/build/config/index.js +283 -0
  5. package/build/main.js +44 -0
  6. package/build/resources/CHANGELOG.md +15 -3
  7. package/build/resources/README.md +15 -1
  8. package/build/resources/api-reference.md +2 -1
  9. package/build/resources/response-format.md +1 -3
  10. package/build/server/http-server.js +398 -0
  11. package/build/server/mcp-server.js +230 -0
  12. package/build/server/resources.js +73 -0
  13. package/build/server/tool-input-zod.js +86 -0
  14. package/build/tools/base.js +157 -0
  15. package/build/tools/household/definitions.js +161 -0
  16. package/build/tools/household/handlers.js +109 -0
  17. package/build/tools/household/index.js +25 -0
  18. package/build/tools/index.js +2 -0
  19. package/build/tools/inventory/definitions.js +416 -0
  20. package/build/tools/inventory/handlers.js +443 -0
  21. package/build/tools/inventory/index.js +32 -0
  22. package/build/tools/module-loader.js +154 -0
  23. package/build/tools/recipes/definitions.js +278 -0
  24. package/build/tools/recipes/handlers.js +518 -0
  25. package/build/tools/recipes/index.js +33 -0
  26. package/build/tools/recipes/validations.js +29 -0
  27. package/build/tools/shopping/definitions.js +147 -0
  28. package/build/tools/shopping/handlers.js +198 -0
  29. package/build/tools/shopping/index.js +17 -0
  30. package/build/tools/system/definitions.js +89 -0
  31. package/build/tools/system/handlers.js +156 -0
  32. package/build/tools/system/index.js +16 -0
  33. package/build/tools/types.js +1 -0
  34. package/build/tools/validation-helpers.js +39 -0
  35. package/build/types/index.js +64 -0
  36. package/build/utils/errors.js +143 -0
  37. package/build/utils/logger.js +142 -0
  38. package/build/version.js +1 -1
  39. package/package.json +27 -25
@@ -0,0 +1,443 @@
1
+ import { BaseToolHandler } from '../base.js';
2
+ import { ValidationError } from '../../utils/errors.js';
3
+ import Fuse from 'fuse.js';
4
+ export class InventoryToolHandlers extends BaseToolHandler {
5
+ // ==================== PRODUCT MANAGEMENT ====================
6
+ getProducts = async (args) => {
7
+ return this.executeToolHandler(async () => {
8
+ const { fields } = args || {};
9
+ // Validate required parameters
10
+ this.validateRequired({ fields }, ['fields']);
11
+ const fieldList = this.parseArrayParam(fields, 'fields');
12
+ // Fetch products
13
+ const products = await this.apiCall('/objects/products');
14
+ if (!Array.isArray(products)) {
15
+ return this.createSuccess([]);
16
+ }
17
+ // Filter to only include requested fields
18
+ const filteredProducts = this.filterFields(products, fieldList);
19
+ return this.createSuccess(filteredProducts);
20
+ });
21
+ };
22
+ getProductGroups = async () => {
23
+ return this.executeToolHandler(async () => {
24
+ const data = await this.apiCall('/objects/product_groups');
25
+ return this.createSuccess(data);
26
+ });
27
+ };
28
+ getPriceHistory = async (args) => {
29
+ return this.executeToolHandler(async () => {
30
+ const { productId } = args || {};
31
+ this.validateRequired({ productId }, ['productId']);
32
+ const data = await this.apiCall(`/stock/products/${productId}/price-history`);
33
+ return this.createSuccess(data);
34
+ });
35
+ };
36
+ // ==================== STOCK QUERIES ====================
37
+ getAllStock = async () => {
38
+ return this.executeToolHandler(async () => {
39
+ const data = await this.apiCall('/stock');
40
+ return this.createSuccess(data);
41
+ });
42
+ };
43
+ getStockByProduct = async (args) => {
44
+ return this.executeToolHandler(async () => {
45
+ const { productId } = args || {};
46
+ this.validateRequired({ productId }, ['productId']);
47
+ const stockEntries = await this.apiCall(`/stock/products/${productId}/entries`);
48
+ // Define essential fields for stock entries
49
+ const entryFields = [
50
+ 'id',
51
+ 'amount',
52
+ 'best_before_date',
53
+ 'purchased_date',
54
+ 'stock_id',
55
+ 'note',
56
+ 'location_id',
57
+ ];
58
+ // Filter entries to only include essential fields
59
+ const filteredEntries = Array.isArray(stockEntries)
60
+ ? this.filterFields(stockEntries, entryFields)
61
+ : [];
62
+ return this.createSuccess(filteredEntries);
63
+ });
64
+ };
65
+ getStockVolatile = async (args) => {
66
+ return this.executeToolHandler(async () => {
67
+ const queryParams = args?.includeDetails ? { include_details: 'true' } : {};
68
+ const data = await this.apiCall('/stock/volatile', 'GET', undefined, { queryParams });
69
+ return this.createSuccess(data);
70
+ });
71
+ };
72
+ getStockByLocation = async (args) => {
73
+ return this.executeToolHandler(async () => {
74
+ const { locationId } = args || {};
75
+ this.validateRequired({ locationId }, ['locationId']);
76
+ const data = await this.apiCall(`stock`, 'GET', undefined, {
77
+ queryParams: { 'query[]': `location_id=${locationId}` },
78
+ });
79
+ return this.createSuccess(data);
80
+ });
81
+ };
82
+ // ==================== STOCK TRANSACTIONS ====================
83
+ purchaseProduct = async (args) => {
84
+ return this.executeToolHandler(async () => {
85
+ const { productId, amount, bestBeforeDate, price, locationId, note } = args || {};
86
+ this.validateRequired({ productId, amount }, ['productId', 'amount']);
87
+ const body = { amount };
88
+ if (bestBeforeDate)
89
+ body.best_before_date = bestBeforeDate;
90
+ if (price !== undefined)
91
+ body.price = price;
92
+ if (locationId)
93
+ body.location_id = locationId;
94
+ if (note)
95
+ body.note = note;
96
+ const result = await this.apiCall(`/stock/products/${productId}/add`, 'POST', body);
97
+ return this.createSuccess(result, 'Product purchased successfully');
98
+ });
99
+ };
100
+ consumeProduct = async (args) => {
101
+ return this.executeToolHandler(async () => {
102
+ const { productId, amount, spoiled = false, locationId, note } = args || {};
103
+ this.validateRequired({ productId, amount }, ['productId', 'amount']);
104
+ const body = {
105
+ amount,
106
+ transaction_type: spoiled ? 'inventory-correction' : 'consume',
107
+ };
108
+ if (locationId)
109
+ body.location_id = locationId;
110
+ if (note)
111
+ body.note = note;
112
+ if (spoiled)
113
+ body.spoiled = true;
114
+ const result = await this.apiCall(`/stock/products/${productId}/consume`, 'POST', body);
115
+ return this.createSuccess(result, 'Product consumed successfully');
116
+ });
117
+ };
118
+ transferProduct = async (args) => {
119
+ return this.executeToolHandler(async () => {
120
+ const { productId, amount, fromLocationId, toLocationId, note } = args || {};
121
+ this.validateRequired({ productId, amount, fromLocationId, toLocationId }, [
122
+ 'productId',
123
+ 'amount',
124
+ 'fromLocationId',
125
+ 'toLocationId',
126
+ ]);
127
+ const body = {
128
+ amount,
129
+ location_id_from: fromLocationId,
130
+ location_id_to: toLocationId,
131
+ };
132
+ if (note)
133
+ body.note = note;
134
+ const result = await this.apiCall(`/stock/products/${productId}/transfer`, 'POST', body);
135
+ return this.createSuccess(result, 'Product transferred successfully');
136
+ });
137
+ };
138
+ inventoryProduct = async (args) => {
139
+ return this.executeToolHandler(async () => {
140
+ const { productId, newAmount, bestBeforeDate, locationId, note } = args || {};
141
+ this.validateRequired({ productId, newAmount }, ['productId', 'newAmount']);
142
+ const body = { new_amount: newAmount };
143
+ if (bestBeforeDate)
144
+ body.best_before_date = bestBeforeDate;
145
+ if (locationId)
146
+ body.location_id = locationId;
147
+ if (note)
148
+ body.note = note;
149
+ const result = await this.apiCall(`/stock/products/${productId}/inventory`, 'POST', body);
150
+ return this.createSuccess(result, 'Product inventory updated successfully');
151
+ });
152
+ };
153
+ openProduct = async (args) => {
154
+ return this.executeToolHandler(async () => {
155
+ const { productId, amount = 1, note } = args || {};
156
+ this.validateRequired({ productId }, ['productId']);
157
+ const body = { amount };
158
+ if (note)
159
+ body.note = note;
160
+ const result = await this.apiCall(`/stock/products/${productId}/open`, 'POST', body);
161
+ return this.createSuccess(result, 'Product opened successfully');
162
+ });
163
+ };
164
+ lookupProduct = async (args) => {
165
+ return this.executeToolHandler(async () => {
166
+ const { productName } = args || {};
167
+ this.validateRequired({ productName }, ['productName']);
168
+ const [productsResponse, locationsResponse, quantityUnitsResponse] = await Promise.all([
169
+ this.apiCall('/objects/products'),
170
+ this.apiCall('/objects/locations'),
171
+ this.apiCall('/objects/quantity_units'),
172
+ ]);
173
+ const products = Array.isArray(productsResponse) ? productsResponse : [];
174
+ const locations = Array.isArray(locationsResponse) ? locationsResponse : [];
175
+ const quantityUnits = Array.isArray(quantityUnitsResponse) ? quantityUnitsResponse : [];
176
+ const fuseOptions = {
177
+ keys: [
178
+ { name: 'name', weight: 1.0 },
179
+ { name: 'description', weight: 0.3 },
180
+ ],
181
+ threshold: 0.6,
182
+ distance: 100,
183
+ minMatchCharLength: 1,
184
+ ignoreLocation: true,
185
+ includeScore: true,
186
+ includeMatches: true,
187
+ useExtendedSearch: false,
188
+ isCaseSensitive: false,
189
+ shouldSort: true,
190
+ findAllMatches: false,
191
+ };
192
+ const fuse = new Fuse(products, fuseOptions);
193
+ const fuseResults = fuse.search(productName);
194
+ let productMatches = fuseResults.map((result) => ({
195
+ ...result.item,
196
+ matchScore: Math.round((1 - result.score) * 100),
197
+ fuseScore: result.score,
198
+ matches: result.matches,
199
+ }));
200
+ if (productMatches.length === 0) {
201
+ const permissiveFuse = new Fuse(products, {
202
+ ...fuseOptions,
203
+ threshold: 0.8,
204
+ distance: 200,
205
+ });
206
+ const permissiveResults = permissiveFuse.search(productName);
207
+ productMatches = permissiveResults.map((result) => ({
208
+ ...result.item,
209
+ matchScore: Math.round((1 - result.score) * 100),
210
+ fuseScore: result.score,
211
+ matches: result.matches,
212
+ isPermissiveMatch: true,
213
+ }));
214
+ }
215
+ productMatches = productMatches.slice(0, 5);
216
+ if (productMatches.length === 0) {
217
+ return this.createError(`No products found matching "${productName}"`, {
218
+ suggestion: 'Try a different product name or check the spelling',
219
+ availableProducts: products.slice(0, 10).map((p) => p.name),
220
+ });
221
+ }
222
+ const enrichedMatches = await Promise.all(productMatches.map(async (product) => {
223
+ let productEntries;
224
+ try {
225
+ productEntries = await this.apiCall(`/stock/products/${product.id}/entries`);
226
+ productEntries = Array.isArray(productEntries) ? productEntries : [];
227
+ }
228
+ catch {
229
+ productEntries = [];
230
+ }
231
+ const stockEntries = productEntries.map((stockItem) => {
232
+ return {
233
+ amount: stockItem.amount,
234
+ bestBeforeDate: stockItem.best_before_date,
235
+ stockId: stockItem.id,
236
+ locationId: parseInt(stockItem.location_id),
237
+ };
238
+ });
239
+ stockEntries.sort((a, b) => {
240
+ if (!a.bestBeforeDate && !b.bestBeforeDate)
241
+ return 0;
242
+ if (!a.bestBeforeDate)
243
+ return 1;
244
+ if (!b.bestBeforeDate)
245
+ return -1;
246
+ return new Date(a.bestBeforeDate).getTime() - new Date(b.bestBeforeDate).getTime();
247
+ });
248
+ const unit = quantityUnits.find((u) => u.id == product.qu_id_stock);
249
+ const unitInfo = unit ? { id: unit.id, name: unit.name } : { id: null, name: 'pieces' };
250
+ const hasMultipleLocations = stockEntries.length > 1 &&
251
+ new Set(stockEntries.map((entry) => entry.locationId)).size > 1;
252
+ const locationInstructions = hasMultipleLocations
253
+ ? 'IMPORTANT: This product has stock in multiple locations. Make sure the user requested a specific location or confirm the locationId before performing any operations.'
254
+ : undefined;
255
+ return {
256
+ productId: product.id,
257
+ productName: product.name,
258
+ stockEntries: stockEntries,
259
+ totalStockAmount: productEntries.reduce((sum, s) => sum + parseFloat(s.amount || 0), 0),
260
+ unit: unitInfo,
261
+ locationInstructions,
262
+ };
263
+ }));
264
+ return this.createSuccess({
265
+ message: `Found ${enrichedMatches.length} product matches for "${productName}" (ordered from most likely to least likely match)`,
266
+ productMatches: enrichedMatches,
267
+ allAvailableLocations: locations.map((l) => ({ id: l.id, name: l.name })),
268
+ instructions: 'Review the matches above. Use the exact productId and locationId from this data for any product operations (inventory_stock_entry_consume, inventory_stock_entry_transfer, inventory_transactions_purchase, inventory_transactions_adjust, etc.).',
269
+ });
270
+ });
271
+ };
272
+ printProductLabel = async (args) => {
273
+ return this.executeToolHandler(async () => {
274
+ const { productId } = args || {};
275
+ this.validateRequired({ productId }, ['productId']);
276
+ const result = await this.apiCall(`/stock/products/${productId}/printlabel`);
277
+ return this.createSuccess(result, 'Product label printed successfully');
278
+ });
279
+ };
280
+ printStockEntryLabel = async (args) => {
281
+ return this.executeToolHandler(async () => {
282
+ const { stockId, productId } = args || {};
283
+ this.validateRequired({ stockId, productId }, ['stockId', 'productId']);
284
+ const stockEntryResponse = await this.apiCall(`/stock/entry/${stockId}`);
285
+ if (!stockEntryResponse || !stockEntryResponse.product_id) {
286
+ throw new ValidationError(`Could not resolve product ID from stock entry ${stockId}`, 'printStockEntryLabel');
287
+ }
288
+ if (stockEntryResponse.product_id !== productId) {
289
+ throw new ValidationError(`Product ID mismatch: stock entry ${stockId} belongs to product ${stockEntryResponse.product_id}, but ${productId} was provided`, 'printStockEntryLabel');
290
+ }
291
+ const result = await this.apiCall(`/stock/entry/${stockId}/printlabel`);
292
+ return this.createSuccess(result, 'Stock entry label printed successfully');
293
+ });
294
+ };
295
+ // ==================== GRANULAR STOCK ENTRY OPERATIONS ====================
296
+ async splitStockEntry(originalEntry, stockAmounts, getUnitForm) {
297
+ const splitEntries = [];
298
+ if (stockAmounts.length === 1) {
299
+ const amount = stockAmounts[0];
300
+ if (typeof amount !== 'number' || amount <= 0) {
301
+ throw new ValidationError(`Invalid amount: ${amount}`, 'splitStockEntry');
302
+ }
303
+ const note = `${originalEntry.note || ''} - ${originalEntry.id} - 1`;
304
+ await this.apiCall(`/stock/entry/${originalEntry.id}`, 'PUT', {
305
+ amount: amount,
306
+ open: false,
307
+ note: note,
308
+ best_before_date: originalEntry.best_before_date,
309
+ purchased_date: originalEntry.purchased_date,
310
+ location_id: originalEntry.location_id,
311
+ });
312
+ splitEntries.push({
313
+ stockId: originalEntry.id,
314
+ amount: amount,
315
+ type: 'updated',
316
+ unit: getUnitForm(amount),
317
+ });
318
+ }
319
+ else {
320
+ for (let i = 0; i < stockAmounts.length; i++) {
321
+ const amount = stockAmounts[i];
322
+ if (typeof amount !== 'number' || amount <= 0) {
323
+ throw new ValidationError(`Invalid amount at index ${i}: ${amount}`, 'splitStockEntry');
324
+ }
325
+ const note = `${originalEntry.note || ''} - ${originalEntry.id} - ${i + 1}`;
326
+ if (i === 0) {
327
+ await this.apiCall(`/stock/entry/${originalEntry.id}`, 'PUT', {
328
+ amount: amount,
329
+ open: false,
330
+ note: note,
331
+ best_before_date: originalEntry.best_before_date,
332
+ purchased_date: originalEntry.purchased_date,
333
+ location_id: originalEntry.location_id,
334
+ });
335
+ splitEntries.push({
336
+ stockId: originalEntry.id,
337
+ amount,
338
+ type: 'updated',
339
+ unit: getUnitForm(amount),
340
+ });
341
+ }
342
+ else {
343
+ const createResponse = await this.apiCall(`/stock/products/${originalEntry.product_id}/add`, 'POST', {
344
+ amount,
345
+ best_before_date: originalEntry.best_before_date,
346
+ purchased_date: originalEntry.purchased_date,
347
+ transaction_type: 'purchase',
348
+ location_id: originalEntry.location_id,
349
+ note: note,
350
+ });
351
+ const stockId = createResponse[0].stock_id || createResponse[0].id;
352
+ const stockResponse = await this.apiCall('/objects/stock');
353
+ const stockEntries = Array.isArray(stockResponse) ? stockResponse : [];
354
+ const actualStockEntry = stockEntries.find((entry) => entry.product_id === originalEntry.product_id && entry.stock_id === stockId);
355
+ if (!actualStockEntry) {
356
+ throw new ValidationError(`Could not find created stock entry with product_id ${originalEntry.product_id} and stock_id ${stockId}`, 'splitStockEntry');
357
+ }
358
+ splitEntries.push({
359
+ stockId: actualStockEntry.id,
360
+ amount,
361
+ type: 'created',
362
+ unit: getUnitForm(amount),
363
+ });
364
+ }
365
+ }
366
+ }
367
+ return splitEntries;
368
+ }
369
+ consumeStockEntry = async (args) => {
370
+ return this.executeToolHandler(async () => {
371
+ const { stockId, productId, amount, spoiled = false, note } = args || {};
372
+ this.validateRequired({ stockId, productId, amount }, ['stockId', 'productId', 'amount']);
373
+ const stockEntryResponse = await this.apiCall(`/stock/entry/${stockId}`);
374
+ if (!stockEntryResponse || !stockEntryResponse.product_id) {
375
+ throw new ValidationError(`Could not resolve product ID from stock entry ${stockId}`, 'consumeStockEntry');
376
+ }
377
+ if (stockEntryResponse.product_id !== productId) {
378
+ throw new ValidationError(`Product ID mismatch: stock entry ${stockId} belongs to product ${stockEntryResponse.product_id}, but ${productId} was provided`, 'consumeStockEntry');
379
+ }
380
+ const body = {
381
+ amount,
382
+ spoiled,
383
+ stock_entry_id: stockEntryResponse.stock_id,
384
+ location_id: stockEntryResponse.location_id,
385
+ };
386
+ if (note)
387
+ body.note = note;
388
+ const result = await this.apiCall(`/stock/products/${stockEntryResponse.product_id}/consume`, 'POST', body);
389
+ return this.createSuccess(result, 'Stock entry consumed successfully');
390
+ });
391
+ };
392
+ transferStockEntry = async (args) => {
393
+ return this.executeToolHandler(async () => {
394
+ const { stockId, productId, amount, locationIdTo, note } = args || {};
395
+ this.validateRequired({ stockId, productId, amount, locationIdTo }, [
396
+ 'stockId',
397
+ 'productId',
398
+ 'amount',
399
+ 'locationIdTo',
400
+ ]);
401
+ const stockEntryResponse = await this.apiCall(`/stock/entry/${stockId}`);
402
+ if (!stockEntryResponse || !stockEntryResponse.product_id) {
403
+ throw new ValidationError(`Could not resolve product ID from stock entry ${stockId}`, 'transferStockEntry');
404
+ }
405
+ if (stockEntryResponse.product_id !== productId) {
406
+ throw new ValidationError(`Product ID mismatch: stock entry ${stockId} belongs to product ${stockEntryResponse.product_id}, but ${productId} was provided`, 'transferStockEntry');
407
+ }
408
+ const body = {
409
+ amount,
410
+ location_id_from: stockEntryResponse.location_id,
411
+ location_id_to: locationIdTo,
412
+ transaction_type: 'transfer',
413
+ stock_entry_id: stockEntryResponse.stock_id,
414
+ };
415
+ if (note)
416
+ body.note = note;
417
+ const result = await this.apiCall(`/stock/products/${stockEntryResponse.product_id}/transfer`, 'POST', body);
418
+ return this.createSuccess(result, 'Stock entry transferred successfully');
419
+ });
420
+ };
421
+ openStockEntry = async (args) => {
422
+ return this.executeToolHandler(async () => {
423
+ const { stockId, productId, amount, note } = args || {};
424
+ this.validateRequired({ stockId, productId, amount }, ['stockId', 'productId', 'amount']);
425
+ const stockEntryResponse = await this.apiCall(`/stock/entry/${stockId}`);
426
+ if (!stockEntryResponse || !stockEntryResponse.product_id) {
427
+ throw new ValidationError(`Could not resolve product ID from stock entry ${stockId}`, 'openStockEntry');
428
+ }
429
+ if (stockEntryResponse.product_id !== productId) {
430
+ throw new ValidationError(`Product ID mismatch: stock entry ${stockId} belongs to product ${stockEntryResponse.product_id}, but ${productId} was provided`, 'openStockEntry');
431
+ }
432
+ const body = {
433
+ amount,
434
+ stock_entry_id: stockEntryResponse.stock_id,
435
+ location_id: stockEntryResponse.location_id,
436
+ };
437
+ if (note)
438
+ body.note = note;
439
+ const result = await this.apiCall(`/stock/products/${stockEntryResponse.product_id}/open`, 'POST', body);
440
+ return this.createSuccess(result, 'Stock entry opened successfully');
441
+ });
442
+ };
443
+ }
@@ -0,0 +1,32 @@
1
+ import { inventoryToolDefinitions } from './definitions.js';
2
+ import { InventoryToolHandlers } from './handlers.js';
3
+ const handlers = new InventoryToolHandlers();
4
+ export const inventoryModule = {
5
+ definitions: inventoryToolDefinitions,
6
+ handlers: {
7
+ // Product Management
8
+ inventory_products_get: handlers.getProducts,
9
+ inventory_products_get_groups: handlers.getProductGroups,
10
+ inventory_products_get_price_history: handlers.getPriceHistory,
11
+ // Stock Queries
12
+ inventory_stock_get_all: handlers.getAllStock,
13
+ inventory_stock_get_by_product: handlers.getStockByProduct,
14
+ inventory_stock_get_volatile: handlers.getStockVolatile,
15
+ inventory_stock_get_by_location: handlers.getStockByLocation,
16
+ // Stock Transactions
17
+ inventory_transactions_purchase: handlers.purchaseProduct,
18
+ inventory_transactions_consume: handlers.consumeProduct,
19
+ inventory_transactions_transfer: handlers.transferProduct,
20
+ inventory_transactions_adjust: handlers.inventoryProduct,
21
+ inventory_transactions_open: handlers.openProduct,
22
+ // Product Lookup and Label Printing
23
+ inventory_products_lookup: handlers.lookupProduct,
24
+ inventory_products_print_label: handlers.printProductLabel,
25
+ inventory_stock_entry_print_label: handlers.printStockEntryLabel,
26
+ // Granular Stock Entry Operations
27
+ inventory_stock_entry_consume: handlers.consumeStockEntry,
28
+ inventory_stock_entry_transfer: handlers.transferStockEntry,
29
+ inventory_stock_entry_open: handlers.openStockEntry,
30
+ },
31
+ };
32
+ export * from './definitions.js';
@@ -0,0 +1,154 @@
1
+ /**
2
+ * Simplified dynamic module loader
3
+ * Improved performance with better caching and lazy loading
4
+ */
5
+ import { readdirSync } from 'fs';
6
+ import { dirname } from 'path';
7
+ import { fileURLToPath } from 'url';
8
+ import { logger } from '../utils/logger.js';
9
+ const __dirname = dirname(fileURLToPath(import.meta.url));
10
+ /**
11
+ * Simplified module loader with performance optimizations
12
+ */
13
+ export class ModuleLoader {
14
+ static moduleCache = new Map();
15
+ static discoveredFolders = null;
16
+ /**
17
+ * Discover tool folders (cached)
18
+ */
19
+ static discoverFolders() {
20
+ if (this.discoveredFolders !== null) {
21
+ return this.discoveredFolders;
22
+ }
23
+ try {
24
+ const toolsDir = __dirname;
25
+ this.discoveredFolders = readdirSync(toolsDir, { withFileTypes: true })
26
+ .filter((dirent) => dirent.isDirectory())
27
+ .filter((dirent) => !dirent.name.startsWith('.') && dirent.name !== 'node_modules')
28
+ .map((dirent) => dirent.name);
29
+ logger.module(`Discovered ${this.discoveredFolders.length} tool folders`);
30
+ }
31
+ catch (error) {
32
+ logger.error('Failed to discover tool folders', 'MODULE', { error });
33
+ this.discoveredFolders = [];
34
+ }
35
+ return this.discoveredFolders;
36
+ }
37
+ /**
38
+ * Load all tool modules with lazy loading
39
+ */
40
+ static async loadAllModules() {
41
+ const folders = this.discoverFolders();
42
+ const toolModules = [];
43
+ // Load modules in parallel for better performance
44
+ const loadPromises = folders.map((folder) => this.loadModule(folder));
45
+ const results = await Promise.allSettled(loadPromises);
46
+ results.forEach((result, index) => {
47
+ const folder = folders[index];
48
+ if (result.status === 'fulfilled' && result.value?.toolModule) {
49
+ toolModules.push(result.value.toolModule);
50
+ logger.module(`Loaded module: ${folder}`);
51
+ }
52
+ else if (result.status === 'rejected') {
53
+ logger.debug(`Failed to load module ${folder}`, 'MODULE', {
54
+ error: result.reason,
55
+ });
56
+ }
57
+ });
58
+ logger.tools(`Loaded ${toolModules.length} tool modules`);
59
+ return { toolModules };
60
+ }
61
+ /**
62
+ * Load a specific module with caching
63
+ */
64
+ static async loadModule(folderName) {
65
+ // Check cache first
66
+ const cached = this.moduleCache.get(folderName);
67
+ if (cached) {
68
+ return cached.loaded ? cached : null;
69
+ }
70
+ const moduleInfo = { loaded: false };
71
+ try {
72
+ // Use .js extension for production builds
73
+ const extension = '.js';
74
+ const indexPath = `./${folderName}/index${extension}`;
75
+ const moduleIndex = await import(indexPath);
76
+ // Find tool module export
77
+ for (const [, exportValue] of Object.entries(moduleIndex)) {
78
+ if (this.isToolModule(exportValue)) {
79
+ moduleInfo.toolModule = exportValue;
80
+ moduleInfo.loaded = true;
81
+ break;
82
+ }
83
+ }
84
+ if (!moduleInfo.loaded) {
85
+ logger.debug(`No tool module found in ${folderName}`, 'MODULE');
86
+ }
87
+ }
88
+ catch (error) {
89
+ const errorMessage = error instanceof Error ? error.message : String(error);
90
+ moduleInfo.error = errorMessage;
91
+ logger.debug(`Failed to load module ${folderName}: ${errorMessage}`, 'MODULE');
92
+ }
93
+ this.moduleCache.set(folderName, moduleInfo);
94
+ return moduleInfo.loaded ? moduleInfo : null;
95
+ }
96
+ /**
97
+ * Check if an export looks like a ToolModule
98
+ */
99
+ static isToolModule(obj) {
100
+ if (obj === null || typeof obj !== 'object') {
101
+ return false;
102
+ }
103
+ const m = obj;
104
+ return (Array.isArray(m.definitions) &&
105
+ m.definitions.length > 0 &&
106
+ typeof m.handlers === 'object' &&
107
+ m.handlers !== null);
108
+ }
109
+ /**
110
+ * Get module from cache
111
+ */
112
+ static getCachedModule(moduleName) {
113
+ const cached = this.moduleCache.get(moduleName);
114
+ return cached?.toolModule;
115
+ }
116
+ /**
117
+ * Get cache statistics
118
+ */
119
+ static getCacheStats() {
120
+ let loaded = 0;
121
+ let errors = 0;
122
+ for (const module of this.moduleCache.values()) {
123
+ if (module.loaded)
124
+ loaded++;
125
+ if (module.error)
126
+ errors++;
127
+ }
128
+ return {
129
+ total: this.moduleCache.size,
130
+ loaded,
131
+ errors,
132
+ };
133
+ }
134
+ }
135
+ // Factory function
136
+ export async function createToolRegistry() {
137
+ const { toolModules } = await ModuleLoader.loadAllModules();
138
+ const definitions = [];
139
+ const handlers = {};
140
+ const validators = {};
141
+ for (const module of toolModules) {
142
+ definitions.push(...module.definitions);
143
+ Object.assign(handlers, module.handlers);
144
+ if (module.validators) {
145
+ Object.assign(validators, module.validators);
146
+ }
147
+ }
148
+ return {
149
+ getDefinitions: () => definitions,
150
+ getHandler: (name) => handlers[name],
151
+ getValidator: (name) => validators[name],
152
+ getToolNames: () => definitions.map((def) => def.name),
153
+ };
154
+ }