mcp-grocy 2.7.1 → 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 +7 -0
  2. package/README.md +15 -5
  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 +9 -3
  7. package/build/resources/README.md +15 -5
  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,398 @@
1
+ import express from 'express';
2
+ import { randomUUID, timingSafeEqual } from 'crypto';
3
+ import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';
4
+ import { SSEServerTransport } from '@modelcontextprotocol/sdk/server/sse.js';
5
+ import { VERSION, PACKAGE_NAME as SERVER_NAME } from '../version.js';
6
+ import cors from 'cors';
7
+ import http from 'http';
8
+ import { logger } from '../utils/logger.js';
9
+ /** Default idle window before an abandoned streamable session is reaped. */
10
+ const DEFAULT_SESSION_IDLE_TIMEOUT_MS = 5 * 60_000;
11
+ /** Default interval between idle-session sweeps. */
12
+ const DEFAULT_SESSION_SWEEP_INTERVAL_MS = 60_000;
13
+ /** Constant-time string comparison to prevent timing attacks on token validation. */
14
+ function safeEqual(a, b) {
15
+ if (a.length !== b.length)
16
+ return false;
17
+ return timingSafeEqual(Buffer.from(a), Buffer.from(b));
18
+ }
19
+ function createMcpAccessGate(accessToken) {
20
+ return (req, res, next) => {
21
+ if (!accessToken) {
22
+ next();
23
+ return;
24
+ }
25
+ if (req.method === 'OPTIONS') {
26
+ next();
27
+ return;
28
+ }
29
+ const bearer = req.headers.authorization;
30
+ const headerToken = req.headers['x-mcp-access-token'];
31
+ const headerTokenStr = Array.isArray(headerToken) ? headerToken[0] : headerToken;
32
+ const q = req.query.access_token;
33
+ const queryToken = typeof q === 'string' ? q : undefined;
34
+ const ok = (typeof bearer === 'string' && safeEqual(bearer, `Bearer ${accessToken}`)) ||
35
+ (typeof headerTokenStr === 'string' && safeEqual(headerTokenStr, accessToken)) ||
36
+ (req.method === 'GET' &&
37
+ typeof queryToken === 'string' &&
38
+ safeEqual(queryToken, accessToken));
39
+ if (ok) {
40
+ next();
41
+ return;
42
+ }
43
+ res.status(401).json({
44
+ jsonrpc: '2.0',
45
+ error: {
46
+ code: -32000,
47
+ message: 'Unauthorized: configure MCP_HTTP_ACCESS_TOKEN and send Authorization: Bearer, X-MCP-Access-Token, or access_token query (GET only)',
48
+ },
49
+ id: (req.body && typeof req.body === 'object' && 'id' in req.body
50
+ ? req.body.id
51
+ : null) ?? null,
52
+ });
53
+ };
54
+ }
55
+ // HTTP Transport for MCP (Context7 style)
56
+ export function startHttpServer(mcpServer, port = 8080, security, sessionOptions = {}) {
57
+ return new Promise((resolve, reject) => {
58
+ const app = express();
59
+ const mcpAccessGate = createMcpAccessGate(security.accessToken);
60
+ if (security.accessToken) {
61
+ logger.config('HTTP MCP access token is enabled (Bearer, X-MCP-Access-Token, or access_token on GET)');
62
+ }
63
+ // Enable JSON body parsing with increased limit
64
+ app.use(express.json({
65
+ limit: '10mb',
66
+ }));
67
+ app.use(cors({
68
+ origin: security.corsOrigin,
69
+ methods: ['GET', 'POST', 'OPTIONS', 'DELETE'],
70
+ allowedHeaders: [
71
+ 'Origin',
72
+ 'X-Requested-With',
73
+ 'Content-Type',
74
+ 'Accept',
75
+ 'Mcp-Session-Id',
76
+ 'Mcp-Protocol-Version',
77
+ 'Authorization',
78
+ 'X-MCP-Access-Token',
79
+ ],
80
+ exposedHeaders: ['Mcp-Session-Id', 'Content-Type'],
81
+ optionsSuccessStatus: 200,
82
+ }));
83
+ // Health check endpoint — always accessible for monitoring/load balancers,
84
+ // but only exposes service details when no access token is configured or request is authenticated.
85
+ app.get('/', (req, res) => {
86
+ const minimal = { status: 'ok' };
87
+ if (security.accessToken) {
88
+ const bearer = req.headers.authorization;
89
+ const headerToken = req.headers['x-mcp-access-token'];
90
+ const headerTokenStr = Array.isArray(headerToken) ? headerToken[0] : headerToken;
91
+ const authenticated = (typeof bearer === 'string' && safeEqual(bearer, `Bearer ${security.accessToken}`)) ||
92
+ (typeof headerTokenStr === 'string' && safeEqual(headerTokenStr, security.accessToken));
93
+ if (!authenticated) {
94
+ res.json(minimal);
95
+ return;
96
+ }
97
+ }
98
+ res.json({
99
+ ...minimal,
100
+ service: SERVER_NAME,
101
+ version: VERSION,
102
+ message: 'MCP server is running',
103
+ endpoints: {
104
+ streamable: '/mcp',
105
+ sse: '/mcp/sse',
106
+ sseMessages: '/mcp/messages',
107
+ },
108
+ });
109
+ });
110
+ // Session management for transports
111
+ const streamableTransports = {};
112
+ const streamableServers = {};
113
+ const streamableLastActivity = {};
114
+ const sseTransports = {};
115
+ const sseServerInstances = {};
116
+ const idleTimeoutMs = sessionOptions.idleTimeoutMs ?? DEFAULT_SESSION_IDLE_TIMEOUT_MS;
117
+ const sweepIntervalMs = sessionOptions.sweepIntervalMs ?? DEFAULT_SESSION_SWEEP_INTERVAL_MS;
118
+ // Close and forget a streamable session, freeing both its transport and the
119
+ // per-session McpServer (52 tools + 3 resources). Idempotent and re-entry
120
+ // safe: the map entries are removed first, so the transport's own `onclose`
121
+ // (which calls back here) short-circuits on the next entry.
122
+ const closeStreamableSession = (sessionId) => {
123
+ const transport = streamableTransports[sessionId];
124
+ if (!transport) {
125
+ return;
126
+ }
127
+ delete streamableTransports[sessionId];
128
+ delete streamableLastActivity[sessionId];
129
+ const serverInstance = streamableServers[sessionId];
130
+ delete streamableServers[sessionId];
131
+ try {
132
+ void transport.close?.();
133
+ }
134
+ catch {
135
+ /* already closed */
136
+ }
137
+ try {
138
+ void serverInstance?.close?.();
139
+ }
140
+ catch {
141
+ /* already closed */
142
+ }
143
+ };
144
+ // Simplified request logging
145
+ app.use((req, _res, next) => {
146
+ logger.server(`${req.method} ${req.path}`);
147
+ next();
148
+ });
149
+ // Helper function to get server instance (shared or new)
150
+ const getServerInstance = () => {
151
+ if (typeof mcpServer === 'function') {
152
+ return mcpServer(); // Create new instance
153
+ }
154
+ else {
155
+ return mcpServer; // Use shared instance
156
+ }
157
+ };
158
+ // Streamable HTTP endpoint (Context7 modern)
159
+ app.all('/mcp', mcpAccessGate, async (req, res) => {
160
+ try {
161
+ const clientSessionId = req.headers['mcp-session-id'] ||
162
+ req.query.sessionId ||
163
+ req.query['mcp-session-id'];
164
+ let transport = undefined;
165
+ // Accept header check (can be done early)
166
+ const accept = req.headers.accept || '';
167
+ // Home Assistant might not send perfect accept headers, so we log a warning instead of blocking
168
+ if (!accept.includes('application/json') && !accept.includes('text/event-stream')) {
169
+ logger.warn('Client should accept application/json or text/event-stream', 'server');
170
+ }
171
+ const isInitializeRequest = req.method === 'POST' && req.body?.method === 'initialize';
172
+ if (clientSessionId && !isInitializeRequest) {
173
+ // Existing session: look up transport
174
+ transport = streamableTransports[clientSessionId];
175
+ if (!transport) {
176
+ logger.warn(`Session ${clientSessionId} not found, returning 400`, 'server');
177
+ res.status(400).json({
178
+ jsonrpc: '2.0',
179
+ error: {
180
+ code: -32001,
181
+ message: `Invalid or expired session ID: ${clientSessionId}. Please re-initialize.`,
182
+ },
183
+ id: req.body?.id || null,
184
+ });
185
+ return;
186
+ }
187
+ // Mark activity so the idle reaper keeps live sessions alive.
188
+ streamableLastActivity[clientSessionId] = Date.now();
189
+ }
190
+ else if (isInitializeRequest) {
191
+ // POST initialize: create new transport and session
192
+ if (clientSessionId) {
193
+ logger.info(`Initialize request included prior session ID ${clientSessionId} (non-graceful reconnect), starting fresh session`, 'server');
194
+ // Clean up the stale transport + server to prevent memory leaks
195
+ closeStreamableSession(clientSessionId);
196
+ }
197
+ const newGeneratedSessionId = randomUUID();
198
+ const newTransportInstance = new StreamableHTTPServerTransport({
199
+ sessionIdGenerator: () => newGeneratedSessionId,
200
+ enableJsonResponse: true,
201
+ });
202
+ transport = newTransportInstance;
203
+ streamableTransports[newGeneratedSessionId] = transport;
204
+ streamableLastActivity[newGeneratedSessionId] = Date.now();
205
+ const serverInstance = getServerInstance();
206
+ streamableServers[newGeneratedSessionId] = serverInstance;
207
+ await serverInstance.connect(transport);
208
+ // McpServer.connect() installs its own transport.onclose; wrap it so we
209
+ // also evict the session from our maps and close the server whenever the
210
+ // transport goes away (explicit DELETE, client disconnect, or idle reap).
211
+ const sdkOnClose = transport.onclose;
212
+ transport.onclose = () => {
213
+ try {
214
+ sdkOnClose?.();
215
+ }
216
+ finally {
217
+ closeStreamableSession(newGeneratedSessionId);
218
+ }
219
+ };
220
+ }
221
+ else {
222
+ // Non-initialize request without a session ID (e.g. GET SSE before session established)
223
+ // Return 405 so clients that probe for SSE support handle it gracefully
224
+ res
225
+ .status(405)
226
+ .set('Allow', 'POST')
227
+ .json({
228
+ jsonrpc: '2.0',
229
+ error: {
230
+ code: -32000,
231
+ message: 'Method not allowed: session not established. Send a POST initialize request first.',
232
+ },
233
+ id: req.body?.id || null,
234
+ });
235
+ return;
236
+ }
237
+ if (!transport) {
238
+ logger.error('Transport is undefined before handling request. This should not happen.', 'server');
239
+ res.status(500).json({
240
+ jsonrpc: '2.0',
241
+ error: { code: -32000, message: 'Internal server error: Transport not available' },
242
+ id: req.body?.id || null,
243
+ });
244
+ return;
245
+ }
246
+ if (transport.sessionId) {
247
+ res.setHeader('Mcp-Session-Id', transport.sessionId);
248
+ }
249
+ await transport.handleRequest(req, res, req.body);
250
+ }
251
+ catch (error) {
252
+ logger.error('Failed to handle streamable HTTP request', 'server', { error });
253
+ // Send error response if headers not sent yet
254
+ if (!res.headersSent) {
255
+ res.status(500).json({
256
+ jsonrpc: '2.0',
257
+ error: {
258
+ code: -32000,
259
+ message: `Internal server error: ${error instanceof Error ? error.message : String(error)}`,
260
+ },
261
+ id: req.body?.id ?? null,
262
+ });
263
+ }
264
+ }
265
+ });
266
+ // SSE endpoint
267
+ app.get('/mcp/sse', mcpAccessGate, async (_req, res) => {
268
+ try {
269
+ // Set SSE headers before creating transport
270
+ res.setHeader('Content-Type', 'text/event-stream');
271
+ res.setHeader('Cache-Control', 'no-cache');
272
+ res.setHeader('Connection', 'keep-alive');
273
+ const transport = new SSEServerTransport('/mcp/messages', res);
274
+ const sessionId = transport.sessionId;
275
+ logger.info(`Created SSE session: ${sessionId}`, 'DEBUG');
276
+ sseTransports[sessionId] = transport;
277
+ // Handle connection cleanup
278
+ const cleanup = () => {
279
+ delete sseTransports[sessionId];
280
+ const isolated = sseServerInstances[sessionId];
281
+ delete sseServerInstances[sessionId];
282
+ try {
283
+ void isolated?.close?.();
284
+ }
285
+ catch {
286
+ /* already closed */
287
+ }
288
+ };
289
+ res.on('close', cleanup);
290
+ res.on('error', (err) => {
291
+ logger.error(`SSE connection error for session ${sessionId}`, 'server', { error: err });
292
+ cleanup();
293
+ });
294
+ // Create isolated server instance for this SSE connection to prevent response cross-talk
295
+ const isolatedServer = getServerInstance();
296
+ sseServerInstances[sessionId] = isolatedServer;
297
+ // Connect transport to isolated MCP server (non-blocking)
298
+ isolatedServer.connect(transport).catch((error) => {
299
+ logger.error(`Failed to connect SSE transport for session ${sessionId}`, 'server', {
300
+ error,
301
+ });
302
+ cleanup();
303
+ if (!res.headersSent) {
304
+ res.status(500).end();
305
+ }
306
+ });
307
+ // Send initial comment to keep connection alive
308
+ res.write(': connected\n\n');
309
+ }
310
+ catch (error) {
311
+ logger.error('Failed to handle SSE connection', 'server', { error });
312
+ if (!res.headersSent) {
313
+ res.status(500).send('Internal Server Error');
314
+ }
315
+ else {
316
+ res.end();
317
+ }
318
+ }
319
+ });
320
+ // Message endpoint for SSE
321
+ app.post('/mcp/messages', mcpAccessGate, async (req, res) => {
322
+ const sessionId = req.query.sessionId;
323
+ if (!sessionId) {
324
+ logger.error('Missing sessionId parameter in SSE message', 'server');
325
+ res.status(400).json({
326
+ error: 'Missing sessionId parameter',
327
+ status: 400,
328
+ });
329
+ return;
330
+ }
331
+ const transport = sseTransports[sessionId];
332
+ if (transport) {
333
+ logger.info(`Found active transport for session ${sessionId}`, 'DEBUG');
334
+ try {
335
+ await transport.handlePostMessage(req, res, req.body);
336
+ }
337
+ catch (error) {
338
+ logger.error(`Failed to handle SSE message for session ${sessionId}`, 'server', {
339
+ error,
340
+ });
341
+ res.status(500).json({
342
+ error: `Internal server error: ${error instanceof Error ? error.message : String(error)}`,
343
+ status: 500,
344
+ });
345
+ }
346
+ }
347
+ else {
348
+ res.status(404).json({
349
+ error: `No active SSE connection found for session ID: ${sessionId}`,
350
+ status: 404,
351
+ });
352
+ }
353
+ });
354
+ // Catch-all for undefined routes — return JSON instead of Express default HTML
355
+ app.use((_req, res) => {
356
+ res.status(404).json({
357
+ jsonrpc: '2.0',
358
+ error: { code: -32000, message: 'Not found' },
359
+ id: null,
360
+ });
361
+ });
362
+ // Create HTTP server with explicit error handling
363
+ const server = http.createServer(app);
364
+ // Idle-session reaper: abandoned streamable sessions (clients that reconnect
365
+ // without sending DELETE) are closed once they exceed the idle window, so
366
+ // their transports + McpServers can be garbage collected instead of growing
367
+ // without bound.
368
+ const reaper = setInterval(() => {
369
+ const now = Date.now();
370
+ for (const sessionId of Object.keys(streamableTransports)) {
371
+ const last = streamableLastActivity[sessionId] ?? 0;
372
+ if (now - last > idleTimeoutMs) {
373
+ logger.info(`Reaping idle MCP session ${sessionId} (idle ${now - last}ms)`, 'server');
374
+ closeStreamableSession(sessionId);
375
+ }
376
+ }
377
+ }, sweepIntervalMs);
378
+ // Don't let the sweep timer keep the process (or test runner) alive.
379
+ reaper.unref?.();
380
+ server.on('close', () => clearInterval(reaper));
381
+ // Expose the live session count for observability and tests.
382
+ server.getMcpSessionCount = () => Object.keys(streamableTransports).length + Object.keys(sseTransports).length;
383
+ server.on('error', (error) => {
384
+ logger.error(`HTTP server error: ${error.message}`, 'server');
385
+ reject(error);
386
+ });
387
+ // Start the server
388
+ server.listen(port, () => {
389
+ logger.server(`HTTP server listening on port ${port}`);
390
+ logger.server(`Available endpoints:`);
391
+ logger.server(` - Health check: http://localhost:${port}/`);
392
+ logger.server(` - Streamable HTTP: http://localhost:${port}/mcp`);
393
+ logger.server(` - SSE: http://localhost:${port}/mcp/sse`);
394
+ logger.server(` - SSE Messages: http://localhost:${port}/mcp/messages`);
395
+ resolve(server);
396
+ });
397
+ });
398
+ }
@@ -0,0 +1,230 @@
1
+ /**
2
+ * Grocy MCP server — McpServer API (registerTool / registerResource).
3
+ */
4
+ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
5
+ import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
6
+ import { z } from 'zod';
7
+ import { CallToolRequestSchema, ErrorCode, McpError, } from '@modelcontextprotocol/sdk/types.js';
8
+ import { VERSION, PACKAGE_NAME, SERVER_NAME as RESOURCE_URI_SCHEME } from '../version.js';
9
+ import { createToolRegistry } from '../tools/index.js';
10
+ import { config } from '../config/index.js';
11
+ import { startHttpServer } from './http-server.js';
12
+ import { ResourceHandler, STATIC_MCP_RESOURCE_ENTRIES } from './resources.js';
13
+ import { toolDefinitionInputZod } from './tool-input-zod.js';
14
+ import { logger } from '../utils/logger.js';
15
+ import { ErrorHandler } from '../utils/errors.js';
16
+ /** Protocol capabilities; merge keeps `tools.asyncSupported` when SDK adds `listChanged`. */
17
+ const GROCY_SERVER_CAPABILITIES = {
18
+ tools: {
19
+ asyncSupported: true,
20
+ },
21
+ resources: {},
22
+ prompts: {},
23
+ };
24
+ const SERVER_INFO = {
25
+ name: PACKAGE_NAME,
26
+ version: VERSION,
27
+ websiteUrl: 'https://github.com/miguelangel-nubla/mcp-grocy',
28
+ description: 'MCP server for Grocy. Documentation: https://github.com/miguelangel-nubla/mcp-grocy/blob/main/README.md',
29
+ };
30
+ export class GrocyMcpServer {
31
+ static sigintHandlerRegistered = false;
32
+ static activeServers = new Set();
33
+ mcp;
34
+ enabledTools = new Set();
35
+ toolSubConfigs = new Map();
36
+ toolAckTokens = new Map();
37
+ resourceHandler;
38
+ toolRegistry;
39
+ constructor(mcp, toolRegistry, resourceHandler) {
40
+ this.mcp = mcp;
41
+ this.toolRegistry = toolRegistry;
42
+ this.resourceHandler = resourceHandler;
43
+ this.parseToolConfiguration();
44
+ this.registerToolsAndResources(this.mcp);
45
+ this.setupErrorHandling(this.mcp);
46
+ }
47
+ static async create() {
48
+ const toolRegistry = await createToolRegistry();
49
+ const resourceHandler = new ResourceHandler();
50
+ const mcp = new McpServer(SERVER_INFO, {
51
+ capabilities: GROCY_SERVER_CAPABILITIES,
52
+ });
53
+ GrocyMcpServer.activeServers.add(mcp);
54
+ return new GrocyMcpServer(mcp, toolRegistry, resourceHandler);
55
+ }
56
+ parseToolConfiguration() {
57
+ const { enabledTools, toolSubConfigs, toolAckTokens } = config.parseToolConfiguration();
58
+ this.toolSubConfigs = toolSubConfigs;
59
+ this.toolAckTokens = toolAckTokens;
60
+ const validToolNames = new Set(this.toolRegistry.getToolNames());
61
+ if (enabledTools.size > 0) {
62
+ const invalidTools = Array.from(enabledTools).filter((tool) => !validToolNames.has(tool));
63
+ if (invalidTools.length > 0) {
64
+ const validNames = Array.from(validToolNames).sort().join(', ');
65
+ logger.error(`Invalid tools: ${invalidTools.join(', ')}. Valid: ${validNames}`, 'CONFIG');
66
+ process.exit(1);
67
+ }
68
+ this.enabledTools = enabledTools;
69
+ logger.config(`Enabled tools: ${Array.from(enabledTools).join(', ')}`);
70
+ }
71
+ else {
72
+ logger.warn('No tools enabled', 'CONFIG');
73
+ }
74
+ }
75
+ registerToolsAndResources(mcp) {
76
+ for (const def of this.toolRegistry.getDefinitions()) {
77
+ const inputSchema = toolDefinitionInputZod(def);
78
+ const registered = mcp.registerTool(def.name, {
79
+ ...(def.title?.trim() ? { title: def.title.trim() } : {}),
80
+ description: def.description,
81
+ inputSchema,
82
+ outputSchema: z.object({ data: z.unknown() }),
83
+ ...(def.annotations ? { annotations: def.annotations } : {}),
84
+ ...(def.meta && Object.keys(def.meta).length > 0 ? { _meta: def.meta } : {}),
85
+ }, async (args) => this.invokeTool(def.name, args));
86
+ if (!this.enabledTools.has(def.name)) {
87
+ registered.disable();
88
+ }
89
+ }
90
+ for (const entry of STATIC_MCP_RESOURCE_ENTRIES) {
91
+ const uri = `${RESOURCE_URI_SCHEME}://${entry.slug}`;
92
+ mcp.registerResource(entry.name, uri, { description: entry.description, mimeType: entry.mimeType }, async (resourceUrl) => this.resourceHandler.readResource(resourceUrl.toString()));
93
+ }
94
+ logger.config(`Registered ${this.toolRegistry.getDefinitions().length} tool(s) (${this.enabledTools.size} enabled) and ${STATIC_MCP_RESOURCE_ENTRIES.length} resource(s) via McpServer`);
95
+ this.installStrictToolsCallHandling(mcp);
96
+ }
97
+ /**
98
+ * MCP SDK maps most tool errors to CallToolResult with isError, but compliance harnesses expect
99
+ * JSON-RPC errors for unknown/disabled tool names.
100
+ *
101
+ * WARNING: accesses SDK internals (_requestHandlers, _registeredTools).
102
+ * Tested against @modelcontextprotocol/sdk ^1.28.0 — verify after upgrades.
103
+ */
104
+ installStrictToolsCallHandling(mcp) {
105
+ const server = mcp.server;
106
+ const previous = server._requestHandlers?.get('tools/call');
107
+ if (!previous) {
108
+ logger.warn('Could not install strict tools/call handling — SDK internals may have changed', 'MCP');
109
+ return;
110
+ }
111
+ const toolsMap = mcp
112
+ ._registeredTools;
113
+ if (!toolsMap) {
114
+ logger.warn('Could not install strict tools/call handling — _registeredTools not found', 'MCP');
115
+ return;
116
+ }
117
+ mcp.server.setRequestHandler(CallToolRequestSchema, async (request, extra) => {
118
+ const name = request.params.name;
119
+ const registered = toolsMap[name];
120
+ if (!registered) {
121
+ throw new McpError(ErrorCode.MethodNotFound, `Tool '${name}' not found`);
122
+ }
123
+ if (!registered.enabled) {
124
+ throw new McpError(ErrorCode.InvalidParams, `Tool '${name}' is disabled`);
125
+ }
126
+ return previous(request, extra);
127
+ });
128
+ }
129
+ async invokeTool(toolName, args) {
130
+ const handler = this.toolRegistry.getHandler(toolName);
131
+ if (!handler) {
132
+ throw new McpError(ErrorCode.MethodNotFound, `Unknown tool: ${toolName}`);
133
+ }
134
+ try {
135
+ const subConfigs = this.toolSubConfigs.get(toolName);
136
+ const result = await handler(args, subConfigs);
137
+ if (!result.isError) {
138
+ if (config.server.serialize_structured_to_content &&
139
+ result.structuredContent?.data !== undefined &&
140
+ result.structuredContent?.data !== null) {
141
+ const dataJson = JSON.stringify(result.structuredContent.data, null, 2);
142
+ const firstBlock = result.content[0];
143
+ if (firstBlock && firstBlock.type === 'text') {
144
+ const currentText = firstBlock.text;
145
+ if (!currentText.includes(dataJson)) {
146
+ firstBlock.text =
147
+ currentText && currentText !== 'Operation completed successfully'
148
+ ? `${currentText}\n${dataJson}`
149
+ : dataJson;
150
+ }
151
+ }
152
+ }
153
+ const ackToken = this.toolAckTokens.get(toolName);
154
+ if (ackToken) {
155
+ result.content.unshift({
156
+ type: 'text',
157
+ text: `Acknowledgment token: ${ackToken}`,
158
+ });
159
+ }
160
+ }
161
+ return result;
162
+ }
163
+ catch (error) {
164
+ ErrorHandler.logError(error, `tool: ${toolName}`);
165
+ throw ErrorHandler.toMcpError(error, `${toolName} failed`);
166
+ }
167
+ }
168
+ setupErrorHandling(mcp) {
169
+ mcp.server.onerror = (error) => {
170
+ logger.error('MCP protocol error', 'MCP', { error });
171
+ };
172
+ if (GrocyMcpServer.sigintHandlerRegistered) {
173
+ return;
174
+ }
175
+ GrocyMcpServer.sigintHandlerRegistered = true;
176
+ process.on('SIGINT', async () => {
177
+ logger.info('Shutting down server...', 'SERVER');
178
+ await Promise.all(Array.from(GrocyMcpServer.activeServers).map((s) => s.close().catch(() => { })));
179
+ process.exit(0);
180
+ });
181
+ }
182
+ createMcpServer() {
183
+ const mcp = new McpServer(SERVER_INFO, {
184
+ capabilities: GROCY_SERVER_CAPABILITIES,
185
+ });
186
+ GrocyMcpServer.activeServers.add(mcp);
187
+ this.registerToolsAndResources(mcp);
188
+ this.setupErrorHandling(mcp);
189
+ return mcp;
190
+ }
191
+ async start() {
192
+ const httpTransportOnly = process.env.MCP_HTTP_TRANSPORT_ONLY === 'true' || process.env.MCP_HTTP_TRANSPORT_ONLY === '1';
193
+ if (httpTransportOnly && !config.server.enable_http_server) {
194
+ logger.error('MCP_HTTP_TRANSPORT_ONLY is set but HTTP server is disabled; enable server.enable_http_server or ENABLE_HTTP_SERVER', 'SERVER');
195
+ process.exit(1);
196
+ }
197
+ if (!httpTransportOnly) {
198
+ const transport = new StdioServerTransport();
199
+ await this.mcp.connect(transport);
200
+ logger.info('MCP server running on stdio', 'SERVER');
201
+ }
202
+ else {
203
+ logger.info('MCP_HTTP_TRANSPORT_ONLY: stdio transport skipped', 'SERVER');
204
+ }
205
+ if (config.server.enable_http_server) {
206
+ try {
207
+ logger.config(`Starting HTTP server on port ${config.server.http_server_port}`);
208
+ const serverFactory = () => this.createMcpServer();
209
+ await startHttpServer(serverFactory, config.server.http_server_port, {
210
+ corsOrigin: config.server.http_cors_origin,
211
+ ...(config.server.http_access_token !== undefined && {
212
+ accessToken: config.server.http_access_token,
213
+ }),
214
+ }, {
215
+ idleTimeoutMs: config.server.session_idle_timeout_ms,
216
+ sweepIntervalMs: config.server.session_sweep_interval_ms,
217
+ });
218
+ }
219
+ catch (error) {
220
+ logger.error('Failed to start HTTP server', 'SERVER', { error });
221
+ logger.error('HTTP server is explicitly enabled but cannot start - exiting', 'SERVER');
222
+ process.exit(1);
223
+ }
224
+ }
225
+ }
226
+ get serverInstance() {
227
+ return this.mcp;
228
+ }
229
+ }
230
+ export default GrocyMcpServer;