mcp-grocy 2.2.0 → 2.6.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 (42) hide show
  1. package/CHANGELOG.md +45 -0
  2. package/README.md +125 -32
  3. package/build/resources/CHANGELOG.md +47 -0
  4. package/build/resources/DOCS.md +8 -3
  5. package/build/resources/README.md +125 -32
  6. package/build/resources/api-reference.md +88 -88
  7. package/build/resources/config.md +45 -17
  8. package/build/resources/examples.md +120 -221
  9. package/build/resources/installation.md +9 -14
  10. package/build/resources/response-format.md +29 -20
  11. package/build/version.js +2 -2
  12. package/package.json +50 -24
  13. package/build/api/client.js +0 -121
  14. package/build/config/index.js +0 -205
  15. package/build/main.js +0 -44
  16. package/build/server/http-server.js +0 -218
  17. package/build/server/mcp-server.js +0 -163
  18. package/build/server/resources.js +0 -60
  19. package/build/tools/base.js +0 -141
  20. package/build/tools/household/definitions.js +0 -157
  21. package/build/tools/household/handlers.js +0 -109
  22. package/build/tools/household/index.js +0 -25
  23. package/build/tools/index.js +0 -2
  24. package/build/tools/inventory/definitions.js +0 -379
  25. package/build/tools/inventory/handlers.js +0 -430
  26. package/build/tools/inventory/index.js +0 -32
  27. package/build/tools/module-loader.js +0 -151
  28. package/build/tools/recipes/definitions.js +0 -261
  29. package/build/tools/recipes/handlers.js +0 -471
  30. package/build/tools/recipes/index.js +0 -33
  31. package/build/tools/recipes/validations.js +0 -23
  32. package/build/tools/shopping/definitions.js +0 -71
  33. package/build/tools/shopping/handlers.js +0 -43
  34. package/build/tools/shopping/index.js +0 -14
  35. package/build/tools/system/definitions.js +0 -86
  36. package/build/tools/system/handlers.js +0 -94
  37. package/build/tools/system/index.js +0 -16
  38. package/build/tools/types.js +0 -1
  39. package/build/tools/validation-helpers.js +0 -36
  40. package/build/types/index.js +0 -62
  41. package/build/utils/errors.js +0 -138
  42. package/build/utils/logger.js +0 -141
@@ -1,218 +0,0 @@
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
- import { logger } from '../utils/logger.js';
9
- // HTTP Transport for MCP (Context7 style)
10
- export function startHttpServer(mcpServer, port = 8080) {
11
- return new Promise((resolve, reject) => {
12
- const app = express();
13
- // Enable JSON body parsing with increased limit
14
- app.use(express.json({
15
- limit: '10mb'
16
- }));
17
- // Enable CORS for all routes
18
- app.use(cors({
19
- origin: '*',
20
- methods: ['GET', 'POST', 'OPTIONS'],
21
- allowedHeaders: ['Origin', 'X-Requested-With', 'Content-Type', 'Accept', 'Mcp-Session-Id', 'Authorization'],
22
- exposedHeaders: ['Mcp-Session-Id', 'Content-Type'],
23
- optionsSuccessStatus: 200
24
- }));
25
- // Simple health check endpoint
26
- app.get('/', (_req, res) => {
27
- res.json({
28
- status: 'ok',
29
- service: SERVER_NAME,
30
- version: VERSION,
31
- message: 'MCP server is running',
32
- endpoints: {
33
- streamable: '/mcp',
34
- sse: '/mcp/sse',
35
- sseMessages: '/mcp/messages'
36
- }
37
- });
38
- });
39
- // Session management for transports
40
- const streamableTransports = {};
41
- const sseTransports = {};
42
- const sseServerInstances = {};
43
- // Simplified request logging
44
- app.use((req, _res, next) => {
45
- logger.server(`${req.method} ${req.path}`);
46
- next();
47
- });
48
- // Helper function to get server instance (shared or new)
49
- const getServerInstance = () => {
50
- if (typeof mcpServer === 'function') {
51
- return mcpServer(); // Create new instance
52
- }
53
- else {
54
- return mcpServer; // Use shared instance
55
- }
56
- };
57
- // Streamable HTTP endpoint (Context7 modern)
58
- app.post('/mcp', async (req, res) => {
59
- try {
60
- const clientSessionId = req.headers['mcp-session-id'];
61
- let transport = undefined;
62
- // Accept header check (can be done early)
63
- const accept = req.headers.accept || '';
64
- 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;
75
- }
76
- if (clientSessionId) {
77
- transport = streamableTransports[clientSessionId];
78
- if (!transport) {
79
- res.status(400).json({
80
- jsonrpc: '2.0',
81
- error: { code: -32001, message: `Invalid or expired session ID: ${clientSessionId}. Please re-initialize.` },
82
- id: req.body?.id || null
83
- });
84
- return;
85
- }
86
- }
87
- else {
88
- // No session ID provided by client, create new transport
89
- const newGeneratedSessionId = randomUUID();
90
- const newTransportInstance = new StreamableHTTPServerTransport({
91
- sessionIdGenerator: () => newGeneratedSessionId
92
- });
93
- transport = newTransportInstance;
94
- streamableTransports[newGeneratedSessionId] = transport;
95
- transport.onclose = () => {
96
- const closedSessionId = transport?.sessionId || newGeneratedSessionId;
97
- delete streamableTransports[closedSessionId];
98
- };
99
- const serverInstance = getServerInstance();
100
- await serverInstance.connect(transport); // Type assertion to work around SDK type issue
101
- }
102
- if (!transport) {
103
- 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 });
105
- return;
106
- }
107
- if (transport.sessionId) {
108
- res.setHeader('Mcp-Session-Id', transport.sessionId);
109
- }
110
- await transport.handleRequest(req, res, req.body);
111
- }
112
- catch (error) {
113
- logger.error('Failed to handle streamable HTTP request', 'server', { error });
114
- // Send error response if headers not sent yet
115
- if (!res.headersSent) {
116
- res.status(500).json({
117
- jsonrpc: '2.0',
118
- error: {
119
- code: -32000,
120
- message: `Internal server error: ${error instanceof Error ? error.message : String(error)}`
121
- },
122
- id: req.body?.id || null
123
- });
124
- }
125
- }
126
- });
127
- // SSE endpoint
128
- app.get('/mcp/sse', async (_req, res) => {
129
- try {
130
- // Set SSE headers before creating transport
131
- res.setHeader('Content-Type', 'text/event-stream');
132
- res.setHeader('Cache-Control', 'no-cache');
133
- res.setHeader('Connection', 'keep-alive');
134
- const transport = new SSEServerTransport('/mcp/messages', res);
135
- const sessionId = transport.sessionId;
136
- sseTransports[sessionId] = transport;
137
- // Handle connection cleanup
138
- const cleanup = () => {
139
- delete sseTransports[sessionId];
140
- delete sseServerInstances[sessionId];
141
- };
142
- res.on('close', cleanup);
143
- res.on('error', (err) => {
144
- logger.error(`SSE connection error for session ${sessionId}`, 'server', { error: err });
145
- cleanup();
146
- });
147
- // Create isolated server instance for this SSE connection to prevent response cross-talk
148
- const isolatedServer = getServerInstance();
149
- sseServerInstances[sessionId] = isolatedServer;
150
- // Connect transport to isolated MCP server (non-blocking)
151
- isolatedServer.connect(transport).catch((error) => {
152
- logger.error(`Failed to connect SSE transport for session ${sessionId}`, 'server', { error });
153
- cleanup();
154
- if (!res.headersSent) {
155
- res.status(500).end();
156
- }
157
- });
158
- // Send initial comment to keep connection alive
159
- res.write(': connected\n\n');
160
- }
161
- catch (error) {
162
- logger.error('Failed to handle SSE connection', 'server', { error });
163
- if (!res.headersSent) {
164
- res.status(500).send('Internal Server Error');
165
- }
166
- else {
167
- res.end();
168
- }
169
- }
170
- });
171
- // Message endpoint for SSE
172
- app.post('/mcp/messages', async (req, res) => {
173
- const sessionId = req.query.sessionId;
174
- if (!sessionId) {
175
- res.status(400).json({
176
- error: 'Missing sessionId parameter',
177
- status: 400
178
- });
179
- return;
180
- }
181
- const transport = sseTransports[sessionId];
182
- if (transport) {
183
- try {
184
- await transport.handlePostMessage(req, res, req.body);
185
- }
186
- catch (error) {
187
- logger.error(`Failed to handle SSE message for session ${sessionId}`, 'server', { error });
188
- res.status(500).json({
189
- error: `Internal server error: ${error instanceof Error ? error.message : String(error)}`,
190
- status: 500
191
- });
192
- }
193
- }
194
- else {
195
- res.status(404).json({
196
- error: `No active SSE connection found for session ID: ${sessionId}`,
197
- status: 404
198
- });
199
- }
200
- });
201
- // Create HTTP server with explicit error handling
202
- const server = http.createServer(app);
203
- server.on('error', (error) => {
204
- logger.error(`HTTP server error: ${error.message}`, 'server');
205
- reject(error);
206
- });
207
- // Start the server
208
- server.listen(port, () => {
209
- logger.server(`HTTP server listening on port ${port}`);
210
- logger.server(`Available endpoints:`);
211
- logger.server(` - Health check: http://localhost:${port}/`);
212
- logger.server(` - Streamable HTTP: http://localhost:${port}/mcp`);
213
- logger.server(` - SSE: http://localhost:${port}/mcp/sse`);
214
- logger.server(` - SSE Messages: http://localhost:${port}/mcp/messages`);
215
- resolve(server);
216
- });
217
- });
218
- }
@@ -1,163 +0,0 @@
1
- /**
2
- * Simplified MCP Server implementation
3
- * Reduced complexity and improved performance
4
- */
5
- import { Server } from '@modelcontextprotocol/sdk/server/index.js';
6
- import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
7
- import { CallToolRequestSchema, ErrorCode, ListToolsRequestSchema, ListResourcesRequestSchema, ReadResourceRequestSchema, McpError, } from '@modelcontextprotocol/sdk/types.js';
8
- import { z } from 'zod';
9
- import { VERSION, PACKAGE_NAME as SERVER_NAME } from '../version.js';
10
- import { createToolRegistry } from '../tools/index.js';
11
- import { config } from '../config/index.js';
12
- import { startHttpServer } from './http-server.js';
13
- import { ResourceHandler } from './resources.js';
14
- import { logger } from '../utils/logger.js';
15
- import { ErrorHandler } from '../utils/errors.js';
16
- export class GrocyMcpServer {
17
- server;
18
- enabledTools = new Set();
19
- toolSubConfigs = new Map();
20
- toolAckTokens = new Map();
21
- resourceHandler;
22
- toolRegistry;
23
- constructor(server, toolRegistry, resourceHandler) {
24
- this.server = server;
25
- this.toolRegistry = toolRegistry;
26
- this.resourceHandler = resourceHandler;
27
- this.parseToolConfiguration();
28
- this.setupHandlers();
29
- this.setupErrorHandling();
30
- }
31
- static async create() {
32
- // Initialize components
33
- const [toolRegistry, resourceHandler] = await Promise.all([
34
- createToolRegistry(),
35
- Promise.resolve(new ResourceHandler())
36
- ]);
37
- // Create server
38
- const server = new Server({
39
- name: SERVER_NAME,
40
- version: VERSION,
41
- serverUrl: "https://github.com/miguelangel-nubla/mcp-grocy",
42
- documentationUrl: "https://github.com/miguelangel-nubla/mcp-grocy/blob/main/README.md"
43
- }, {
44
- capabilities: {
45
- tools: {},
46
- resources: {},
47
- prompts: {}
48
- }
49
- });
50
- return new GrocyMcpServer(server, toolRegistry, resourceHandler);
51
- }
52
- parseToolConfiguration() {
53
- const { enabledTools, toolSubConfigs, toolAckTokens } = config.parseToolConfiguration();
54
- this.toolSubConfigs = toolSubConfigs;
55
- this.toolAckTokens = toolAckTokens;
56
- // Validate tool names
57
- const validToolNames = new Set(this.toolRegistry.getToolNames());
58
- if (enabledTools.size > 0) {
59
- const invalidTools = Array.from(enabledTools).filter(tool => !validToolNames.has(tool));
60
- if (invalidTools.length > 0) {
61
- const validNames = Array.from(validToolNames).sort().join(', ');
62
- logger.error(`Invalid tools: ${invalidTools.join(', ')}. Valid: ${validNames}`, 'CONFIG');
63
- process.exit(1);
64
- }
65
- this.enabledTools = enabledTools;
66
- logger.config(`Enabled tools: ${Array.from(enabledTools).join(', ')}`);
67
- }
68
- else {
69
- logger.warn('No tools enabled', 'CONFIG');
70
- }
71
- }
72
- setupHandlers() {
73
- // Initialize handler
74
- this.server.setRequestHandler(z.object({ method: z.literal('initialize'), params: z.any().optional() }), async (request) => {
75
- logger.debug('Initialize request', 'MCP');
76
- return {
77
- protocolVersion: request.params?.protocolVersion || '2024-11-05',
78
- capabilities: { tools: {}, resources: {}, prompts: {} },
79
- serverInfo: { name: SERVER_NAME, version: VERSION }
80
- };
81
- });
82
- // List tools
83
- this.server.setRequestHandler(ListToolsRequestSchema, async () => {
84
- const allTools = this.toolRegistry.getDefinitions();
85
- const filteredTools = allTools.filter(tool => this.enabledTools.has(tool.name));
86
- logger.config(`Available tools: ${filteredTools.map(t => t.name).join(', ')}`);
87
- return { tools: filteredTools };
88
- });
89
- // Call tool
90
- this.server.setRequestHandler(CallToolRequestSchema, async (request) => {
91
- const { name: toolName, arguments: args } = request.params;
92
- // Check if tool is enabled
93
- if (!this.enabledTools.has(toolName)) {
94
- throw new McpError(ErrorCode.InvalidRequest, `Tool '${toolName}' is not enabled. Enable it in your configuration.`);
95
- }
96
- // Get handler
97
- const handler = this.toolRegistry.getHandler(toolName);
98
- if (!handler) {
99
- throw new McpError(ErrorCode.MethodNotFound, `Unknown tool: ${toolName}`);
100
- }
101
- try {
102
- const subConfigs = this.toolSubConfigs.get(toolName);
103
- const result = await handler(args, subConfigs);
104
- // Automatically add ack_token to successful responses (at the beginning)
105
- if (!result.isError) {
106
- const ackToken = this.toolAckTokens.get(toolName);
107
- if (ackToken) {
108
- result.content.unshift({
109
- type: 'text',
110
- text: `Acknowledgment token: ${ackToken}`
111
- });
112
- }
113
- }
114
- return result;
115
- }
116
- catch (error) {
117
- ErrorHandler.logError(error, `tool: ${toolName}`);
118
- throw ErrorHandler.toMcpError(error, `${toolName} failed`);
119
- }
120
- });
121
- // Resources
122
- this.server.setRequestHandler(ListResourcesRequestSchema, async () => {
123
- return this.resourceHandler.listResources();
124
- });
125
- this.server.setRequestHandler(ReadResourceRequestSchema, async (request) => {
126
- return this.resourceHandler.readResource(request.params.uri);
127
- });
128
- }
129
- setupErrorHandling() {
130
- this.server.onerror = (error) => {
131
- logger.error('MCP protocol error', 'MCP', { error });
132
- };
133
- process.on('SIGINT', async () => {
134
- logger.info('Shutting down server...', 'SERVER');
135
- await this.server.close();
136
- process.exit(0);
137
- });
138
- }
139
- async start() {
140
- // Start STDIO transport
141
- const transport = new StdioServerTransport();
142
- await this.server.connect(transport);
143
- logger.info('MCP server running on stdio', 'SERVER');
144
- // Start HTTP/SSE if enabled
145
- if (config.server.enable_http_server) {
146
- try {
147
- logger.config(`Starting HTTP server on port ${config.server.http_server_port}`);
148
- const serverFactory = () => this.server;
149
- await startHttpServer(serverFactory, config.server.http_server_port);
150
- }
151
- catch (error) {
152
- logger.error('Failed to start HTTP server', 'SERVER', { error });
153
- logger.error('HTTP server is explicitly enabled but cannot start - exiting', 'SERVER');
154
- process.exit(1);
155
- }
156
- }
157
- }
158
- // Expose server for HTTP transport
159
- get serverInstance() {
160
- return this.server;
161
- }
162
- }
163
- export default GrocyMcpServer;
@@ -1,60 +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
- 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
- }
@@ -1,141 +0,0 @@
1
- /**
2
- * Simplified base tool handler
3
- */
4
- import apiClient from '../api/client.js';
5
- import { ErrorHandler, ValidationError } from '../utils/errors.js';
6
- import { logger } from '../utils/logger.js';
7
- export class BaseToolHandler {
8
- /**
9
- * Create a standardized success result
10
- */
11
- createSuccess(data, message) {
12
- return {
13
- content: [
14
- {
15
- type: 'text',
16
- text: message || 'Operation completed successfully'
17
- },
18
- {
19
- type: 'text',
20
- text: JSON.stringify(data, null, 2)
21
- }
22
- ]
23
- };
24
- }
25
- /**
26
- * Create a standardized error result
27
- */
28
- createError(message, details) {
29
- return {
30
- content: [
31
- {
32
- type: 'text',
33
- text: `Error: ${message}`
34
- },
35
- ...(details ? [{
36
- type: 'text',
37
- text: JSON.stringify(details, null, 2)
38
- }] : [])
39
- ],
40
- isError: true
41
- };
42
- }
43
- /**
44
- * Validate required parameters
45
- */
46
- validateRequired(params, required) {
47
- const missing = required.filter(field => {
48
- const value = params[field];
49
- return value === undefined || value === null || value === '';
50
- });
51
- if (missing.length > 0) {
52
- throw new ValidationError(`Missing required parameters: ${missing.join(', ')}`, 'parameter validation');
53
- }
54
- }
55
- /**
56
- * Safely make API calls with error handling
57
- */
58
- async apiCall(endpoint, method = 'GET', data, options) {
59
- return ErrorHandler.handleAsync(async () => {
60
- const response = await apiClient.request(endpoint, {
61
- method,
62
- body: data,
63
- queryParams: options?.queryParams || {}
64
- });
65
- return response.data;
66
- }, `API ${method} ${endpoint}`);
67
- }
68
- /**
69
- * Handle tool execution with standardized error handling
70
- */
71
- async executeToolHandler(handler) {
72
- try {
73
- return await handler();
74
- }
75
- catch (error) {
76
- ErrorHandler.logError(error, 'tool execution');
77
- if (error instanceof ValidationError) {
78
- return this.createError(error.message);
79
- }
80
- const message = error instanceof Error ? error.message : 'Internal error';
81
- return this.createError(`Tool execution failed: ${message}`);
82
- }
83
- }
84
- /**
85
- * Safe JSON formatting
86
- */
87
- safeStringify(data) {
88
- try {
89
- return JSON.stringify(data, null, 2);
90
- }
91
- catch (error) {
92
- logger.warn('Failed to stringify data', 'TOOLS', { error });
93
- return '[Unable to format data]';
94
- }
95
- }
96
- /**
97
- * Filter object fields based on allowlist
98
- */
99
- filterFields(objects, fields) {
100
- return objects.map(obj => {
101
- const filtered = {};
102
- fields.forEach(field => {
103
- if (field in obj) {
104
- filtered[field] = obj[field];
105
- }
106
- });
107
- return filtered;
108
- });
109
- }
110
- /**
111
- * Parse and validate array parameter
112
- */
113
- parseArrayParam(value, paramName) {
114
- if (!value) {
115
- throw new ValidationError(`${paramName} is required`);
116
- }
117
- if (!Array.isArray(value)) {
118
- throw new ValidationError(`${paramName} must be an array`);
119
- }
120
- if (value.length === 0) {
121
- throw new ValidationError(`${paramName} cannot be empty`);
122
- }
123
- return value.map(v => String(v));
124
- }
125
- /**
126
- * Parse and validate numeric parameter
127
- */
128
- parseNumberParam(value, paramName, required = true) {
129
- if (value === undefined || value === null) {
130
- if (required) {
131
- throw new ValidationError(`${paramName} is required`);
132
- }
133
- return undefined;
134
- }
135
- const num = Number(value);
136
- if (isNaN(num)) {
137
- throw new ValidationError(`${paramName} must be a valid number`);
138
- }
139
- return num;
140
- }
141
- }