mcp-grocy-api
Version:
Model Context Protocol (MCP) wrapper for grocy API
1,077 lines • 82 kB
JavaScript
#!/usr/bin/env node
// Load environment variables from .env file
import 'dotenv/config';
import { Server } from '@modelcontextprotocol/sdk/server/index.js';
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
import { CallToolRequestSchema, ErrorCode, InitializeRequestSchema, ListToolsRequestSchema, ListResourcesRequestSchema, ReadResourceRequestSchema, McpError, } from '@modelcontextprotocol/sdk/types.js';
import axios from 'axios';
import { VERSION, PACKAGE_NAME as SERVER_NAME } from './version.js';
import { startHttpServer } from './server/http-server.js';
import { z } from 'zod';
// Debug output to help identify version and naming issues
console.error(`Starting ${SERVER_NAME} server version ${VERSION}`);
if (!process.env.GROCY_BASE_URL) {
console.error('GROCY_BASE_URL environment variable is not set. Setting default to http://localhost:9283');
process.env.GROCY_BASE_URL = 'http://localhost:9283';
}
// Default response size limit: 10KB (10000 bytes)
const RESPONSE_SIZE_LIMIT = process.env.REST_RESPONSE_SIZE_LIMIT
? parseInt(process.env.REST_RESPONSE_SIZE_LIMIT, 10)
: 10000;
if (isNaN(RESPONSE_SIZE_LIMIT) || RESPONSE_SIZE_LIMIT <= 0) {
throw new Error('REST_RESPONSE_SIZE_LIMIT must be a positive number');
}
// Disabled authentication methods (but keeping references for code compatibility)
const AUTH_BASIC_USERNAME = undefined;
const AUTH_BASIC_PASSWORD = undefined;
const AUTH_BEARER = undefined;
// Hardcoded API key header name
const AUTH_APIKEY_HEADER_NAME = "GROCY-API-KEY";
const GROCY_APIKEY_VALUE = process.env.GROCY_APIKEY_VALUE;
const GROCY_ENABLE_SSL_VERIFY = process.env.GROCY_ENABLE_SSL_VERIFY !== 'false';
const hasBasicAuth = () => false; // Basic auth is disabled
const hasBearerAuth = () => false; // Bearer auth is disabled
const hasApiKeyAuth = () => !!GROCY_APIKEY_VALUE; // Only check if API key value exists
// Function to sanitize headers by removing sensitive values and non-approved headers
const sanitizeHeaders = (headers, isFromOptionalParams = false) => {
const sanitized = {};
for (const [key, value] of Object.entries(headers)) {
const lowerKey = key.toLowerCase();
// Always include headers from optional parameters
if (isFromOptionalParams) {
sanitized[key] = value;
continue;
}
// Handle authentication headers
if (lowerKey === 'authorization' ||
(AUTH_APIKEY_HEADER_NAME && lowerKey === AUTH_APIKEY_HEADER_NAME.toLowerCase())) {
sanitized[key] = '[REDACTED]';
continue;
}
// For headers from config/env
const customHeaders = getCustomHeaders();
if (key in customHeaders) {
// Show value only for headers that are in the approved list
const safeHeaders = new Set([
'accept',
'accept-language',
'content-type',
'user-agent',
'cache-control',
'if-match',
'if-none-match',
'if-modified-since',
'if-unmodified-since'
]);
const lowerKey = key.toLowerCase();
sanitized[key] = safeHeaders.has(lowerKey) ? value : '[REDACTED]';
}
}
return sanitized;
};
const normalizeBaseUrl = (url) => url.replace(/\/+$/, '');
const isValidEndpointArgs = (args) => {
if (typeof args !== 'object' || args === null)
return false;
if (!['GET', 'POST', 'PUT', 'DELETE'].includes(args.method))
return false;
if (typeof args.endpoint !== 'string')
return false;
if (args.headers !== undefined && (typeof args.headers !== 'object' || args.headers === null))
return false;
// Check if endpoint contains a full URL
const urlPattern = /^(https?:\/\/|www\.)/i;
if (urlPattern.test(args.endpoint)) {
throw new McpError(ErrorCode.InvalidParams, `Invalid endpoint format. Do not include full URLs. Instead of "${args.endpoint}", use just the path (e.g. "/api/users"). ` +
`Your path will be resolved to: ${process.env.GROCY_BASE_URL}${args.endpoint.replace(/^\/+|\/+$/g, '')}. ` +
`To test a different base URL, update the GROCY_BASE_URL environment variable.`);
}
return true;
};
// Collect custom headers from environment variables
const getCustomHeaders = () => {
const headers = {};
const headerPrefix = /^header_/i; // Case-insensitive match for 'header_'
for (const [key, value] of Object.entries(process.env)) {
if (headerPrefix.test(key) && value !== undefined) {
// Extract header name after the prefix, preserving case
const headerName = key.replace(headerPrefix, '');
headers[headerName] = value;
}
}
return headers;
};
class GrocyApiServer {
server;
axiosInstance;
constructor() {
this.setupServer();
}
async setupServer() {
this.server = new Server({
name: SERVER_NAME,
version: VERSION,
serverUrl: "https://github.com/saya6k/mcp-grocy-api",
documentationUrl: "https://github.com/saya6k/mcp-grocy-api/blob/main/README.md"
}, {
capabilities: {
tools: {}, // Empty object instead of boolean
resources: {}, // Empty object instead of boolean
prompts: {} // Empty object instead of boolean
},
});
const https = await import('https');
this.axiosInstance = axios.create({
baseURL: normalizeBaseUrl(process.env.GROCY_BASE_URL),
validateStatus: () => true, // Allow any status code
httpsAgent: GROCY_ENABLE_SSL_VERIFY ? undefined : new https.Agent({
rejectUnauthorized: false
})
});
this.setupToolHandlers();
this.setupResourceHandlers();
this.server.onerror = (error) => console.error('[MCP Error]', error);
process.on('SIGINT', async () => {
await this.server.close();
process.exit(0);
});
this.server.setRequestHandler(InitializeRequestSchema, async (request) => {
console.error('[DEBUG] Initialize handler called with request:', JSON.stringify(request));
return {
capabilities: {
tools: {},
resources: {},
prompts: {}
},
serverInfo: {
name: SERVER_NAME,
version: VERSION
}
};
});
// Custom schema for method: 'initialize' (for compatibility with clients that use this method name)
const PlainInitializeRequestSchema = z.object({
jsonrpc: z.literal('2.0'),
id: z.union([z.string(), z.number()]).optional(),
method: z.literal('initialize'),
params: z.any().optional()
});
this.server.setRequestHandler(PlainInitializeRequestSchema, async (request) => {
console.error('[DEBUG] Plain "initialize" handler called with request:', JSON.stringify(request));
// Extract protocolVersion from request.params or fallback to a default
let protocolVersion = '2024-11-05';
if (request.params && typeof request.params.protocolVersion === 'string') {
protocolVersion = request.params.protocolVersion;
}
return {
protocolVersion, // Always include protocolVersion as string
capabilities: {
tools: {},
resources: {},
prompts: {}
},
serverInfo: {
name: SERVER_NAME,
version: VERSION
}
};
});
}
makeApiRequest = async (endpoint, method = 'GET', body = null, additionalHeaders = {}, isSpecial = false) => {
// Enhanced endpoint path handling with better logging (using stderr to avoid breaking JSON responses)
console.error(`Original endpoint: ${endpoint}, Method: ${method}, isSpecial: ${isSpecial}`);
// Standardize path handling
let normalizedEndpoint = endpoint;
// Check if endpoint explicitly starts with /api/ - use it as is
if (endpoint.startsWith('/api/')) {
normalizedEndpoint = endpoint;
}
// Handle endpoints that start with api/ without leading slash
else if (endpoint.startsWith('api/')) {
normalizedEndpoint = `/${endpoint}`;
}
// Special handling for stock operations if isSpecial is true (legacy support)
else if (isSpecial && endpoint.includes('stock/products/')) {
if (endpoint.startsWith('/')) {
normalizedEndpoint = `/api${endpoint}`;
}
else {
normalizedEndpoint = `/api/${endpoint}`;
}
}
// All other endpoints - ensure they start with /api/
else {
if (endpoint.startsWith('/')) {
normalizedEndpoint = `/api${endpoint}`;
}
else {
normalizedEndpoint = `/api/${endpoint}`;
}
}
console.error(`Final endpoint URL: ${normalizeBaseUrl(process.env.GROCY_BASE_URL)}${normalizedEndpoint}`);
const config = {
method,
url: normalizedEndpoint,
headers: {
...additionalHeaders,
'Accept': 'application/json',
'Content-Type': 'application/json'
},
// Add timeout to prevent hanging connections
timeout: 30000 // 30 seconds timeout
};
if (['POST', 'PUT'].includes(method) && body) {
config.data = body;
console.error(`Request body: ${JSON.stringify(body)}`);
}
// Only apply API Key authentication
if (hasApiKeyAuth()) {
config.headers = {
...config.headers,
[AUTH_APIKEY_HEADER_NAME]: GROCY_APIKEY_VALUE
};
}
try {
console.error(`Making ${method} request to ${normalizeBaseUrl(process.env.GROCY_BASE_URL)}${normalizedEndpoint}`);
const response = await this.axiosInstance.request(config);
if (response.status >= 400) {
console.error(`API error (${response.status}): ${JSON.stringify(response.data)}`);
throw new Error(`API error (${response.status}): ${JSON.stringify(response.data)}`);
}
return response.data;
}
catch (error) {
// Improve error handling with more detailed error messages
if (axios.isAxiosError(error)) {
if (error.code === 'ECONNABORTED') {
console.error('API request timeout:', error.message);
throw new Error(`Connection timeout: The server took too long to respond. Please check your network connection or server availability.`);
}
else if (error.code === 'ECONNRESET' || error.message.includes('socket hang up')) {
console.error('API connection reset:', error.message);
throw new Error(`Connection reset: The server unexpectedly closed the connection. This might be due to server overload or network issues.`);
}
else if (!error.response) {
console.error('API network error:', error.message);
throw new Error(`Network error: Unable to reach the Grocy server. Please verify that the server is running and accessible.`);
}
}
console.error('API request error:', error);
throw error;
}
};
setupResourceHandlers() {
this.server.setRequestHandler(ListResourcesRequestSchema, async () => ({
resources: [
{
uri: `${SERVER_NAME}://examples`,
name: 'Grocy API Usage Examples',
description: 'Detailed examples of using the Grocy API',
mimeType: 'text/markdown'
},
{
uri: `${SERVER_NAME}://response-format`,
name: 'Response Format Documentation',
description: 'Documentation of the response format and structure',
mimeType: 'text/markdown'
},
{
uri: `${SERVER_NAME}://config`,
name: 'Configuration Documentation',
description: 'Documentation of all configuration options and how to use them',
mimeType: 'text/markdown'
}
]
}));
this.server.setRequestHandler(ReadResourceRequestSchema, async (request) => {
const uriPattern = new RegExp(`^${SERVER_NAME}://(.+)$`);
const match = request.params.uri.match(uriPattern);
if (!match) {
throw new McpError(ErrorCode.InvalidRequest, `Invalid resource URI format: ${request.params.uri}`);
}
const resource = match[1];
const fs = await import('fs');
const path = await import('path');
try {
const url = await import('url');
const __filename = url.fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
// In the built app, resources are in build/resources
// In development, they're in src/resources
const resourcePath = path.join(__dirname, 'resources', `${resource}.md`);
const content = await fs.promises.readFile(resourcePath, 'utf8');
return {
contents: [{
uri: request.params.uri,
mimeType: 'text/markdown',
text: content
}]
};
}
catch (error) {
throw new McpError(ErrorCode.InvalidRequest, `Resource not found: ${resource}`);
}
});
}
setupToolHandlers() {
this.server.setRequestHandler(ListToolsRequestSchema, async () => ({
tools: [
{
name: 'get_stock_volatile',
description: 'Get volatile stock information (due products, overdue products, expired products, missing products).',
inputSchema: {
type: 'object',
properties: {
includeDetails: {
type: 'boolean',
description: 'Whether to include additional details about each stock item'
}
},
required: [],
},
},
{
name: 'get_shopping_list',
description: 'Get your current shopping list items.',
inputSchema: {
type: 'object',
properties: {},
required: [],
},
},
{
name: 'get_chores',
description: 'Get all chores from your Grocy instance.',
inputSchema: {
type: 'object',
properties: {},
required: [],
},
},
{
name: 'get_tasks',
description: 'Get all tasks from your Grocy instance.',
inputSchema: {
type: 'object',
properties: {},
required: [],
},
},
{
name: 'get_locations',
description: 'Get all storage locations from your Grocy instance.',
inputSchema: {
type: 'object',
properties: {},
required: [],
},
},
{
name: 'get_shopping_locations',
description: 'Get all shopping locations (stores) from your Grocy instance.',
inputSchema: {
type: 'object',
properties: {},
required: [],
},
},
{
name: 'get_product_groups',
description: 'Get all product groups from your Grocy instance.',
inputSchema: {
type: 'object',
properties: {},
required: [],
},
},
{
name: 'get_quantity_units',
description: 'Get all quantity units from your Grocy instance.',
inputSchema: {
type: 'object',
properties: {},
required: [],
},
},
{
name: 'get_users',
description: 'Get all users from your Grocy instance.',
inputSchema: {
type: 'object',
properties: {},
required: [],
},
},
{
name: 'get_recipes_fulfillment',
description: 'Get fulfillment information for all recipes.',
inputSchema: {
type: 'object',
properties: {},
required: [],
},
},
{
name: 'add_recipe_products_to_shopping_list',
description: 'Add not fulfilled products of a recipe to the shopping list.',
inputSchema: {
type: 'object',
properties: {
recipeId: {
type: 'string',
description: 'ID of the recipe'
}
},
required: ['recipeId'],
},
},
{
name: 'undo_action',
description: 'Undo an action for different entity types (chores, batteries, tasks).',
inputSchema: {
type: 'object',
properties: {
entityType: {
type: 'string',
description: 'Type of entity (chores, batteries, tasks)',
enum: ['chores', 'batteries', 'tasks']
},
id: {
type: 'string',
description: 'ID of the execution, charge cycle, or task'
}
},
required: ['entityType', 'id'],
},
},
{
name: 'get_meal_plan',
description: 'Get your meal plan data from Grocy instance.',
inputSchema: {
type: 'object',
properties: {
startDate: {
type: 'string',
description: 'Optional start date in YYYY-MM-DD format. Defaults to today.'
},
days: {
type: 'number',
description: 'Optional number of days to retrieve. Defaults to 7.'
}
},
required: [],
},
},
{
name: 'get_products',
description: 'Get all products from your Grocy instance.',
inputSchema: {
type: 'object',
properties: {},
required: [],
},
},
{
name: 'get_recipes',
description: 'Get all recipes from your Grocy instance.',
inputSchema: {
type: 'object',
properties: {},
required: [],
},
},
{
name: 'get_stock',
description: 'Get current stock from your Grocy instance.',
inputSchema: {
type: 'object',
properties: {},
required: [],
},
},
{
name: 'get_batteries',
description: 'Get all batteries from your Grocy instance.',
inputSchema: {
type: 'object',
properties: {},
required: [],
},
},
{
name: 'get_equipment',
description: 'Get all equipment from your Grocy instance.',
inputSchema: {
type: 'object',
properties: {},
required: [],
},
},
{
name: 'add_shopping_list_item',
description: 'Add an item to your shopping list.',
inputSchema: {
type: 'object',
properties: {
productId: {
type: 'number',
description: 'ID of the product to add'
},
amount: {
type: 'number',
description: 'Amount to add (default: 1)',
default: 1
},
shoppingListId: {
type: 'number',
description: 'ID of the shopping list to add to (default: 1)',
default: 1
},
note: {
type: 'string',
description: 'Optional note for the shopping list item'
}
},
required: ['productId'],
},
},
{
name: 'add_recipe_to_meal_plan',
description: 'Add a recipe to the meal plan.',
inputSchema: {
type: 'object',
properties: {
recipeId: {
type: 'number',
description: 'ID of the recipe to add'
},
day: {
type: 'string',
description: 'Day to add the recipe to in YYYY-MM-DD format (default: today)'
},
servings: {
type: 'number',
description: 'Number of servings (default: 1)',
default: 1
}
// Removed 'section' parameter as it's not supported by the Grocy API
},
required: ['recipeId'],
},
},
{
name: 'inventory_product',
description: 'Track a product inventory (set current stock amount).',
inputSchema: {
type: 'object',
properties: {
productId: {
type: 'number',
description: 'ID of the product to inventory'
},
newAmount: {
type: 'number',
description: 'The new total amount in stock'
},
bestBeforeDate: {
type: 'string',
description: 'Best before date in YYYY-MM-DD format (default: today + 1 year)'
},
locationId: {
type: 'number',
description: 'ID of the location (optional)'
},
note: {
type: 'string',
description: 'Optional note'
}
},
required: ['productId', 'newAmount'],
},
},
{
name: 'create_recipe',
description: 'Create a new recipe in your Grocy instance.',
inputSchema: {
type: 'object',
properties: {
name: {
type: 'string',
description: 'Name of the recipe'
},
description: {
type: 'string',
description: 'Description of the recipe'
},
servings: {
type: 'number',
description: 'Number of servings (default: 1)',
default: 1
},
baseServingAmount: {
type: 'number',
description: 'Base serving amount (default: 1)',
default: 1
},
desiredServings: {
type: 'number',
description: 'Number of desired servings (default: 1)',
default: 1
}
},
required: ['name'],
},
},
{
name: 'purchase_product',
description: 'Track a product purchase in your Grocy instance.',
inputSchema: {
type: 'object',
properties: {
productId: {
type: 'number',
description: 'ID of the product to purchase'
},
amount: {
type: 'number',
description: 'Amount to purchase (default: 1)',
default: 1
},
bestBeforeDate: {
type: 'string',
description: 'Best before date in YYYY-MM-DD format (default: today + 1 year)'
},
price: {
type: 'number',
description: 'Price of the purchase (optional)'
},
storeId: {
type: 'number',
description: 'ID of the store where purchased (optional)'
},
locationId: {
type: 'number',
description: 'ID of the storage location (optional)'
},
note: {
type: 'string',
description: 'Optional note'
}
},
required: ['productId'],
},
},
{
name: 'consume_product',
description: 'Track consumption of a product in your Grocy instance.',
inputSchema: {
type: 'object',
properties: {
productId: {
type: 'number',
description: 'ID of the product to consume'
},
amount: {
type: 'number',
description: 'Amount to consume (default: 1)',
default: 1
},
spoiled: {
type: 'boolean',
description: 'Whether the product is spoiled (default: false)',
default: false
},
recipeId: {
type: 'number',
description: 'ID of the recipe if consuming for a recipe (optional)'
},
locationId: {
type: 'number',
description: 'ID of the location to consume from (optional)'
},
note: {
type: 'string',
description: 'Optional note'
}
},
required: ['productId'],
},
},
{
name: 'track_chore_execution',
description: 'Track execution of a chore in your Grocy instance.',
inputSchema: {
type: 'object',
properties: {
choreId: {
type: 'number',
description: 'ID of the chore that was executed'
},
executedBy: {
type: 'number',
description: 'ID of the user who executed the chore (optional)'
},
tracked_time: {
type: 'string',
description: 'When the chore was executed in YYYY-MM-DD HH:MM:SS format (default: now)'
},
note: {
type: 'string',
description: 'Optional note'
}
},
required: ['choreId'],
},
},
{
name: 'complete_task',
description: 'Mark a task as completed in your Grocy instance.',
inputSchema: {
type: 'object',
properties: {
taskId: {
type: 'number',
description: 'ID of the task to complete'
},
note: {
type: 'string',
description: 'Optional note'
}
},
required: ['taskId'],
},
},
{
name: 'transfer_product',
description: 'Transfer a product from one location to another in your Grocy instance.',
inputSchema: {
type: 'object',
properties: {
productId: {
type: 'number',
description: 'ID of the product to transfer'
},
amount: {
type: 'number',
description: 'Amount to transfer (default: 1)',
default: 1
},
locationIdFrom: {
type: 'number',
description: 'ID of the source location (required)'
},
locationIdTo: {
type: 'number',
description: 'ID of the destination location (required)'
},
note: {
type: 'string',
description: 'Optional note for this transfer'
}
},
required: ['productId', 'locationIdFrom', 'locationIdTo'],
},
},
{
name: 'get_price_history',
description: 'Get the price history of a product from your Grocy instance.',
inputSchema: {
type: 'object',
properties: {
productId: {
type: 'number',
description: 'ID of the product to get price history for'
}
},
required: ['productId'],
},
},
{
name: 'open_product',
description: 'Mark a product as opened in your Grocy instance.',
inputSchema: {
type: 'object',
properties: {
productId: {
type: 'number',
description: 'ID of the product to mark as opened (alternative to stockEntryId)'
},
stockEntryId: {
type: 'number',
description: 'ID of the specific stock entry to mark as opened (more precise than productId)'
},
amount: {
type: 'number',
description: 'Amount to mark as opened (default: 1)',
default: 1
},
note: {
type: 'string',
description: 'Optional note'
}
}
},
},
{
name: 'get_stock_by_location',
description: 'Get all stock from a specific location in your Grocy instance.',
inputSchema: {
type: 'object',
properties: {
locationId: {
type: 'number',
description: 'ID of the location to get stock for'
}
},
required: ['locationId'],
},
},
{
name: 'consume_recipe',
description: 'Consume all ingredients needed for a recipe in your Grocy instance.',
inputSchema: {
type: 'object',
properties: {
recipeId: {
type: 'number',
description: 'ID of the recipe to consume'
},
servings: {
type: 'number',
description: 'Number of servings to consume (default: 1)',
default: 1
}
},
required: ['recipeId'],
},
},
{
name: 'charge_battery',
description: 'Track charging of a battery in your Grocy instance.',
inputSchema: {
type: 'object',
properties: {
batteryId: {
type: 'number',
description: 'ID of the battery that was charged'
},
trackedTime: {
type: 'string',
description: 'When the battery was charged in YYYY-MM-DD HH:MM:SS format (default: now)'
},
note: {
type: 'string',
description: 'Optional note'
}
},
required: ['batteryId'],
},
},
{
name: 'add_missing_products_to_shopping_list',
description: 'Add all missing products for a recipe to your shopping list.',
inputSchema: {
type: 'object',
properties: {
recipeId: {
type: 'number',
description: 'ID of the recipe to add missing products for'
},
servings: {
type: 'number',
description: 'Number of servings (default: 1)',
default: 1
},
shoppingListId: {
type: 'number',
description: 'ID of the shopping list to add to (default: 1)',
default: 1
}
},
required: ['recipeId'],
},
},
{
name: 'get_recipe_fulfillment',
description: 'Get stock fulfillment information for a recipe.',
inputSchema: {
type: 'object',
properties: {
recipeId: {
type: 'number',
description: 'ID of the recipe to check fulfillment for'
},
servings: {
type: 'number',
description: 'Number of servings (default: 1)',
default: 1
}
},
required: ['recipeId'],
},
},
{
name: 'remove_shopping_list_item',
description: 'Remove an item from your shopping list.',
inputSchema: {
type: 'object',
properties: {
shoppingListItemId: {
type: 'number',
description: 'ID of the shopping list item to remove'
}
},
required: ['shoppingListItemId'],
},
},
{
name: 'call_grocy_api',
description: 'Call a specific Grocy API endpoint with custom parameters.',
inputSchema: {
type: 'object',
properties: {
endpoint: {
type: 'string',
description: 'Grocy API endpoint to call (e.g., "objects/products"). Do not include /api/ prefix.'
},
method: {
type: 'string',
enum: ['GET', 'POST', 'PUT', 'DELETE'],
description: 'HTTP method to use',
default: 'GET'
},
body: {
type: 'object',
description: 'Optional request body for POST/PUT requests'
}
},
required: ['endpoint'],
},
},
{
name: 'test_request',
description: `Test a REST API endpoint and get detailed response information. Base URL: ${normalizeBaseUrl(process.env.GROCY_BASE_URL)} | SSL Verification ${GROCY_ENABLE_SSL_VERIFY ? 'enabled' : 'disabled'} (see config resource for SSL settings) | Authentication: ${hasApiKeyAuth() ?
`API Key using header: GROCY-API-KEY` :
'No authentication configured'}`,
inputSchema: {
type: 'object',
properties: {
method: {
type: 'string',
enum: ['GET', 'POST', 'PUT', 'DELETE'],
description: 'HTTP method to use',
},
endpoint: {
type: 'string',
description: `Endpoint path (e.g. "/users"). Do not include full URLs - only the path.`,
},
body: {
type: 'object',
description: 'Optional request body for POST/PUT requests',
},
headers: {
type: 'object',
description: 'Optional request headers for one-time use.',
additionalProperties: {
type: 'string'
}
}
},
required: ['method', 'endpoint'],
},
},
{
name: 'get_product_entries',
description: 'Get all stock entries for a specific product in your Grocy instance.',
inputSchema: {
type: 'object',
properties: {
productId: {
type: 'number',
description: 'ID of the product to get stock entries for'
}
},
required: ['productId'],
},
},
],
}));
this.server.setRequestHandler(CallToolRequestSchema, async (request) => {
switch (request.params.name) {
case 'test_request':
return await this.handleTestRequest(request);
case 'call_grocy_api':
return await this.handleCustomGrocyApiCall(request);
case 'open_product':
return await this.handleOpenProduct(request);
case 'get_stock_volatile':
return await this.handleGrocyApiCall('/api/stock/volatile', 'Get volatile stock information', request.params.arguments?.includeDetails ? { query_params: { include_details: "true" } } : {});
case 'get_shopping_list':
return await this.handleGrocyApiCall('/objects/shopping_list', 'Get shopping list items');
case 'get_chores':
return await this.handleGrocyApiCall('/objects/chores', 'Get all chores');
case 'get_tasks':
return await this.handleGrocyApiCall('/objects/tasks', 'Get all tasks');
case 'get_locations':
return await this.handleGrocyApiCall('/objects/locations', 'Get all storage locations');
case 'get_shopping_locations':
return await this.handleGrocyApiCall('/objects/shopping_locations', 'Get all shopping locations');
case 'get_product_groups':
return await this.handleGrocyApiCall('/objects/product_groups', 'Get all product groups');
case 'get_quantity_units':
return await this.handleGrocyApiCall('/objects/quantity_units', 'Get all quantity units');
case 'get_users':
return await this.handleGrocyApiCall('/users', 'Get all users');
case 'get_recipe_fulfillment':
return await this.handleGetRecipeFulfillment(request);
case 'get_recipes_fulfillment':
return await this.handleGetRecipesFulfillment(request);
case 'add_recipe_products_to_shopping_list':
return await this.handleAddRecipeProductsToShoppingList(request);
case 'undo_action':
return await this.handleUndoAction(request);
case 'get_meal_plan':
const startDate = request.params.arguments?.startDate || new Date().toISOString().split('T')[0];
const days = request.params.arguments?.days || 7;
return await this.handleGrocyApiCall(`/objects/meal_plan?query%5B%5D=day%3E%3D${startDate}&limit=${days}`, 'Get meal plan');
case 'get_products':
return await this.handleGrocyApiCall('/objects/products', 'Get all products');
case 'get_recipes':
return await this.handleGrocyApiCall('/objects/recipes', 'Get all recipes');
case 'get_stock':
return await this.handleGrocyApiCall('/stock', 'Get current stock');
case 'get_batteries':
return await this.handleGrocyApiCall('/objects/batteries', 'Get all batteries');
case 'get_equipment':
return await this.handleGrocyApiCall('/objects/equipment', 'Get all equipment');
case 'add_shopping_list_item':
const { productId: shoppingProductId, amount: shoppingAmount = 1, shoppingListId = 1, note: shoppingNote = '' } = request.params.arguments || {};
if (!shoppingProductId) {
throw new McpError(ErrorCode.InvalidParams, 'productId is required');
}
const shoppingListItemData = {
product_id: shoppingProductId,
amount: shoppingAmount,
shopping_list_id: shoppingListId,
note: shoppingNote
};
return await this.handleGrocyApiCall('/objects/shopping_list', 'Add shopping list item', {
method: 'POST',
body: shoppingListItemData
});
case 'add_recipe_to_meal_plan':
const { recipeId: mealPlanRecipeId, day = new Date().toISOString().split('T')[0], servings: mealPlanServings = 1 } = request.params.arguments || {};
if (!mealPlanRecipeId) {
throw new McpError(ErrorCode.InvalidParams, 'recipeId is required');
}
const mealPlanData = {
day: day,
recipe_id: mealPlanRecipeId,
recipe_servings: mealPlanServings,
type: "recipe" // Add the type field to specify this is a recipe entry
};
return await this.handleGrocyApiCall('/objects/meal_plan', 'Add recipe to meal plan', {
method: 'POST',
body: mealPlanData
});
case 'inventory_prod