mcp-grocy 2.2.0 → 2.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (40) hide show
  1. package/CHANGELOG.md +31 -0
  2. package/README.md +112 -32
  3. package/build/api/client.js +11 -13
  4. package/build/config/index.js +74 -20
  5. package/build/resources/CHANGELOG.md +31 -0
  6. package/build/resources/DOCS.md +8 -3
  7. package/build/resources/README.md +112 -32
  8. package/build/resources/api-reference.md +88 -88
  9. package/build/resources/config.md +30 -17
  10. package/build/resources/examples.md +120 -221
  11. package/build/resources/installation.md +9 -14
  12. package/build/resources/response-format.md +18 -13
  13. package/build/server/http-server.js +154 -42
  14. package/build/server/mcp-server.js +143 -96
  15. package/build/server/resources.js +37 -24
  16. package/build/server/tool-input-zod.js +86 -0
  17. package/build/tools/base.js +19 -15
  18. package/build/tools/household/definitions.js +47 -43
  19. package/build/tools/household/handlers.js +2 -2
  20. package/build/tools/household/index.js +2 -2
  21. package/build/tools/inventory/definitions.js +150 -113
  22. package/build/tools/inventory/handlers.js +55 -42
  23. package/build/tools/inventory/index.js +2 -2
  24. package/build/tools/module-loader.js +15 -12
  25. package/build/tools/recipes/definitions.js +103 -86
  26. package/build/tools/recipes/handlers.js +44 -38
  27. package/build/tools/recipes/index.js +3 -3
  28. package/build/tools/recipes/validations.js +8 -2
  29. package/build/tools/shopping/definitions.js +22 -20
  30. package/build/tools/shopping/handlers.js +1 -1
  31. package/build/tools/shopping/index.js +2 -2
  32. package/build/tools/system/definitions.js +25 -22
  33. package/build/tools/system/handlers.js +74 -12
  34. package/build/tools/system/index.js +2 -2
  35. package/build/tools/validation-helpers.js +10 -7
  36. package/build/types/index.js +12 -10
  37. package/build/utils/errors.js +10 -5
  38. package/build/utils/logger.js +16 -15
  39. package/build/version.js +2 -2
  40. package/package.json +38 -23
@@ -1,8 +1,8 @@
1
1
  # Grocy API Response Format Documentation
2
2
 
3
- The Grocy API testing tool (`test_request`) returns a comprehensive JSON response containing request details, response information, and validation results. Other specialized Grocy tools (e.g., `get_stock`, `add_shopping_list_item`) return the direct JSON response from the Grocy API, which is then stringified.
3
+ The dev tool **`system_dev_test_request`** returns a JSON payload with request details, response information, and validation results. Other tools (e.g. `inventory_stock_get_all`, `shopping_list_add_item`) return the Grocy API body as text content (typically stringified JSON).
4
4
 
5
- ## `test_request` Tool Response Structure
5
+ ## `system_dev_test_request` tool response structure
6
6
 
7
7
  ```json
8
8
  {
@@ -29,7 +29,7 @@ The Grocy API testing tool (`test_request`) returns a comprehensive JSON respons
29
29
  "id": "1",
30
30
  "name": "Cookies",
31
31
  "description": null,
32
- "product_group_id": "1",
32
+ "product_group_id": "1"
33
33
  // ... other product fields ...
34
34
  }
35
35
  },
@@ -72,9 +72,10 @@ GROCY_API_KEY=your-private-api-key
72
72
 
73
73
  These values can be set in your `.env` file for local development or in your project configuration for production use.
74
74
 
75
- ## Response Fields for `test_request`
75
+ ## Response Fields for `system_dev_test_request`
76
76
 
77
77
  ### Request Details (`request`)
78
+
78
79
  - `url`: Full URL of the Grocy API endpoint called, including base URL and path.
79
80
  - `method`: HTTP method used (e.g., GET, POST, PUT, DELETE).
80
81
  - `headers`: Request headers sent to the Grocy API. Sensitive headers like `GROCY-API-KEY` will have their values redacted.
@@ -82,6 +83,7 @@ These values can be set in your `.env` file for local development or in your pro
82
83
  - `authMethod`: Authentication method used. For Grocy, this will typically be `apikey` if `GROCY_API_KEY` is configured, or `none`.
83
84
 
84
85
  ### Response Details (`response`)
86
+
85
87
  - `statusCode`: HTTP status code returned by the Grocy API (e.g., 200, 400, 401).
86
88
  - `statusText`: HTTP status message (e.g., "OK", "Bad Request").
87
89
  - `timing`: Duration of the API request in milliseconds.
@@ -89,6 +91,7 @@ These values can be set in your `.env` file for local development or in your pro
89
91
  - `body`: Response body content from the Grocy API. This will be the JSON data returned by Grocy.
90
92
 
91
93
  ### Validation (`validation`)
94
+
92
95
  - `isError`: Boolean, `true` if the HTTP status code is 400 or higher, indicating an error.
93
96
  - `messages`: Array of messages, including success messages or error details.
94
97
  - `truncated` (optional): If the response body exceeds `REST_RESPONSE_SIZE_LIMIT`, this object will contain details about the truncation.
@@ -99,9 +102,10 @@ These values can be set in your `.env` file for local development or in your pro
99
102
 
100
103
  ## Specialized Grocy Tools Response Format
101
104
 
102
- Tools like `get_stock`, `get_products`, `add_shopping_list_item`, etc., directly return the JSON response from the Grocy API, stringified within the MCP tool response content.
105
+ Tools like `inventory_stock_get_all`, `inventory_products_get`, `shopping_list_add_item`, etc., return the Grocy API response as MCP text content (usually stringified JSON).
106
+
107
+ Example shape for a product-related read (illustrative):
103
108
 
104
- Example for `get_product` (if it existed as a specialized tool for a single product):
105
109
  ```json
106
110
  {
107
111
  "content": [
@@ -119,6 +123,7 @@ Example for `get_product` (if it existed as a specialized tool for a single prod
119
123
  ```
120
124
 
121
125
  If an error occurs with a specialized tool, the response will typically look like:
126
+
122
127
  ```json
123
128
  {
124
129
  "content": [
@@ -131,9 +136,10 @@ If an error occurs with a specialized tool, the response will typically look lik
131
136
  }
132
137
  ```
133
138
 
134
- ## Error Response Example for `test_request`
139
+ ## Error response example for `system_dev_test_request`
140
+
141
+ If **`system_dev_test_request`** encounters an API error (e.g., authentication failure):
135
142
 
136
- If the `test_request` tool encounters an API error (e.g., authentication failure):
137
143
  ```json
138
144
  {
139
145
  "request": {
@@ -149,17 +155,16 @@ If the `test_request` tool encounters an API error (e.g., authentication failure
149
155
  "statusCode": 401,
150
156
  "statusText": "Unauthorized",
151
157
  "timing": "50ms",
152
- "headers": { /* ... headers ... */ },
158
+ "headers": {
159
+ /* ... headers ... */
160
+ },
153
161
  "body": {
154
162
  "error_message": "API key is missing or invalid."
155
163
  }
156
164
  },
157
165
  "validation": {
158
166
  "isError": true,
159
- "messages": [
160
- "Request failed with status 401",
161
- "API key is missing or invalid."
162
- ]
167
+ "messages": ["Request failed with status 401", "API key is missing or invalid."]
163
168
  }
164
169
  }
165
170
  ```
@@ -1,39 +1,106 @@
1
1
  import express from 'express';
2
- import { randomUUID } from 'crypto';
2
+ import { randomUUID, timingSafeEqual } from 'crypto';
3
3
  import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';
4
4
  import { SSEServerTransport } from '@modelcontextprotocol/sdk/server/sse.js';
5
5
  import { VERSION, PACKAGE_NAME as SERVER_NAME } from '../version.js';
6
6
  import cors from 'cors';
7
7
  import http from 'http';
8
8
  import { logger } from '../utils/logger.js';
9
+ /** Constant-time string comparison to prevent timing attacks on token validation. */
10
+ function safeEqual(a, b) {
11
+ if (a.length !== b.length)
12
+ return false;
13
+ return timingSafeEqual(Buffer.from(a), Buffer.from(b));
14
+ }
15
+ function createMcpAccessGate(accessToken) {
16
+ return (req, res, next) => {
17
+ if (!accessToken) {
18
+ next();
19
+ return;
20
+ }
21
+ if (req.method === 'OPTIONS') {
22
+ next();
23
+ return;
24
+ }
25
+ const bearer = req.headers.authorization;
26
+ const headerToken = req.headers['x-mcp-access-token'];
27
+ const headerTokenStr = Array.isArray(headerToken) ? headerToken[0] : headerToken;
28
+ const q = req.query.access_token;
29
+ const queryToken = typeof q === 'string' ? q : undefined;
30
+ const ok = (typeof bearer === 'string' && safeEqual(bearer, `Bearer ${accessToken}`)) ||
31
+ (typeof headerTokenStr === 'string' && safeEqual(headerTokenStr, accessToken)) ||
32
+ (req.method === 'GET' &&
33
+ typeof queryToken === 'string' &&
34
+ safeEqual(queryToken, accessToken));
35
+ if (ok) {
36
+ next();
37
+ return;
38
+ }
39
+ res.status(401).json({
40
+ jsonrpc: '2.0',
41
+ error: {
42
+ code: -32000,
43
+ message: 'Unauthorized: configure MCP_HTTP_ACCESS_TOKEN and send Authorization: Bearer, X-MCP-Access-Token, or access_token query (GET only)',
44
+ },
45
+ id: (req.body && typeof req.body === 'object' && 'id' in req.body
46
+ ? req.body.id
47
+ : null) ?? null,
48
+ });
49
+ };
50
+ }
9
51
  // HTTP Transport for MCP (Context7 style)
10
- export function startHttpServer(mcpServer, port = 8080) {
52
+ export function startHttpServer(mcpServer, port = 8080, security) {
11
53
  return new Promise((resolve, reject) => {
12
54
  const app = express();
55
+ const mcpAccessGate = createMcpAccessGate(security.accessToken);
56
+ if (security.accessToken) {
57
+ logger.config('HTTP MCP access token is enabled (Bearer, X-MCP-Access-Token, or access_token on GET)');
58
+ }
13
59
  // Enable JSON body parsing with increased limit
14
60
  app.use(express.json({
15
- limit: '10mb'
61
+ limit: '10mb',
16
62
  }));
17
- // Enable CORS for all routes
18
63
  app.use(cors({
19
- origin: '*',
20
- methods: ['GET', 'POST', 'OPTIONS'],
21
- allowedHeaders: ['Origin', 'X-Requested-With', 'Content-Type', 'Accept', 'Mcp-Session-Id', 'Authorization'],
64
+ origin: security.corsOrigin,
65
+ methods: ['GET', 'POST', 'OPTIONS', 'DELETE'],
66
+ allowedHeaders: [
67
+ 'Origin',
68
+ 'X-Requested-With',
69
+ 'Content-Type',
70
+ 'Accept',
71
+ 'Mcp-Session-Id',
72
+ 'Mcp-Protocol-Version',
73
+ 'Authorization',
74
+ 'X-MCP-Access-Token',
75
+ ],
22
76
  exposedHeaders: ['Mcp-Session-Id', 'Content-Type'],
23
- optionsSuccessStatus: 200
77
+ optionsSuccessStatus: 200,
24
78
  }));
25
- // Simple health check endpoint
26
- app.get('/', (_req, res) => {
79
+ // Health check endpoint — always accessible for monitoring/load balancers,
80
+ // but only exposes service details when no access token is configured or request is authenticated.
81
+ app.get('/', (req, res) => {
82
+ const minimal = { status: 'ok' };
83
+ if (security.accessToken) {
84
+ const bearer = req.headers.authorization;
85
+ const headerToken = req.headers['x-mcp-access-token'];
86
+ const headerTokenStr = Array.isArray(headerToken) ? headerToken[0] : headerToken;
87
+ const authenticated = (typeof bearer === 'string' && safeEqual(bearer, `Bearer ${security.accessToken}`)) ||
88
+ (typeof headerTokenStr === 'string' && safeEqual(headerTokenStr, security.accessToken));
89
+ if (!authenticated) {
90
+ res.json(minimal);
91
+ return;
92
+ }
93
+ }
27
94
  res.json({
28
- status: 'ok',
95
+ ...minimal,
29
96
  service: SERVER_NAME,
30
97
  version: VERSION,
31
98
  message: 'MCP server is running',
32
99
  endpoints: {
33
100
  streamable: '/mcp',
34
101
  sse: '/mcp/sse',
35
- sseMessages: '/mcp/messages'
36
- }
102
+ sseMessages: '/mcp/messages',
103
+ },
37
104
  });
38
105
  });
39
106
  // Session management for transports
@@ -55,40 +122,50 @@ export function startHttpServer(mcpServer, port = 8080) {
55
122
  }
56
123
  };
57
124
  // Streamable HTTP endpoint (Context7 modern)
58
- app.post('/mcp', async (req, res) => {
125
+ app.all('/mcp', mcpAccessGate, async (req, res) => {
59
126
  try {
60
- const clientSessionId = req.headers['mcp-session-id'];
127
+ const clientSessionId = req.headers['mcp-session-id'] ||
128
+ req.query.sessionId ||
129
+ req.query['mcp-session-id'];
61
130
  let transport = undefined;
62
131
  // Accept header check (can be done early)
63
132
  const accept = req.headers.accept || '';
133
+ // Home Assistant might not send perfect accept headers, so we log a warning instead of blocking
64
134
  if (!accept.includes('application/json') && !accept.includes('text/event-stream')) {
65
- logger.error('Client must accept application/json or text/event-stream', 'server');
66
- res.status(406).json({
67
- jsonrpc: '2.0',
68
- error: {
69
- code: -32000,
70
- message: 'Not Acceptable: Client must accept application/json or text/event-stream'
71
- },
72
- id: req.body?.id || null
73
- });
74
- return;
135
+ logger.warn('Client should accept application/json or text/event-stream', 'server');
75
136
  }
76
- if (clientSessionId) {
137
+ const isInitializeRequest = req.method === 'POST' && req.body?.method === 'initialize';
138
+ if (clientSessionId && !isInitializeRequest) {
139
+ // Existing session: look up transport
77
140
  transport = streamableTransports[clientSessionId];
78
141
  if (!transport) {
142
+ logger.warn(`Session ${clientSessionId} not found, returning 400`, 'server');
79
143
  res.status(400).json({
80
144
  jsonrpc: '2.0',
81
- error: { code: -32001, message: `Invalid or expired session ID: ${clientSessionId}. Please re-initialize.` },
82
- id: req.body?.id || null
145
+ error: {
146
+ code: -32001,
147
+ message: `Invalid or expired session ID: ${clientSessionId}. Please re-initialize.`,
148
+ },
149
+ id: req.body?.id || null,
83
150
  });
84
151
  return;
85
152
  }
86
153
  }
87
- else {
88
- // No session ID provided by client, create new transport
154
+ else if (isInitializeRequest) {
155
+ // POST initialize: create new transport and session
156
+ if (clientSessionId) {
157
+ logger.info(`Initialize request included prior session ID ${clientSessionId} (non-graceful reconnect), starting fresh session`, 'server');
158
+ // Clean up the stale transport to prevent memory leaks
159
+ const staleTransport = streamableTransports[clientSessionId];
160
+ if (staleTransport) {
161
+ delete streamableTransports[clientSessionId];
162
+ staleTransport.close?.().catch(() => { });
163
+ }
164
+ }
89
165
  const newGeneratedSessionId = randomUUID();
90
166
  const newTransportInstance = new StreamableHTTPServerTransport({
91
- sessionIdGenerator: () => newGeneratedSessionId
167
+ sessionIdGenerator: () => newGeneratedSessionId,
168
+ enableJsonResponse: true,
92
169
  });
93
170
  transport = newTransportInstance;
94
171
  streamableTransports[newGeneratedSessionId] = transport;
@@ -97,11 +174,31 @@ export function startHttpServer(mcpServer, port = 8080) {
97
174
  delete streamableTransports[closedSessionId];
98
175
  };
99
176
  const serverInstance = getServerInstance();
100
- await serverInstance.connect(transport); // Type assertion to work around SDK type issue
177
+ await serverInstance.connect(transport);
178
+ }
179
+ else {
180
+ // Non-initialize request without a session ID (e.g. GET SSE before session established)
181
+ // Return 405 so clients that probe for SSE support handle it gracefully
182
+ res
183
+ .status(405)
184
+ .set('Allow', 'POST')
185
+ .json({
186
+ jsonrpc: '2.0',
187
+ error: {
188
+ code: -32000,
189
+ message: 'Method not allowed: session not established. Send a POST initialize request first.',
190
+ },
191
+ id: req.body?.id || null,
192
+ });
193
+ return;
101
194
  }
102
195
  if (!transport) {
103
196
  logger.error('Transport is undefined before handling request. This should not happen.', 'server');
104
- res.status(500).json({ jsonrpc: '2.0', error: { code: -32000, message: 'Internal server error: Transport not available' }, id: req.body?.id || null });
197
+ res.status(500).json({
198
+ jsonrpc: '2.0',
199
+ error: { code: -32000, message: 'Internal server error: Transport not available' },
200
+ id: req.body?.id || null,
201
+ });
105
202
  return;
106
203
  }
107
204
  if (transport.sessionId) {
@@ -117,15 +214,15 @@ export function startHttpServer(mcpServer, port = 8080) {
117
214
  jsonrpc: '2.0',
118
215
  error: {
119
216
  code: -32000,
120
- message: `Internal server error: ${error instanceof Error ? error.message : String(error)}`
217
+ message: `Internal server error: ${error instanceof Error ? error.message : String(error)}`,
121
218
  },
122
- id: req.body?.id || null
219
+ id: req.body?.id ?? null,
123
220
  });
124
221
  }
125
222
  }
126
223
  });
127
224
  // SSE endpoint
128
- app.get('/mcp/sse', async (_req, res) => {
225
+ app.get('/mcp/sse', mcpAccessGate, async (_req, res) => {
129
226
  try {
130
227
  // Set SSE headers before creating transport
131
228
  res.setHeader('Content-Type', 'text/event-stream');
@@ -133,6 +230,7 @@ export function startHttpServer(mcpServer, port = 8080) {
133
230
  res.setHeader('Connection', 'keep-alive');
134
231
  const transport = new SSEServerTransport('/mcp/messages', res);
135
232
  const sessionId = transport.sessionId;
233
+ logger.info(`Created SSE session: ${sessionId}`, 'DEBUG');
136
234
  sseTransports[sessionId] = transport;
137
235
  // Handle connection cleanup
138
236
  const cleanup = () => {
@@ -149,7 +247,9 @@ export function startHttpServer(mcpServer, port = 8080) {
149
247
  sseServerInstances[sessionId] = isolatedServer;
150
248
  // Connect transport to isolated MCP server (non-blocking)
151
249
  isolatedServer.connect(transport).catch((error) => {
152
- logger.error(`Failed to connect SSE transport for session ${sessionId}`, 'server', { error });
250
+ logger.error(`Failed to connect SSE transport for session ${sessionId}`, 'server', {
251
+ error,
252
+ });
153
253
  cleanup();
154
254
  if (!res.headersSent) {
155
255
  res.status(500).end();
@@ -169,35 +269,47 @@ export function startHttpServer(mcpServer, port = 8080) {
169
269
  }
170
270
  });
171
271
  // Message endpoint for SSE
172
- app.post('/mcp/messages', async (req, res) => {
272
+ app.post('/mcp/messages', mcpAccessGate, async (req, res) => {
173
273
  const sessionId = req.query.sessionId;
174
274
  if (!sessionId) {
275
+ logger.error('Missing sessionId parameter in SSE message', 'server');
175
276
  res.status(400).json({
176
277
  error: 'Missing sessionId parameter',
177
- status: 400
278
+ status: 400,
178
279
  });
179
280
  return;
180
281
  }
181
282
  const transport = sseTransports[sessionId];
182
283
  if (transport) {
284
+ logger.info(`Found active transport for session ${sessionId}`, 'DEBUG');
183
285
  try {
184
286
  await transport.handlePostMessage(req, res, req.body);
185
287
  }
186
288
  catch (error) {
187
- logger.error(`Failed to handle SSE message for session ${sessionId}`, 'server', { error });
289
+ logger.error(`Failed to handle SSE message for session ${sessionId}`, 'server', {
290
+ error,
291
+ });
188
292
  res.status(500).json({
189
293
  error: `Internal server error: ${error instanceof Error ? error.message : String(error)}`,
190
- status: 500
294
+ status: 500,
191
295
  });
192
296
  }
193
297
  }
194
298
  else {
195
299
  res.status(404).json({
196
300
  error: `No active SSE connection found for session ID: ${sessionId}`,
197
- status: 404
301
+ status: 404,
198
302
  });
199
303
  }
200
304
  });
305
+ // Catch-all for undefined routes — return JSON instead of Express default HTML
306
+ app.use((_req, res) => {
307
+ res.status(404).json({
308
+ jsonrpc: '2.0',
309
+ error: { code: -32000, message: 'Not found' },
310
+ id: null,
311
+ });
312
+ });
201
313
  // Create HTTP server with explicit error handling
202
314
  const server = http.createServer(app);
203
315
  server.on('error', (error) => {