mcp-grocy 2.5.0 → 2.7.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 (38) hide show
  1. package/CHANGELOG.md +28 -4
  2. package/README.md +13 -0
  3. package/build/resources/CHANGELOG.md +30 -4
  4. package/build/resources/README.md +13 -0
  5. package/build/resources/config.md +21 -4
  6. package/build/resources/response-format.md +12 -8
  7. package/package.json +14 -2
  8. package/build/api/client.js +0 -119
  9. package/build/config/index.js +0 -259
  10. package/build/main.js +0 -44
  11. package/build/server/http-server.js +0 -330
  12. package/build/server/mcp-server.js +0 -210
  13. package/build/server/resources.js +0 -73
  14. package/build/server/tool-input-zod.js +0 -86
  15. package/build/tools/base.js +0 -145
  16. package/build/tools/household/definitions.js +0 -161
  17. package/build/tools/household/handlers.js +0 -109
  18. package/build/tools/household/index.js +0 -25
  19. package/build/tools/index.js +0 -2
  20. package/build/tools/inventory/definitions.js +0 -416
  21. package/build/tools/inventory/handlers.js +0 -443
  22. package/build/tools/inventory/index.js +0 -32
  23. package/build/tools/module-loader.js +0 -154
  24. package/build/tools/recipes/definitions.js +0 -278
  25. package/build/tools/recipes/handlers.js +0 -477
  26. package/build/tools/recipes/index.js +0 -33
  27. package/build/tools/recipes/validations.js +0 -29
  28. package/build/tools/shopping/definitions.js +0 -73
  29. package/build/tools/shopping/handlers.js +0 -43
  30. package/build/tools/shopping/index.js +0 -14
  31. package/build/tools/system/definitions.js +0 -89
  32. package/build/tools/system/handlers.js +0 -156
  33. package/build/tools/system/index.js +0 -16
  34. package/build/tools/types.js +0 -1
  35. package/build/tools/validation-helpers.js +0 -39
  36. package/build/types/index.js +0 -64
  37. package/build/utils/errors.js +0 -143
  38. package/build/utils/logger.js +0 -142
package/build/main.js DELETED
@@ -1,44 +0,0 @@
1
- #!/usr/bin/env node
2
- /**
3
- * Simplified main entry point
4
- * Demonstrates the refactored architecture
5
- */
6
- // Load environment variables
7
- import 'dotenv/config';
8
- import { GrocyMcpServer } from './server/mcp-server.js';
9
- import { config } from './config/index.js';
10
- import { VERSION, PACKAGE_NAME as SERVER_NAME } from './version.js';
11
- import { logger } from './utils/logger.js';
12
- import { ErrorHandler } from './utils/errors.js';
13
- // Startup banner
14
- logger.info(`Starting ${SERVER_NAME} v${VERSION}`, 'SERVER');
15
- async function main() {
16
- return ErrorHandler.handleAsync(async () => {
17
- // Check API key
18
- if (!config.grocy.api_key) {
19
- logger.warn('No API key configured. Some operations may fail.', 'CONFIG');
20
- }
21
- // Log configuration summary
22
- logger.config(`Grocy URL: ${config.grocy.base_url}`);
23
- logger.config(`SSL Verify: ${config.grocy.enable_ssl_verify}`);
24
- logger.config(`HTTP Server: ${config.server.enable_http_server}`);
25
- // Create and start server
26
- const server = await GrocyMcpServer.create();
27
- await server.start();
28
- logger.info('Server started successfully', 'SERVER');
29
- }, 'server startup');
30
- }
31
- // Error handling
32
- process.on('uncaughtException', (error) => {
33
- logger.error('Uncaught exception', 'PROCESS', { error });
34
- process.exit(1);
35
- });
36
- process.on('unhandledRejection', (reason, promise) => {
37
- logger.error('Unhandled rejection', 'PROCESS', { reason, promise });
38
- process.exit(1);
39
- });
40
- // Start the application
41
- main().catch((error) => {
42
- logger.error('Failed to start server', 'SERVER', { error });
43
- process.exit(1);
44
- });
@@ -1,330 +0,0 @@
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
- /** 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
- }
51
- // HTTP Transport for MCP (Context7 style)
52
- export function startHttpServer(mcpServer, port = 8080, security) {
53
- return new Promise((resolve, reject) => {
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
- }
59
- // Enable JSON body parsing with increased limit
60
- app.use(express.json({
61
- limit: '10mb',
62
- }));
63
- app.use(cors({
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
- ],
76
- exposedHeaders: ['Mcp-Session-Id', 'Content-Type'],
77
- optionsSuccessStatus: 200,
78
- }));
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
- }
94
- res.json({
95
- ...minimal,
96
- service: SERVER_NAME,
97
- version: VERSION,
98
- message: 'MCP server is running',
99
- endpoints: {
100
- streamable: '/mcp',
101
- sse: '/mcp/sse',
102
- sseMessages: '/mcp/messages',
103
- },
104
- });
105
- });
106
- // Session management for transports
107
- const streamableTransports = {};
108
- const sseTransports = {};
109
- const sseServerInstances = {};
110
- // Simplified request logging
111
- app.use((req, _res, next) => {
112
- logger.server(`${req.method} ${req.path}`);
113
- next();
114
- });
115
- // Helper function to get server instance (shared or new)
116
- const getServerInstance = () => {
117
- if (typeof mcpServer === 'function') {
118
- return mcpServer(); // Create new instance
119
- }
120
- else {
121
- return mcpServer; // Use shared instance
122
- }
123
- };
124
- // Streamable HTTP endpoint (Context7 modern)
125
- app.all('/mcp', mcpAccessGate, async (req, res) => {
126
- try {
127
- const clientSessionId = req.headers['mcp-session-id'] ||
128
- req.query.sessionId ||
129
- req.query['mcp-session-id'];
130
- let transport = undefined;
131
- // Accept header check (can be done early)
132
- const accept = req.headers.accept || '';
133
- // Home Assistant might not send perfect accept headers, so we log a warning instead of blocking
134
- if (!accept.includes('application/json') && !accept.includes('text/event-stream')) {
135
- logger.warn('Client should accept application/json or text/event-stream', 'server');
136
- }
137
- const isInitializeRequest = req.method === 'POST' && req.body?.method === 'initialize';
138
- if (clientSessionId && !isInitializeRequest) {
139
- // Existing session: look up transport
140
- transport = streamableTransports[clientSessionId];
141
- if (!transport) {
142
- logger.warn(`Session ${clientSessionId} not found, returning 400`, 'server');
143
- res.status(400).json({
144
- jsonrpc: '2.0',
145
- error: {
146
- code: -32001,
147
- message: `Invalid or expired session ID: ${clientSessionId}. Please re-initialize.`,
148
- },
149
- id: req.body?.id || null,
150
- });
151
- return;
152
- }
153
- }
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
- }
165
- const newGeneratedSessionId = randomUUID();
166
- const newTransportInstance = new StreamableHTTPServerTransport({
167
- sessionIdGenerator: () => newGeneratedSessionId,
168
- enableJsonResponse: true,
169
- });
170
- transport = newTransportInstance;
171
- streamableTransports[newGeneratedSessionId] = transport;
172
- transport.onclose = () => {
173
- const closedSessionId = transport?.sessionId || newGeneratedSessionId;
174
- delete streamableTransports[closedSessionId];
175
- };
176
- const serverInstance = getServerInstance();
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;
194
- }
195
- if (!transport) {
196
- logger.error('Transport is undefined before handling request. This should not happen.', 'server');
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
- });
202
- return;
203
- }
204
- if (transport.sessionId) {
205
- res.setHeader('Mcp-Session-Id', transport.sessionId);
206
- }
207
- await transport.handleRequest(req, res, req.body);
208
- }
209
- catch (error) {
210
- logger.error('Failed to handle streamable HTTP request', 'server', { error });
211
- // Send error response if headers not sent yet
212
- if (!res.headersSent) {
213
- res.status(500).json({
214
- jsonrpc: '2.0',
215
- error: {
216
- code: -32000,
217
- message: `Internal server error: ${error instanceof Error ? error.message : String(error)}`,
218
- },
219
- id: req.body?.id ?? null,
220
- });
221
- }
222
- }
223
- });
224
- // SSE endpoint
225
- app.get('/mcp/sse', mcpAccessGate, async (_req, res) => {
226
- try {
227
- // Set SSE headers before creating transport
228
- res.setHeader('Content-Type', 'text/event-stream');
229
- res.setHeader('Cache-Control', 'no-cache');
230
- res.setHeader('Connection', 'keep-alive');
231
- const transport = new SSEServerTransport('/mcp/messages', res);
232
- const sessionId = transport.sessionId;
233
- logger.info(`Created SSE session: ${sessionId}`, 'DEBUG');
234
- sseTransports[sessionId] = transport;
235
- // Handle connection cleanup
236
- const cleanup = () => {
237
- delete sseTransports[sessionId];
238
- delete sseServerInstances[sessionId];
239
- };
240
- res.on('close', cleanup);
241
- res.on('error', (err) => {
242
- logger.error(`SSE connection error for session ${sessionId}`, 'server', { error: err });
243
- cleanup();
244
- });
245
- // Create isolated server instance for this SSE connection to prevent response cross-talk
246
- const isolatedServer = getServerInstance();
247
- sseServerInstances[sessionId] = isolatedServer;
248
- // Connect transport to isolated MCP server (non-blocking)
249
- isolatedServer.connect(transport).catch((error) => {
250
- logger.error(`Failed to connect SSE transport for session ${sessionId}`, 'server', {
251
- error,
252
- });
253
- cleanup();
254
- if (!res.headersSent) {
255
- res.status(500).end();
256
- }
257
- });
258
- // Send initial comment to keep connection alive
259
- res.write(': connected\n\n');
260
- }
261
- catch (error) {
262
- logger.error('Failed to handle SSE connection', 'server', { error });
263
- if (!res.headersSent) {
264
- res.status(500).send('Internal Server Error');
265
- }
266
- else {
267
- res.end();
268
- }
269
- }
270
- });
271
- // Message endpoint for SSE
272
- app.post('/mcp/messages', mcpAccessGate, async (req, res) => {
273
- const sessionId = req.query.sessionId;
274
- if (!sessionId) {
275
- logger.error('Missing sessionId parameter in SSE message', 'server');
276
- res.status(400).json({
277
- error: 'Missing sessionId parameter',
278
- status: 400,
279
- });
280
- return;
281
- }
282
- const transport = sseTransports[sessionId];
283
- if (transport) {
284
- logger.info(`Found active transport for session ${sessionId}`, 'DEBUG');
285
- try {
286
- await transport.handlePostMessage(req, res, req.body);
287
- }
288
- catch (error) {
289
- logger.error(`Failed to handle SSE message for session ${sessionId}`, 'server', {
290
- error,
291
- });
292
- res.status(500).json({
293
- error: `Internal server error: ${error instanceof Error ? error.message : String(error)}`,
294
- status: 500,
295
- });
296
- }
297
- }
298
- else {
299
- res.status(404).json({
300
- error: `No active SSE connection found for session ID: ${sessionId}`,
301
- status: 404,
302
- });
303
- }
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
- });
313
- // Create HTTP server with explicit error handling
314
- const server = http.createServer(app);
315
- server.on('error', (error) => {
316
- logger.error(`HTTP server error: ${error.message}`, 'server');
317
- reject(error);
318
- });
319
- // Start the server
320
- server.listen(port, () => {
321
- logger.server(`HTTP server listening on port ${port}`);
322
- logger.server(`Available endpoints:`);
323
- logger.server(` - Health check: http://localhost:${port}/`);
324
- logger.server(` - Streamable HTTP: http://localhost:${port}/mcp`);
325
- logger.server(` - SSE: http://localhost:${port}/mcp/sse`);
326
- logger.server(` - SSE Messages: http://localhost:${port}/mcp/messages`);
327
- resolve(server);
328
- });
329
- });
330
- }
@@ -1,210 +0,0 @@
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 { CallToolRequestSchema, ErrorCode, McpError, } from '@modelcontextprotocol/sdk/types.js';
7
- import { VERSION, PACKAGE_NAME, SERVER_NAME as RESOURCE_URI_SCHEME } from '../version.js';
8
- import { createToolRegistry } from '../tools/index.js';
9
- import { config } from '../config/index.js';
10
- import { startHttpServer } from './http-server.js';
11
- import { ResourceHandler, STATIC_MCP_RESOURCE_ENTRIES } from './resources.js';
12
- import { toolDefinitionInputZod } from './tool-input-zod.js';
13
- import { logger } from '../utils/logger.js';
14
- import { ErrorHandler } from '../utils/errors.js';
15
- /** Janix mcp-validator (2025-03-26) checks `tools.asyncSupported`; merge keeps this when SDK adds `listChanged`. */
16
- const GROCY_SERVER_CAPABILITIES = {
17
- tools: {
18
- asyncSupported: true,
19
- },
20
- resources: {},
21
- prompts: {},
22
- };
23
- const SERVER_INFO = {
24
- name: PACKAGE_NAME,
25
- version: VERSION,
26
- websiteUrl: 'https://github.com/miguelangel-nubla/mcp-grocy',
27
- description: 'MCP server for Grocy. Documentation: https://github.com/miguelangel-nubla/mcp-grocy/blob/main/README.md',
28
- };
29
- export class GrocyMcpServer {
30
- static sigintHandlerRegistered = false;
31
- static activeServers = new Set();
32
- mcp;
33
- enabledTools = new Set();
34
- toolSubConfigs = new Map();
35
- toolAckTokens = new Map();
36
- resourceHandler;
37
- toolRegistry;
38
- constructor(mcp, toolRegistry, resourceHandler) {
39
- this.mcp = mcp;
40
- this.toolRegistry = toolRegistry;
41
- this.resourceHandler = resourceHandler;
42
- this.parseToolConfiguration();
43
- this.registerToolsAndResources(this.mcp);
44
- this.setupErrorHandling(this.mcp);
45
- }
46
- static async create() {
47
- const toolRegistry = await createToolRegistry();
48
- const resourceHandler = new ResourceHandler();
49
- const mcp = new McpServer(SERVER_INFO, {
50
- capabilities: GROCY_SERVER_CAPABILITIES,
51
- });
52
- GrocyMcpServer.activeServers.add(mcp);
53
- return new GrocyMcpServer(mcp, toolRegistry, resourceHandler);
54
- }
55
- parseToolConfiguration() {
56
- const { enabledTools, toolSubConfigs, toolAckTokens } = config.parseToolConfiguration();
57
- this.toolSubConfigs = toolSubConfigs;
58
- this.toolAckTokens = toolAckTokens;
59
- const validToolNames = new Set(this.toolRegistry.getToolNames());
60
- if (enabledTools.size > 0) {
61
- const invalidTools = Array.from(enabledTools).filter((tool) => !validToolNames.has(tool));
62
- if (invalidTools.length > 0) {
63
- const validNames = Array.from(validToolNames).sort().join(', ');
64
- logger.error(`Invalid tools: ${invalidTools.join(', ')}. Valid: ${validNames}`, 'CONFIG');
65
- process.exit(1);
66
- }
67
- this.enabledTools = enabledTools;
68
- logger.config(`Enabled tools: ${Array.from(enabledTools).join(', ')}`);
69
- }
70
- else {
71
- logger.warn('No tools enabled', 'CONFIG');
72
- }
73
- }
74
- registerToolsAndResources(mcp) {
75
- for (const def of this.toolRegistry.getDefinitions()) {
76
- const inputSchema = toolDefinitionInputZod(def);
77
- const registered = mcp.registerTool(def.name, {
78
- ...(def.title?.trim() ? { title: def.title.trim() } : {}),
79
- description: def.description,
80
- inputSchema,
81
- ...(def.annotations ? { annotations: def.annotations } : {}),
82
- ...(def.meta && Object.keys(def.meta).length > 0 ? { _meta: def.meta } : {}),
83
- }, async (args) => this.invokeTool(def.name, args));
84
- if (!this.enabledTools.has(def.name)) {
85
- registered.disable();
86
- }
87
- }
88
- for (const entry of STATIC_MCP_RESOURCE_ENTRIES) {
89
- const uri = `${RESOURCE_URI_SCHEME}://${entry.slug}`;
90
- mcp.registerResource(entry.name, uri, { description: entry.description, mimeType: entry.mimeType }, async (resourceUrl) => this.resourceHandler.readResource(resourceUrl.toString()));
91
- }
92
- logger.config(`Registered ${this.toolRegistry.getDefinitions().length} tool(s) (${this.enabledTools.size} enabled) and ${STATIC_MCP_RESOURCE_ENTRIES.length} resource(s) via McpServer`);
93
- this.installStrictToolsCallHandling(mcp);
94
- }
95
- /**
96
- * MCP SDK maps most tool errors to CallToolResult with isError, but compliance harnesses expect
97
- * JSON-RPC errors for unknown/disabled tool names.
98
- *
99
- * WARNING: accesses SDK internals (_requestHandlers, _registeredTools).
100
- * Tested against @modelcontextprotocol/sdk ^1.28.0 — verify after upgrades.
101
- */
102
- installStrictToolsCallHandling(mcp) {
103
- const server = mcp.server;
104
- const previous = server._requestHandlers?.get('tools/call');
105
- if (!previous) {
106
- logger.warn('Could not install strict tools/call handling — SDK internals may have changed', 'MCP');
107
- return;
108
- }
109
- const toolsMap = mcp
110
- ._registeredTools;
111
- if (!toolsMap) {
112
- logger.warn('Could not install strict tools/call handling — _registeredTools not found', 'MCP');
113
- return;
114
- }
115
- mcp.server.setRequestHandler(CallToolRequestSchema, async (request, extra) => {
116
- const name = request.params.name;
117
- const registered = toolsMap[name];
118
- if (!registered) {
119
- throw new McpError(ErrorCode.MethodNotFound, `Tool '${name}' not found`);
120
- }
121
- if (!registered.enabled) {
122
- throw new McpError(ErrorCode.InvalidParams, `Tool '${name}' is disabled`);
123
- }
124
- return previous(request, extra);
125
- });
126
- }
127
- async invokeTool(toolName, args) {
128
- const handler = this.toolRegistry.getHandler(toolName);
129
- if (!handler) {
130
- throw new McpError(ErrorCode.MethodNotFound, `Unknown tool: ${toolName}`);
131
- }
132
- try {
133
- const subConfigs = this.toolSubConfigs.get(toolName);
134
- const result = await handler(args, subConfigs);
135
- if (!result.isError) {
136
- const ackToken = this.toolAckTokens.get(toolName);
137
- if (ackToken) {
138
- result.content.unshift({
139
- type: 'text',
140
- text: `Acknowledgment token: ${ackToken}`,
141
- });
142
- }
143
- }
144
- return result;
145
- }
146
- catch (error) {
147
- ErrorHandler.logError(error, `tool: ${toolName}`);
148
- throw ErrorHandler.toMcpError(error, `${toolName} failed`);
149
- }
150
- }
151
- setupErrorHandling(mcp) {
152
- mcp.server.onerror = (error) => {
153
- logger.error('MCP protocol error', 'MCP', { error });
154
- };
155
- if (GrocyMcpServer.sigintHandlerRegistered) {
156
- return;
157
- }
158
- GrocyMcpServer.sigintHandlerRegistered = true;
159
- process.on('SIGINT', async () => {
160
- logger.info('Shutting down server...', 'SERVER');
161
- await Promise.all(Array.from(GrocyMcpServer.activeServers).map((s) => s.close().catch(() => { })));
162
- process.exit(0);
163
- });
164
- }
165
- createMcpServer() {
166
- const mcp = new McpServer(SERVER_INFO, {
167
- capabilities: GROCY_SERVER_CAPABILITIES,
168
- });
169
- GrocyMcpServer.activeServers.add(mcp);
170
- this.registerToolsAndResources(mcp);
171
- this.setupErrorHandling(mcp);
172
- return mcp;
173
- }
174
- async start() {
175
- const httpTransportOnly = process.env.MCP_HTTP_TRANSPORT_ONLY === 'true' || process.env.MCP_HTTP_TRANSPORT_ONLY === '1';
176
- if (httpTransportOnly && !config.server.enable_http_server) {
177
- logger.error('MCP_HTTP_TRANSPORT_ONLY is set but HTTP server is disabled; enable server.enable_http_server or ENABLE_HTTP_SERVER', 'SERVER');
178
- process.exit(1);
179
- }
180
- if (!httpTransportOnly) {
181
- const transport = new StdioServerTransport();
182
- await this.mcp.connect(transport);
183
- logger.info('MCP server running on stdio', 'SERVER');
184
- }
185
- else {
186
- logger.info('MCP_HTTP_TRANSPORT_ONLY: stdio transport skipped', 'SERVER');
187
- }
188
- if (config.server.enable_http_server) {
189
- try {
190
- logger.config(`Starting HTTP server on port ${config.server.http_server_port}`);
191
- const serverFactory = () => this.createMcpServer();
192
- await startHttpServer(serverFactory, config.server.http_server_port, {
193
- corsOrigin: config.server.http_cors_origin,
194
- ...(config.server.http_access_token !== undefined && {
195
- accessToken: config.server.http_access_token,
196
- }),
197
- });
198
- }
199
- catch (error) {
200
- logger.error('Failed to start HTTP server', 'SERVER', { error });
201
- logger.error('HTTP server is explicitly enabled but cannot start - exiting', 'SERVER');
202
- process.exit(1);
203
- }
204
- }
205
- }
206
- get serverInstance() {
207
- return this.mcp;
208
- }
209
- }
210
- export default GrocyMcpServer;
@@ -1,73 +0,0 @@
1
- import { ErrorCode, McpError } from '@modelcontextprotocol/sdk/types.js';
2
- import { SERVER_NAME } from '../version.js';
3
- import fs from 'fs';
4
- import path from 'path';
5
- import { fileURLToPath } from 'url';
6
- /** Static docs exposed as MCP resources (used by ResourceHandler and McpServer registration). */
7
- export const STATIC_MCP_RESOURCE_ENTRIES = [
8
- {
9
- slug: 'examples',
10
- name: 'mcp-grocy usage examples',
11
- description: 'Examples of calling this MCP server’s tools against Grocy',
12
- mimeType: 'text/markdown',
13
- },
14
- {
15
- slug: 'response-format',
16
- name: 'Tool response format',
17
- description: 'How tool results and system_dev_test_request responses are shaped',
18
- mimeType: 'text/markdown',
19
- },
20
- {
21
- slug: 'config',
22
- name: 'Configuration',
23
- description: 'YAML and environment configuration for mcp-grocy',
24
- mimeType: 'text/markdown',
25
- },
26
- ];
27
- export class ResourceHandler {
28
- __dirname;
29
- allowedResources = new Set(STATIC_MCP_RESOURCE_ENTRIES.map((e) => e.slug));
30
- constructor() {
31
- const __filename = fileURLToPath(import.meta.url);
32
- this.__dirname = path.dirname(__filename);
33
- }
34
- async listResources() {
35
- return {
36
- resources: STATIC_MCP_RESOURCE_ENTRIES.map((e) => ({
37
- uri: `${SERVER_NAME}://${e.slug}`,
38
- name: e.name,
39
- description: e.description,
40
- mimeType: e.mimeType,
41
- })),
42
- };
43
- }
44
- async readResource(uri) {
45
- const uriPattern = new RegExp(`^${SERVER_NAME}://(.+)$`);
46
- const match = uri.match(uriPattern);
47
- if (!match) {
48
- throw new McpError(ErrorCode.InvalidRequest, `Invalid resource URI format: ${uri}`);
49
- }
50
- const resource = match[1];
51
- if (!resource || !this.allowedResources.has(resource)) {
52
- throw new McpError(ErrorCode.InvalidRequest, `Resource not found: ${resource}`);
53
- }
54
- try {
55
- // In the built app, resources are in build/resources
56
- // In development, they're in src/resources
57
- const resourcePath = path.join(this.__dirname, '../resources', `${resource}.md`);
58
- const content = await fs.promises.readFile(resourcePath, 'utf8');
59
- return {
60
- contents: [
61
- {
62
- uri,
63
- mimeType: 'text/markdown',
64
- text: content,
65
- },
66
- ],
67
- };
68
- }
69
- catch {
70
- throw new McpError(ErrorCode.InvalidRequest, `Resource not found: ${resource}`);
71
- }
72
- }
73
- }