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,147 @@
1
+ export const shoppingToolDefinitions = [
2
+ {
3
+ name: 'shopping_lists_get',
4
+ description: '[SHOPPING/BOOK] Get all shopping lists, including their ID, name, and notes.',
5
+ annotations: { readOnlyHint: true },
6
+ inputSchema: {
7
+ type: 'object',
8
+ properties: {},
9
+ required: [],
10
+ },
11
+ },
12
+ {
13
+ name: 'shopping_list_get',
14
+ description: '[SHOPPING/LIST] Get a shopping list, including its metadata (like notes) and its array of product items.',
15
+ annotations: { readOnlyHint: true },
16
+ inputSchema: {
17
+ type: 'object',
18
+ properties: {
19
+ shoppingListId: {
20
+ type: 'number',
21
+ description: 'ID of the shopping list to get (default: 1). Most users have only one shopping list with ID 1.',
22
+ default: 1,
23
+ },
24
+ },
25
+ required: ['shoppingListId'],
26
+ },
27
+ },
28
+ {
29
+ name: 'shopping_list_update',
30
+ description: '[SHOPPING/BOOK] Update a shopping list. You can update its name or notes.',
31
+ inputSchema: {
32
+ type: 'object',
33
+ properties: {
34
+ shoppingListId: {
35
+ type: 'number',
36
+ description: 'ID of the shopping list to update. Most users have only one shopping list with ID 1.',
37
+ },
38
+ name: {
39
+ type: 'string',
40
+ description: 'Optional. New name for the shopping list.',
41
+ },
42
+ notes: {
43
+ type: 'string',
44
+ description: 'Optional. Notes or description for the shopping list.',
45
+ },
46
+ },
47
+ required: ['shoppingListId'],
48
+ },
49
+ },
50
+ {
51
+ name: 'shopping_list_add_item',
52
+ description: '[SHOPPING/LIST] Add an item to a shopping list. Use inventory_products_get first to find the product ID you want to add. If multiple similar products exist or you are unsure, you can omit the product ID and just provide the item name in the note.',
53
+ inputSchema: {
54
+ type: 'object',
55
+ properties: {
56
+ productId: {
57
+ type: 'number',
58
+ description: 'Optional. ID of the product to add. If a great match is found, use it. Otherwise, omit this and just specify the requested item in the note.',
59
+ },
60
+ amount: {
61
+ type: 'number',
62
+ description: "Amount to add to shopping list in the product's stock unit (e.g., 2 pieces, 1.5 kg, 750 ml). Ensure you know the product's unit before specifying amount. Default: 0 (meaning: buy whatever you find reasonable).",
63
+ default: 0,
64
+ },
65
+ shoppingListId: {
66
+ type: 'number',
67
+ description: 'ID of the shopping list to add to (default: 1). Most users have only one shopping list with ID 1.',
68
+ default: 1,
69
+ },
70
+ note: {
71
+ type: 'string',
72
+ description: 'Note or name of the item. Required if productId is omitted.',
73
+ },
74
+ },
75
+ required: [],
76
+ },
77
+ },
78
+ {
79
+ name: 'shopping_list_remove_item',
80
+ description: '[SHOPPING/LIST] Remove an item from a shopping list. Use shopping_list_get first to find the shopping list item ID.',
81
+ inputSchema: {
82
+ type: 'object',
83
+ properties: {
84
+ shoppingListItemId: {
85
+ type: 'number',
86
+ description: 'ID of the shopping list item to remove. Use shopping_list_get tool to find the correct shopping list item ID by looking at the "id" field in the results.',
87
+ },
88
+ },
89
+ required: ['shoppingListItemId'],
90
+ },
91
+ },
92
+ {
93
+ name: 'shopping_list_update_item',
94
+ description: '[SHOPPING/LIST] Update an item in a shopping list (e.g., to edit the note or amount). Use shopping_list_get first to find the shopping list item ID.',
95
+ inputSchema: {
96
+ type: 'object',
97
+ properties: {
98
+ shoppingListItemId: {
99
+ type: 'number',
100
+ description: 'ID of the shopping list item to update. Use shopping_list_get tool to find the correct shopping list item ID by looking at the "id" field in the results.',
101
+ },
102
+ productId: {
103
+ type: 'number',
104
+ description: 'Optional. Product ID if you want to change it.',
105
+ },
106
+ amount: {
107
+ type: 'number',
108
+ description: 'Optional. Amount to update to.',
109
+ },
110
+ shoppingListId: {
111
+ type: 'number',
112
+ description: 'Optional. Shopping list ID if you want to move it.',
113
+ },
114
+ note: {
115
+ type: 'string',
116
+ description: 'Optional. Note for the shopping list item. Use this to add or edit notes.',
117
+ },
118
+ },
119
+ required: ['shoppingListItemId'],
120
+ },
121
+ },
122
+ {
123
+ name: 'shopping_list_print_thermal',
124
+ description: '[SHOPPING/PRINTING] Print the shopping list with a thermal printer. This creates a physical shopping list for store visits.',
125
+ inputSchema: {
126
+ type: 'object',
127
+ properties: {
128
+ shoppingListId: {
129
+ type: 'number',
130
+ description: 'ID of the shopping list to print (default: 1). Most users have only one shopping list with ID 1.',
131
+ default: 1,
132
+ },
133
+ },
134
+ required: [],
135
+ },
136
+ },
137
+ {
138
+ name: 'shopping_locations_get',
139
+ description: '[SHOPPING/LOCATIONS] Get **retail store / shop** locations where you buy groceries (Grocy shopping locations). NOT pantry or home storage—use system_locations_get for storage location IDs (locationId). Use shopping_locations_get for storeId when adding shopping-list items or store-specific workflows.',
140
+ annotations: { readOnlyHint: true },
141
+ inputSchema: {
142
+ type: 'object',
143
+ properties: {},
144
+ required: [],
145
+ },
146
+ },
147
+ ];
@@ -0,0 +1,198 @@
1
+ import { BaseToolHandler } from '../base.js';
2
+ export class ShoppingToolHandlers extends BaseToolHandler {
3
+ async getResolvedShoppingMetadata() {
4
+ const [productsResponse, quantityUnitsResponse] = await Promise.all([
5
+ this.apiCall('/objects/products'),
6
+ this.apiCall('/objects/quantity_units'),
7
+ ]);
8
+ const products = Array.isArray(productsResponse) ? productsResponse : [];
9
+ const quantityUnits = Array.isArray(quantityUnitsResponse) ? quantityUnitsResponse : [];
10
+ return {
11
+ productsById: new Map(products
12
+ .filter((product) => product && product.id !== undefined && product.id !== null)
13
+ .map((product) => [Number(product.id), product])),
14
+ quantityUnitsById: new Map(quantityUnits
15
+ .filter((unit) => unit && unit.id !== undefined && unit.id !== null)
16
+ .map((unit) => [Number(unit.id), unit])),
17
+ };
18
+ }
19
+ enrichShoppingListItem(item, productsById, quantityUnitsById) {
20
+ const productId = item?.product_id !== undefined && item?.product_id !== null ? Number(item.product_id) : null;
21
+ const product = productId !== null ? productsById.get(productId) : undefined;
22
+ const productQuIdStock = product?.qu_id_stock !== undefined && product?.qu_id_stock !== null
23
+ ? Number(product.qu_id_stock)
24
+ : null;
25
+ const productQuantityUnitStock = productQuIdStock !== null ? quantityUnitsById.get(productQuIdStock) : undefined;
26
+ const quId = item?.qu_id !== undefined && item?.qu_id !== null ? Number(item.qu_id) : productQuIdStock;
27
+ const quantityUnit = quId !== null ? quantityUnitsById.get(quId) : undefined;
28
+ const { product_id: _product_id, shopping_list_id: _shopping_list_id, qu_id: _qu_id, ...restItem } = item || {};
29
+ return {
30
+ ...restItem,
31
+ product: product
32
+ ? {
33
+ id: product.id,
34
+ name: product.name,
35
+ description: product.description ?? null,
36
+ quantityUnitStock: productQuantityUnitStock
37
+ ? {
38
+ id: productQuantityUnitStock.id,
39
+ name: productQuantityUnitStock.name,
40
+ }
41
+ : null,
42
+ }
43
+ : null,
44
+ quantityUnit: quantityUnit
45
+ ? {
46
+ id: quantityUnit.id,
47
+ name: quantityUnit.name,
48
+ }
49
+ : null,
50
+ };
51
+ }
52
+ async enrichShoppingListResponse(data) {
53
+ if (Array.isArray(data)) {
54
+ if (data.length === 0) {
55
+ return data;
56
+ }
57
+ const { productsById, quantityUnitsById } = await this.getResolvedShoppingMetadata();
58
+ return data.map((item) => this.enrichShoppingListItem(item, productsById, quantityUnitsById));
59
+ }
60
+ if (data && typeof data === 'object') {
61
+ const hasShoppingFields = 'product_id' in data || 'qu_id' in data;
62
+ if (!hasShoppingFields) {
63
+ return data;
64
+ }
65
+ const { productsById, quantityUnitsById } = await this.getResolvedShoppingMetadata();
66
+ return this.enrichShoppingListItem(data, productsById, quantityUnitsById);
67
+ }
68
+ return data;
69
+ }
70
+ normalizeShoppingList(item) {
71
+ if (!item || typeof item !== 'object') {
72
+ return item;
73
+ }
74
+ const { description, ...rest } = item;
75
+ return {
76
+ ...rest,
77
+ notes: description ?? null,
78
+ };
79
+ }
80
+ normalizeShoppingListsResponse(data) {
81
+ if (Array.isArray(data)) {
82
+ return data.map((item) => this.normalizeShoppingList(item));
83
+ }
84
+ return this.normalizeShoppingList(data);
85
+ }
86
+ getShoppingLists = async () => {
87
+ return this.executeToolHandler(async () => {
88
+ const result = await this.apiCall('/objects/shopping_lists');
89
+ return this.createSuccess(this.normalizeShoppingListsResponse(result), 'Shopping lists retrieved successfully');
90
+ });
91
+ };
92
+ getShoppingList = async (args) => {
93
+ return this.executeToolHandler(async () => {
94
+ const { shoppingListId } = args || {};
95
+ this.validateRequired({ shoppingListId }, ['shoppingListId']);
96
+ const queryParams = {
97
+ 'query[]': `shopping_list_id=${shoppingListId}`,
98
+ };
99
+ const result = await this.apiCall('/objects/shopping_list', 'GET', undefined, {
100
+ queryParams,
101
+ });
102
+ const enriched = await this.enrichShoppingListResponse(result);
103
+ const meta = await this.apiCall(`/objects/shopping_lists/${shoppingListId}`);
104
+ const listMetadata = this.normalizeShoppingList(meta);
105
+ return this.createSuccess({
106
+ list: listMetadata,
107
+ items: enriched,
108
+ }, 'Shopping list retrieved successfully');
109
+ });
110
+ };
111
+ updateShoppingList = async (args) => {
112
+ return this.executeToolHandler(async () => {
113
+ const { shoppingListId, name, notes } = args || {};
114
+ this.validateRequired({ shoppingListId }, ['shoppingListId']);
115
+ const existingList = await this.apiCall(`/objects/shopping_lists/${shoppingListId}`);
116
+ if (!existingList) {
117
+ throw new Error(`Shopping list ${shoppingListId} not found`);
118
+ }
119
+ const body = {
120
+ ...existingList,
121
+ };
122
+ if (name !== undefined)
123
+ body.name = name;
124
+ if (notes !== undefined) {
125
+ body.description = notes;
126
+ }
127
+ const result = await this.apiCall(`/objects/shopping_lists/${shoppingListId}`, 'PUT', body);
128
+ return this.createSuccess(this.normalizeShoppingListsResponse(result), 'Shopping list updated successfully');
129
+ });
130
+ };
131
+ addShoppingListItem = async (args) => {
132
+ return this.executeToolHandler(async () => {
133
+ const { productId, amount = 1, shoppingListId = 1, note = '' } = args || {};
134
+ if (productId === undefined && !note) {
135
+ throw new Error('Either productId or note must be provided');
136
+ }
137
+ const body = {
138
+ amount,
139
+ shopping_list_id: shoppingListId,
140
+ note,
141
+ };
142
+ if (productId !== undefined) {
143
+ body.product_id = productId;
144
+ }
145
+ const result = await this.apiCall('/objects/shopping_list', 'POST', body);
146
+ const enriched = await this.enrichShoppingListResponse(result);
147
+ return this.createSuccess(enriched, 'Shopping list item added successfully');
148
+ });
149
+ };
150
+ removeShoppingListItem = async (args) => {
151
+ return this.executeToolHandler(async () => {
152
+ const { shoppingListItemId } = args || {};
153
+ this.validateRequired({ shoppingListItemId }, ['shoppingListItemId']);
154
+ const result = await this.apiCall(`/objects/shopping_list/${shoppingListItemId}`, 'DELETE');
155
+ return this.createSuccess(result, 'Shopping list item removed successfully');
156
+ });
157
+ };
158
+ updateShoppingListItem = async (args) => {
159
+ return this.executeToolHandler(async () => {
160
+ const { shoppingListItemId, productId, amount, shoppingListId, note } = args || {};
161
+ this.validateRequired({ shoppingListItemId }, ['shoppingListItemId']);
162
+ const existingItem = await this.apiCall(`/objects/shopping_list/${shoppingListItemId}`);
163
+ if (!existingItem) {
164
+ throw new Error(`Shopping list item ${shoppingListItemId} not found`);
165
+ }
166
+ const body = {
167
+ ...existingItem,
168
+ };
169
+ if (productId !== undefined)
170
+ body.product_id = productId;
171
+ if (amount !== undefined)
172
+ body.amount = amount;
173
+ if (shoppingListId !== undefined)
174
+ body.shopping_list_id = shoppingListId;
175
+ if (note !== undefined)
176
+ body.note = note;
177
+ const result = await this.apiCall(`/objects/shopping_list/${shoppingListItemId}`, 'PUT', body);
178
+ const enriched = await this.enrichShoppingListResponse(result);
179
+ return this.createSuccess(enriched, 'Shopping list item updated successfully');
180
+ });
181
+ };
182
+ printShoppingListThermal = async (args) => {
183
+ return this.executeToolHandler(async () => {
184
+ const { shoppingListId } = args || {};
185
+ const endpoint = shoppingListId !== undefined
186
+ ? `/print/shoppinglist/thermal?list_id=${shoppingListId}`
187
+ : '/print/shoppinglist/thermal';
188
+ const result = await this.apiCall(endpoint);
189
+ return this.createSuccess(result, 'Shopping list sent to thermal printer successfully');
190
+ });
191
+ };
192
+ getShoppingLocations = async () => {
193
+ return this.executeToolHandler(async () => {
194
+ const result = await this.apiCall('/objects/shopping_locations');
195
+ return this.createSuccess(result, 'Shopping locations retrieved successfully');
196
+ });
197
+ };
198
+ }
@@ -0,0 +1,17 @@
1
+ import { shoppingToolDefinitions } from './definitions.js';
2
+ import { ShoppingToolHandlers } from './handlers.js';
3
+ const handlers = new ShoppingToolHandlers();
4
+ export const shoppingModule = {
5
+ definitions: shoppingToolDefinitions,
6
+ handlers: {
7
+ shopping_lists_get: handlers.getShoppingLists,
8
+ shopping_list_get: handlers.getShoppingList,
9
+ shopping_list_update: handlers.updateShoppingList,
10
+ shopping_list_add_item: handlers.addShoppingListItem,
11
+ shopping_list_remove_item: handlers.removeShoppingListItem,
12
+ shopping_list_update_item: handlers.updateShoppingListItem,
13
+ shopping_list_print_thermal: handlers.printShoppingListThermal,
14
+ shopping_locations_get: handlers.getShoppingLocations,
15
+ },
16
+ };
17
+ export * from './definitions.js';
@@ -0,0 +1,89 @@
1
+ import { config } from '../../config/index.js';
2
+ export const systemToolDefinitions = [
3
+ // ==================== CORE SYSTEM UTILITIES ====================
4
+ {
5
+ name: 'system_locations_get',
6
+ description: '[SYSTEM/LOCATIONS] Get **home storage / stock** locations (freezer, pantry, fridge—where inventory lives). NOT retail stores—use shopping_locations_get for shop/store IDs (storeId). Use this for locationId on stock, transfers, and purchases.',
7
+ annotations: { readOnlyHint: true },
8
+ inputSchema: {
9
+ type: 'object',
10
+ properties: {},
11
+ required: [],
12
+ },
13
+ },
14
+ {
15
+ name: 'system_units_get',
16
+ description: '[SYSTEM/UNITS] Get all quantity units from your Grocy instance.',
17
+ annotations: { readOnlyHint: true },
18
+ inputSchema: {
19
+ type: 'object',
20
+ properties: {},
21
+ required: [],
22
+ },
23
+ },
24
+ {
25
+ name: 'system_users_get',
26
+ description: '[SYSTEM/USERS] Get all users from your Grocy instance.',
27
+ annotations: { readOnlyHint: true },
28
+ inputSchema: {
29
+ type: 'object',
30
+ properties: {},
31
+ required: [],
32
+ },
33
+ },
34
+ // ==================== DEVELOPER UTILITIES ====================
35
+ {
36
+ name: 'system_dev_call_api',
37
+ description: '[SYSTEM/DEV] Call a specific Grocy API endpoint with custom parameters.',
38
+ inputSchema: {
39
+ type: 'object',
40
+ properties: {
41
+ endpoint: {
42
+ type: 'string',
43
+ description: 'Grocy API endpoint to call (e.g., "objects/products"). Do not include /api/ prefix.',
44
+ },
45
+ method: {
46
+ type: 'string',
47
+ enum: ['GET', 'POST', 'PUT', 'DELETE'],
48
+ description: 'HTTP method to use',
49
+ default: 'GET',
50
+ },
51
+ body: {
52
+ type: 'object',
53
+ description: 'Optional request body for POST/PUT requests',
54
+ },
55
+ },
56
+ required: ['endpoint'],
57
+ },
58
+ },
59
+ {
60
+ name: 'system_dev_test_request',
61
+ description: `[SYSTEM/DEV] Test a REST API endpoint and get detailed response information. Base URL: ${config.grocy.base_url} | SSL Verification enabled | Authentication: ${config.grocy.api_key ? 'API Key using header: GROCY-API-KEY' : 'No authentication configured'}`,
62
+ inputSchema: {
63
+ type: 'object',
64
+ properties: {
65
+ method: {
66
+ type: 'string',
67
+ enum: ['GET', 'POST', 'PUT', 'DELETE'],
68
+ description: 'HTTP method to use',
69
+ },
70
+ endpoint: {
71
+ type: 'string',
72
+ description: 'Endpoint path (e.g. "/users"). Do not include full URLs - only the path.',
73
+ },
74
+ body: {
75
+ type: 'object',
76
+ description: 'Optional request body for POST/PUT requests',
77
+ },
78
+ headers: {
79
+ type: 'object',
80
+ description: 'Optional request headers for one-time use.',
81
+ additionalProperties: {
82
+ type: 'string',
83
+ },
84
+ },
85
+ },
86
+ required: ['method', 'endpoint'],
87
+ },
88
+ },
89
+ ];
@@ -0,0 +1,156 @@
1
+ import { BaseToolHandler } from '../base.js';
2
+ import { ErrorCode, McpError } from '@modelcontextprotocol/sdk/types.js';
3
+ import apiClient from '../../api/client.js';
4
+ import { config } from '../../config/index.js';
5
+ import { logger } from '../../utils/logger.js';
6
+ export class SystemToolHandlers extends BaseToolHandler {
7
+ redactHeaders(headers) {
8
+ const redacted = { ...headers };
9
+ const sensitiveHeaders = new Set([
10
+ 'grocy-api-key',
11
+ 'authorization',
12
+ 'x-api-key',
13
+ 'proxy-authorization',
14
+ ]);
15
+ for (const key of Object.keys(redacted)) {
16
+ if (sensitiveHeaders.has(key.toLowerCase())) {
17
+ redacted[key] = '[REDACTED]';
18
+ }
19
+ }
20
+ return redacted;
21
+ }
22
+ /**
23
+ * Truncate a string to at most `maxBytes` UTF-8 bytes without splitting a codepoint.
24
+ */
25
+ truncateStringToUtf8Bytes(str, maxBytes) {
26
+ const buf = Buffer.from(str, 'utf8');
27
+ if (buf.length <= maxBytes) {
28
+ return { text: str, byteLength: buf.length };
29
+ }
30
+ let end = maxBytes;
31
+ while (end > 0) {
32
+ while (end > 0 && (buf[end - 1] & 0xc0) === 0x80) {
33
+ end--;
34
+ }
35
+ if (end === 0) {
36
+ break;
37
+ }
38
+ const text = buf.subarray(0, end).toString('utf8');
39
+ if (Buffer.byteLength(text, 'utf8') === end) {
40
+ return { text, byteLength: end };
41
+ }
42
+ end--;
43
+ }
44
+ return { text: '', byteLength: 0 };
45
+ }
46
+ applyResponseSizeLimit(body) {
47
+ const sizeLimit = config.grocy.response_size_limit;
48
+ const jsonBody = JSON.stringify(body);
49
+ const originalSize = Buffer.byteLength(jsonBody, 'utf8');
50
+ if (originalSize <= sizeLimit) {
51
+ return { body };
52
+ }
53
+ const { text: truncatedJsonBody, byteLength: prefixBytes } = this.truncateStringToUtf8Bytes(jsonBody, sizeLimit);
54
+ const returnedSize = Buffer.byteLength(truncatedJsonBody, 'utf8');
55
+ return {
56
+ body: `${truncatedJsonBody}...[TRUNCATED]`,
57
+ truncated: {
58
+ originalSize,
59
+ returnedSize,
60
+ truncationPoint: prefixBytes,
61
+ sizeLimit,
62
+ },
63
+ };
64
+ }
65
+ // ==================== CORE SYSTEM UTILITIES ====================
66
+ getLocations = async () => {
67
+ return this.executeToolHandler(async () => {
68
+ const data = await this.apiCall('/objects/locations');
69
+ return this.createSuccess(data);
70
+ });
71
+ };
72
+ getQuantityUnits = async () => {
73
+ return this.executeToolHandler(async () => {
74
+ const data = await this.apiCall('/objects/quantity_units');
75
+ return this.createSuccess(data);
76
+ });
77
+ };
78
+ getUsers = async () => {
79
+ return this.executeToolHandler(async () => {
80
+ const data = await this.apiCall('/users');
81
+ return this.createSuccess(data);
82
+ });
83
+ };
84
+ // ==================== DEVELOPER UTILITIES ====================
85
+ callGrocyApi = async (args) => {
86
+ const { endpoint, method = 'GET', body = null } = args;
87
+ if (!endpoint) {
88
+ throw new McpError(ErrorCode.InvalidParams, 'Missing required parameter: endpoint');
89
+ }
90
+ // Remove leading /api/ if present
91
+ const cleanEndpoint = endpoint.replace(/^\/?(?:api\/)?/, '');
92
+ try {
93
+ const response = await apiClient.request(`/${cleanEndpoint}`, {
94
+ method,
95
+ body,
96
+ });
97
+ return this.createSuccess(response.data);
98
+ }
99
+ catch (error) {
100
+ logger.error(`Error calling Grocy API endpoint ${endpoint}`, 'api', { error });
101
+ return this.createError(`Failed to call Grocy API endpoint ${endpoint}: ${error.message}`);
102
+ }
103
+ };
104
+ testRequest = async (args) => {
105
+ const { method, endpoint, body, headers = {} } = args;
106
+ if (!method || !endpoint) {
107
+ throw new McpError(ErrorCode.InvalidParams, 'method and endpoint are required');
108
+ }
109
+ const normalizedEndpoint = `/${endpoint.replace(/^\/+|\/+$/g, '')}`;
110
+ const requestHeaders = { ...config.getCustomHeaders(), ...headers };
111
+ const safeRequestHeaders = this.redactHeaders(requestHeaders);
112
+ try {
113
+ const startTime = Date.now();
114
+ const response = await apiClient.request(normalizedEndpoint, {
115
+ method,
116
+ body,
117
+ headers: requestHeaders,
118
+ });
119
+ const endTime = Date.now();
120
+ const responseWithLimit = this.applyResponseSizeLimit(response.data);
121
+ const responseObj = {
122
+ request: {
123
+ url: `${config.grocy.base_url}${normalizedEndpoint}`,
124
+ method,
125
+ headers: safeRequestHeaders,
126
+ body,
127
+ authMethod: config.grocy.api_key ? 'apikey' : 'none',
128
+ },
129
+ response: {
130
+ statusCode: response.status,
131
+ timing: `${endTime - startTime}ms`,
132
+ headers: response.headers,
133
+ body: responseWithLimit.body,
134
+ },
135
+ validation: {
136
+ isError: response.status >= 400,
137
+ messages: response.status >= 400
138
+ ? [`Request failed with status ${response.status}`]
139
+ : ['Request completed successfully'],
140
+ ...(responseWithLimit.truncated ? { truncated: responseWithLimit.truncated } : {}),
141
+ },
142
+ };
143
+ return this.createSuccess(responseObj);
144
+ }
145
+ catch (error) {
146
+ return this.createError(`Test request failed: ${error.message}`, {
147
+ request: {
148
+ url: `${config.grocy.base_url}${normalizedEndpoint}`,
149
+ method,
150
+ headers: safeRequestHeaders,
151
+ body,
152
+ },
153
+ });
154
+ }
155
+ };
156
+ }
@@ -0,0 +1,16 @@
1
+ import { systemToolDefinitions } from './definitions.js';
2
+ import { SystemToolHandlers } from './handlers.js';
3
+ const handlers = new SystemToolHandlers();
4
+ export const systemModule = {
5
+ definitions: systemToolDefinitions,
6
+ handlers: {
7
+ // Core System Utilities
8
+ system_locations_get: handlers.getLocations,
9
+ system_units_get: handlers.getQuantityUnits,
10
+ system_users_get: handlers.getUsers,
11
+ // Developer Utilities
12
+ system_dev_call_api: handlers.callGrocyApi,
13
+ system_dev_test_request: handlers.testRequest,
14
+ },
15
+ };
16
+ export * from './definitions.js';
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,39 @@
1
+ /**
2
+ * Common validation utility functions
3
+ */
4
+ import { ValidationError } from '../utils/errors.js';
5
+ export class ValidationHelpers {
6
+ static validateBoolean(value, fieldName) {
7
+ if (value !== undefined && typeof value !== 'boolean') {
8
+ throw new ValidationError(`${fieldName} must be a boolean`, 'sub-config validation');
9
+ }
10
+ }
11
+ static validateString(value, fieldName) {
12
+ if (value !== undefined && typeof value !== 'string') {
13
+ throw new ValidationError(`${fieldName} must be a string`, 'sub-config validation');
14
+ }
15
+ }
16
+ static validateNumber(value, fieldName, options) {
17
+ if (value !== undefined) {
18
+ if (typeof value !== 'number') {
19
+ throw new ValidationError(`${fieldName} must be a number`, 'sub-config validation');
20
+ }
21
+ if (options?.min !== undefined && value < options.min) {
22
+ throw new ValidationError(`${fieldName} must be at least ${options.min}`, 'sub-config validation');
23
+ }
24
+ if (options?.max !== undefined && value > options.max) {
25
+ throw new ValidationError(`${fieldName} must be at most ${options.max}`, 'sub-config validation');
26
+ }
27
+ }
28
+ }
29
+ static validateKnownOptions(subConfigs, knownOptions, toolName) {
30
+ for (const [key] of subConfigs) {
31
+ if (!knownOptions.has(key)) {
32
+ const validOptions = Array.from(knownOptions)
33
+ .filter((k) => k !== 'ack_token')
34
+ .join(', ');
35
+ throw new ValidationError(`Unknown sub-configuration option '${key}' for ${toolName} tool. Valid options are: ${validOptions}`, 'sub-config validation');
36
+ }
37
+ }
38
+ }
39
+ }