mcp-grocy 1.9.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.
@@ -0,0 +1,214 @@
1
+ import express from 'express';
2
+ import { randomUUID } 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
+ // HTTP Transport for MCP (Context7 style)
9
+ export function startHttpServer(mcpServer, port = 8080) {
10
+ const app = express();
11
+ // Enable JSON body parsing with increased limit
12
+ app.use(express.json({
13
+ limit: '10mb'
14
+ }));
15
+ // Enable CORS for all routes
16
+ app.use(cors({
17
+ origin: '*',
18
+ methods: ['GET', 'POST', 'OPTIONS'],
19
+ allowedHeaders: ['Origin', 'X-Requested-With', 'Content-Type', 'Accept', 'Mcp-Session-Id', 'Authorization'],
20
+ exposedHeaders: ['Mcp-Session-Id', 'Content-Type'],
21
+ optionsSuccessStatus: 200
22
+ }));
23
+ // Simple health check endpoint
24
+ app.get('/', (req, res) => {
25
+ res.json({
26
+ status: 'ok',
27
+ service: SERVER_NAME,
28
+ version: VERSION,
29
+ message: 'MCP server is running',
30
+ endpoints: {
31
+ streamable: '/mcp',
32
+ sse: '/mcp/sse',
33
+ sseMessages: '/mcp/messages'
34
+ }
35
+ });
36
+ });
37
+ // Session management for transports
38
+ const streamableTransports = {};
39
+ const sseTransports = {};
40
+ const sseServerInstances = {};
41
+ // Simplified request logging
42
+ app.use((req, res, next) => {
43
+ console.error(`[${new Date().toISOString()}] ${req.method} ${req.path}`);
44
+ next();
45
+ });
46
+ // Helper function to get server instance (shared or new)
47
+ const getServerInstance = () => {
48
+ if (typeof mcpServer === 'function') {
49
+ return mcpServer(); // Create new instance
50
+ }
51
+ else {
52
+ return mcpServer; // Use shared instance
53
+ }
54
+ };
55
+ // Streamable HTTP endpoint (Context7 modern)
56
+ app.post('/mcp', async (req, res) => {
57
+ try {
58
+ const clientSessionId = req.headers['mcp-session-id'];
59
+ let transport = undefined;
60
+ // Accept header check (can be done early)
61
+ const accept = req.headers.accept || '';
62
+ if (!accept.includes('application/json') && !accept.includes('text/event-stream')) {
63
+ console.error('[ERROR] Client must accept application/json or text/event-stream');
64
+ res.status(406).json({
65
+ jsonrpc: '2.0',
66
+ error: {
67
+ code: -32000,
68
+ message: 'Not Acceptable: Client must accept application/json or text/event-stream'
69
+ },
70
+ id: req.body?.id || null
71
+ });
72
+ return;
73
+ }
74
+ if (clientSessionId) {
75
+ transport = streamableTransports[clientSessionId];
76
+ if (!transport) {
77
+ res.status(400).json({
78
+ jsonrpc: '2.0',
79
+ error: { code: -32001, message: `Invalid or expired session ID: ${clientSessionId}. Please re-initialize.` },
80
+ id: req.body?.id || null
81
+ });
82
+ return;
83
+ }
84
+ }
85
+ else {
86
+ // No session ID provided by client, create new transport
87
+ const newGeneratedSessionId = randomUUID();
88
+ const newTransportInstance = new StreamableHTTPServerTransport({
89
+ sessionIdGenerator: () => newGeneratedSessionId
90
+ });
91
+ transport = newTransportInstance;
92
+ streamableTransports[newGeneratedSessionId] = transport;
93
+ transport.onclose = () => {
94
+ const closedSessionId = transport?.sessionId || newGeneratedSessionId;
95
+ delete streamableTransports[closedSessionId];
96
+ };
97
+ const serverInstance = getServerInstance();
98
+ await serverInstance.connect(transport);
99
+ }
100
+ if (!transport) {
101
+ console.error('[CRITICAL_ERROR] Transport is undefined before handling request. This should not happen.');
102
+ res.status(500).json({ jsonrpc: '2.0', error: { code: -32000, message: 'Internal server error: Transport not available' }, id: req.body?.id || null });
103
+ return;
104
+ }
105
+ if (transport.sessionId) {
106
+ res.setHeader('Mcp-Session-Id', transport.sessionId);
107
+ }
108
+ await transport.handleRequest(req, res, req.body);
109
+ }
110
+ catch (error) {
111
+ console.error('[ERROR] Failed to handle streamable HTTP request:', error);
112
+ // Send error response if headers not sent yet
113
+ if (!res.headersSent) {
114
+ res.status(500).json({
115
+ jsonrpc: '2.0',
116
+ error: {
117
+ code: -32000,
118
+ message: `Internal server error: ${error instanceof Error ? error.message : String(error)}`
119
+ },
120
+ id: req.body?.id || null
121
+ });
122
+ }
123
+ }
124
+ });
125
+ // SSE endpoint
126
+ app.get('/mcp/sse', async (_req, res) => {
127
+ try {
128
+ // Set SSE headers before creating transport
129
+ res.setHeader('Content-Type', 'text/event-stream');
130
+ res.setHeader('Cache-Control', 'no-cache');
131
+ res.setHeader('Connection', 'keep-alive');
132
+ const transport = new SSEServerTransport('/mcp/messages', res);
133
+ const sessionId = transport.sessionId;
134
+ sseTransports[sessionId] = transport;
135
+ // Handle connection cleanup
136
+ const cleanup = () => {
137
+ delete sseTransports[sessionId];
138
+ delete sseServerInstances[sessionId];
139
+ };
140
+ res.on('close', cleanup);
141
+ res.on('error', (err) => {
142
+ console.error(`[ERROR] SSE connection error for session ${sessionId}:`, err);
143
+ cleanup();
144
+ });
145
+ // Create isolated server instance for this SSE connection to prevent response cross-talk
146
+ const isolatedServer = getServerInstance();
147
+ sseServerInstances[sessionId] = isolatedServer;
148
+ // Connect transport to isolated MCP server (non-blocking)
149
+ isolatedServer.connect(transport).catch((error) => {
150
+ console.error(`[ERROR] Failed to connect SSE transport for session ${sessionId}:`, error);
151
+ cleanup();
152
+ if (!res.headersSent) {
153
+ res.status(500).end();
154
+ }
155
+ });
156
+ // Send initial comment to keep connection alive
157
+ res.write(': connected\n\n');
158
+ }
159
+ catch (error) {
160
+ console.error('[ERROR] Failed to handle SSE connection:', error);
161
+ if (!res.headersSent) {
162
+ res.status(500).send('Internal Server Error');
163
+ }
164
+ else {
165
+ res.end();
166
+ }
167
+ }
168
+ });
169
+ // Message endpoint for SSE
170
+ app.post('/mcp/messages', async (req, res) => {
171
+ const sessionId = req.query.sessionId;
172
+ if (!sessionId) {
173
+ res.status(400).json({
174
+ error: 'Missing sessionId parameter',
175
+ status: 400
176
+ });
177
+ return;
178
+ }
179
+ const transport = sseTransports[sessionId];
180
+ if (transport) {
181
+ try {
182
+ await transport.handlePostMessage(req, res, req.body);
183
+ }
184
+ catch (error) {
185
+ console.error(`[ERROR] Failed to handle SSE message for session ${sessionId}:`, error);
186
+ res.status(500).json({
187
+ error: `Internal server error: ${error instanceof Error ? error.message : String(error)}`,
188
+ status: 500
189
+ });
190
+ }
191
+ }
192
+ else {
193
+ res.status(404).json({
194
+ error: `No active SSE connection found for session ID: ${sessionId}`,
195
+ status: 404
196
+ });
197
+ }
198
+ });
199
+ // Create HTTP server with explicit error handling
200
+ const server = http.createServer(app);
201
+ server.on('error', (error) => {
202
+ console.error(`[ERROR] HTTP server error: ${error.message}`);
203
+ });
204
+ // Start the server
205
+ server.listen(port, () => {
206
+ console.error(`[MCP] HTTP server listening on port ${port}`);
207
+ console.error(`[MCP] Available endpoints:`);
208
+ console.error(`[MCP] - Health check: http://localhost:${port}/`);
209
+ console.error(`[MCP] - Streamable HTTP: http://localhost:${port}/mcp`);
210
+ console.error(`[MCP] - SSE: http://localhost:${port}/mcp/sse`);
211
+ console.error(`[MCP] - SSE Messages: http://localhost:${port}/mcp/messages`);
212
+ });
213
+ return server;
214
+ }
@@ -0,0 +1,173 @@
1
+ import { Server } from '@modelcontextprotocol/sdk/server/index.js';
2
+ import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
3
+ import { CallToolRequestSchema, ErrorCode, InitializeRequestSchema, ListToolsRequestSchema, ListResourcesRequestSchema, ReadResourceRequestSchema, McpError, } from '@modelcontextprotocol/sdk/types.js';
4
+ import { z } from 'zod';
5
+ import { VERSION, PACKAGE_NAME as SERVER_NAME } from '../version.js';
6
+ import { toolRegistry } from '../tools/index.js';
7
+ import config from '../config/environment.js';
8
+ import { startHttpServer } from './http-server.js';
9
+ import { ResourceHandler } from './resources.js';
10
+ export class GrocyMcpServer {
11
+ server;
12
+ enabledTools = new Set();
13
+ toolSubConfigs = new Map();
14
+ resourceHandler;
15
+ // Expose server instance for HTTP/SSE transport
16
+ get serverInstance() {
17
+ return this.server;
18
+ }
19
+ constructor() {
20
+ this.resourceHandler = new ResourceHandler();
21
+ this.parseToolConfiguration();
22
+ this.setupServer();
23
+ }
24
+ parseToolConfiguration() {
25
+ const { enabledTools, toolSubConfigs } = config.parseToolConfiguration();
26
+ this.toolSubConfigs = toolSubConfigs;
27
+ // Validate tool names against registry
28
+ const validToolNames = new Set(toolRegistry.getToolNames());
29
+ if (enabledTools.size > 0) {
30
+ const invalidTools = Array.from(enabledTools).filter(tool => !validToolNames.has(tool));
31
+ if (invalidTools.length > 0) {
32
+ console.error(`[ERROR] Invalid tool names in configuration: ${invalidTools.join(', ')}`);
33
+ console.error(`[ERROR] Valid tool names are: ${Array.from(validToolNames).sort().join(', ')}`);
34
+ process.exit(1);
35
+ }
36
+ this.enabledTools = enabledTools;
37
+ }
38
+ else {
39
+ console.error('[CONFIG] No tools enabled - server will have no tools available');
40
+ }
41
+ }
42
+ isToolAllowed(toolName) {
43
+ // Only enabled tools are allowed - all others are disabled by default
44
+ return this.enabledTools.has(toolName);
45
+ }
46
+ async setupServer() {
47
+ this.server = new Server({
48
+ name: SERVER_NAME,
49
+ version: VERSION,
50
+ serverUrl: "https://github.com/miguelangel-nubla/mcp-grocy",
51
+ documentationUrl: "https://github.com/miguelangel-nubla/mcp-grocy/blob/main/README.md"
52
+ }, {
53
+ capabilities: {
54
+ tools: {},
55
+ resources: {},
56
+ prompts: {}
57
+ }
58
+ });
59
+ this.setupHandlers();
60
+ this.setupErrorHandling();
61
+ }
62
+ setupHandlers() {
63
+ // Initialize handler
64
+ this.server.setRequestHandler(InitializeRequestSchema, async (request) => {
65
+ console.error('[DEBUG] Initialize handler called with request:', JSON.stringify(request));
66
+ return {
67
+ capabilities: {
68
+ tools: {},
69
+ resources: {},
70
+ prompts: {}
71
+ },
72
+ serverInfo: {
73
+ name: SERVER_NAME,
74
+ version: VERSION
75
+ }
76
+ };
77
+ });
78
+ // Plain initialize handler for compatibility
79
+ const PlainInitializeRequestSchema = z.object({
80
+ jsonrpc: z.literal('2.0'),
81
+ id: z.union([z.string(), z.number()]).optional(),
82
+ method: z.literal('initialize'),
83
+ params: z.any().optional()
84
+ });
85
+ this.server.setRequestHandler(PlainInitializeRequestSchema, async (request) => {
86
+ console.error('[DEBUG] Plain "initialize" handler called');
87
+ let protocolVersion = '2024-11-05';
88
+ if (request.params && typeof request.params.protocolVersion === 'string') {
89
+ protocolVersion = request.params.protocolVersion;
90
+ }
91
+ return {
92
+ protocolVersion,
93
+ capabilities: {
94
+ tools: {},
95
+ resources: {},
96
+ prompts: {}
97
+ },
98
+ serverInfo: {
99
+ name: SERVER_NAME,
100
+ version: VERSION
101
+ }
102
+ };
103
+ });
104
+ // List tools handler
105
+ this.server.setRequestHandler(ListToolsRequestSchema, async () => {
106
+ const allTools = toolRegistry.getDefinitions();
107
+ const filteredTools = allTools.filter(tool => this.isToolAllowed(tool.name));
108
+ console.error(`[CONFIG] Available tools: ${filteredTools.map(t => t.name).join(', ')}`);
109
+ return { tools: filteredTools };
110
+ });
111
+ // Call tool handler
112
+ this.server.setRequestHandler(CallToolRequestSchema, async (request) => {
113
+ const toolName = request.params.name;
114
+ // Check if the tool is allowed
115
+ if (!this.isToolAllowed(toolName)) {
116
+ throw new McpError(ErrorCode.InvalidRequest, `Tool '${toolName}' is not enabled. Set TOOL__${toolName}=true in your configuration to enable it.`);
117
+ }
118
+ // Get handler from registry
119
+ const handler = toolRegistry.getHandler(toolName);
120
+ if (!handler) {
121
+ throw new McpError(ErrorCode.MethodNotFound, `Unknown tool: ${toolName}`);
122
+ }
123
+ try {
124
+ const subConfigs = this.toolSubConfigs.get(toolName);
125
+ const result = await handler(request.params.arguments, subConfigs);
126
+ return result; // Cast to proper MCP type
127
+ }
128
+ catch (error) {
129
+ console.error(`Error executing tool ${toolName}:`, error);
130
+ if (error instanceof McpError) {
131
+ throw error;
132
+ }
133
+ throw new McpError(ErrorCode.InternalError, `Tool execution failed: ${error.message || error}`);
134
+ }
135
+ });
136
+ // Resource handlers
137
+ this.server.setRequestHandler(ListResourcesRequestSchema, async () => {
138
+ return this.resourceHandler.listResources();
139
+ });
140
+ this.server.setRequestHandler(ReadResourceRequestSchema, async (request) => {
141
+ return this.resourceHandler.readResource(request.params.uri);
142
+ });
143
+ }
144
+ setupErrorHandling() {
145
+ this.server.onerror = (error) => console.error('[MCP Error]', error);
146
+ process.on('SIGINT', async () => {
147
+ await this.server.close();
148
+ process.exit(0);
149
+ });
150
+ }
151
+ async start() {
152
+ // Start STDIO transport
153
+ const transport = new StdioServerTransport();
154
+ await this.server.connect(transport);
155
+ console.error('Grocy MCP server running on stdio');
156
+ // Start HTTP/SSE transport if enabled
157
+ const envConfig = config.get();
158
+ if (envConfig.ENABLE_HTTP_SERVER) {
159
+ try {
160
+ console.error(`[CONFIG] Starting HTTP/SSE server on port ${envConfig.HTTP_SERVER_PORT}`);
161
+ const serverFactory = () => new GrocyMcpServer().serverInstance;
162
+ startHttpServer(serverFactory, envConfig.HTTP_SERVER_PORT);
163
+ }
164
+ catch (error) {
165
+ console.error('[ERROR] Failed to start HTTP/SSE server:', error);
166
+ }
167
+ }
168
+ else {
169
+ console.error('[CONFIG] HTTP/SSE server is disabled');
170
+ }
171
+ }
172
+ }
173
+ export default GrocyMcpServer;
@@ -0,0 +1,60 @@
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
+ export class ResourceHandler {
7
+ __dirname;
8
+ constructor() {
9
+ const __filename = fileURLToPath(import.meta.url);
10
+ this.__dirname = path.dirname(__filename);
11
+ }
12
+ async listResources() {
13
+ return {
14
+ resources: [
15
+ {
16
+ uri: `${SERVER_NAME}://examples`,
17
+ name: 'Grocy API Usage Examples',
18
+ description: 'Detailed examples of using the Grocy API',
19
+ mimeType: 'text/markdown'
20
+ },
21
+ {
22
+ uri: `${SERVER_NAME}://response-format`,
23
+ name: 'Response Format Documentation',
24
+ description: 'Documentation of the response format and structure',
25
+ mimeType: 'text/markdown'
26
+ },
27
+ {
28
+ uri: `${SERVER_NAME}://config`,
29
+ name: 'Configuration Documentation',
30
+ description: 'Documentation of all configuration options and how to use them',
31
+ mimeType: 'text/markdown'
32
+ }
33
+ ]
34
+ };
35
+ }
36
+ async readResource(uri) {
37
+ const uriPattern = new RegExp(`^${SERVER_NAME}://(.+)$`);
38
+ const match = uri.match(uriPattern);
39
+ if (!match) {
40
+ throw new McpError(ErrorCode.InvalidRequest, `Invalid resource URI format: ${uri}`);
41
+ }
42
+ const resource = match[1];
43
+ try {
44
+ // In the built app, resources are in build/resources
45
+ // In development, they're in src/resources
46
+ const resourcePath = path.join(this.__dirname, '../resources', `${resource}.md`);
47
+ const content = await fs.promises.readFile(resourcePath, 'utf8');
48
+ return {
49
+ contents: [{
50
+ uri,
51
+ mimeType: 'text/markdown',
52
+ text: content
53
+ }]
54
+ };
55
+ }
56
+ catch (error) {
57
+ throw new McpError(ErrorCode.InvalidRequest, `Resource not found: ${resource}`);
58
+ }
59
+ }
60
+ }
@@ -0,0 +1,56 @@
1
+ import apiClient, { ApiError } from '../api/client.js';
2
+ export class BaseToolHandler {
3
+ safeJsonStringify(data) {
4
+ try {
5
+ return JSON.stringify(data, null, 2);
6
+ }
7
+ catch (error) {
8
+ console.error('Error stringifying JSON:', error);
9
+ return JSON.stringify({ error: 'Error formatting response data' }, null, 2);
10
+ }
11
+ }
12
+ createSuccessResult(data) {
13
+ return {
14
+ content: [{
15
+ type: 'text',
16
+ text: this.safeJsonStringify(data)
17
+ }]
18
+ };
19
+ }
20
+ createErrorResult(error, context) {
21
+ const errorMessage = error instanceof Error ? error.message : error;
22
+ const result = { error: errorMessage };
23
+ if (context) {
24
+ result.context = context;
25
+ }
26
+ return {
27
+ content: [{
28
+ type: 'text',
29
+ text: this.safeJsonStringify(result)
30
+ }],
31
+ isError: true
32
+ };
33
+ }
34
+ async handleApiCall(endpoint, description, options = {}) {
35
+ try {
36
+ const method = options.method || 'GET';
37
+ const body = options.body || null;
38
+ const headers = options.headers || {};
39
+ const queryParams = options.queryParams || {};
40
+ const response = await apiClient.request(endpoint, {
41
+ method,
42
+ body,
43
+ headers,
44
+ queryParams
45
+ });
46
+ return this.createSuccessResult(response.data);
47
+ }
48
+ catch (error) {
49
+ console.error(`Error in ${description}:`, error);
50
+ if (error instanceof ApiError) {
51
+ return this.createErrorResult(`Failed to ${description.toLowerCase()}: ${error.message}`);
52
+ }
53
+ return this.createErrorResult(`Failed to ${description.toLowerCase()}: ${error.message || error}`);
54
+ }
55
+ }
56
+ }