import { Server } from '@modelcontextprotocol/sdk/server/index.js';
import {
CallToolRequestSchema,
ListToolsRequestSchema,
Tool,
} from '@modelcontextprotocol/sdk/types.js';
import { NextcloudCookbookClient } from './nextcloud-client.js';
import dotenv from 'dotenv';
import express from 'express';
// Load environment variables
dotenv.config();
// Validate required environment variables
const NEXTCLOUD_URL = process.env.NEXTCLOUD_URL;
const NEXTCLOUD_USERNAME = process.env.NEXTCLOUD_USERNAME;
const NEXTCLOUD_PASSWORD = process.env.NEXTCLOUD_PASSWORD;
if (!NEXTCLOUD_URL || !NEXTCLOUD_USERNAME || !NEXTCLOUD_PASSWORD) {
console.error('Error: Missing required environment variables');
console.error('Please set NEXTCLOUD_URL, NEXTCLOUD_USERNAME, and NEXTCLOUD_PASSWORD');
process.exit(1);
}
// Initialize Nextcloud client
const client = new NextcloudCookbookClient(
NEXTCLOUD_URL,
NEXTCLOUD_USERNAME,
NEXTCLOUD_PASSWORD
);
// Define MCP tools
const tools: Tool[] = [
{
name: 'list_recipes',
description: 'List all recipes in the cookbook. Returns recipe stubs with basic information.',
inputSchema: {
type: 'object',
properties: {},
required: [],
},
},
{
name: 'search_recipes',
description: 'Search recipes by keyword, tag, or category. Returns matching recipe stubs.',
inputSchema: {
type: 'object',
properties: {
query: {
type: 'string',
description: 'Search query (keywords, tags, or category)',
},
},
required: ['query'],
},
},
{
name: 'get_recipe',
description: 'Get detailed information about a specific recipe by its ID.',
inputSchema: {
type: 'object',
properties: {
id: {
type: 'number',
description: 'Recipe ID',
},
},
required: ['id'],
},
},
{
name: 'create_recipe',
description: 'Create a new recipe in the cookbook.',
inputSchema: {
type: 'object',
properties: {
name: {
type: 'string',
description: 'Recipe name',
},
description: {
type: 'string',
description: 'Recipe description',
},
url: {
type: 'string',
description: 'Source URL',
},
category: {
type: 'string',
description: 'Recipe category',
},
keywords: {
type: 'array',
items: { type: 'string' },
description: 'Recipe keywords/tags',
},
recipeYield: {
type: 'number',
description: 'Number of servings',
},
prepTime: {
type: 'string',
description: 'Preparation time (ISO 8601 duration format, e.g., PT30M)',
},
cookTime: {
type: 'string',
description: 'Cooking time (ISO 8601 duration format, e.g., PT1H)',
},
totalTime: {
type: 'string',
description: 'Total time (ISO 8601 duration format, e.g., PT1H30M)',
},
tools: {
type: 'array',
items: { type: 'string' },
description: 'Required tools',
},
recipeIngredient: {
type: 'array',
items: { type: 'string' },
description: 'List of ingredients',
},
recipeInstructions: {
type: 'array',
items: { type: 'string' },
description: 'Cooking instructions (one step per item)',
},
},
required: ['name'],
},
},
{
name: 'update_recipe',
description: 'Update an existing recipe by its ID.',
inputSchema: {
type: 'object',
properties: {
id: {
type: 'number',
description: 'Recipe ID to update',
},
name: {
type: 'string',
description: 'Recipe name',
},
description: {
type: 'string',
description: 'Recipe description',
},
url: {
type: 'string',
description: 'Source URL',
},
category: {
type: 'string',
description: 'Recipe category',
},
keywords: {
type: 'array',
items: { type: 'string' },
description: 'Recipe keywords/tags',
},
recipeYield: {
type: 'number',
description: 'Number of servings',
},
prepTime: {
type: 'string',
description: 'Preparation time (ISO 8601 duration format)',
},
cookTime: {
type: 'string',
description: 'Cooking time (ISO 8601 duration format)',
},
totalTime: {
type: 'string',
description: 'Total time (ISO 8601 duration format)',
},
tools: {
type: 'array',
items: { type: 'string' },
description: 'Required tools',
},
recipeIngredient: {
type: 'array',
items: { type: 'string' },
description: 'List of ingredients',
},
recipeInstructions: {
type: 'array',
items: { type: 'string' },
description: 'Cooking instructions',
},
},
required: ['id'],
},
},
{
name: 'delete_recipe',
description: 'Delete a recipe by its ID.',
inputSchema: {
type: 'object',
properties: {
id: {
type: 'number',
description: 'Recipe ID to delete',
},
},
required: ['id'],
},
},
{
name: 'get_recipe_image_url',
description: 'Get the URL for a recipe image.',
inputSchema: {
type: 'object',
properties: {
id: {
type: 'number',
description: 'Recipe ID',
},
size: {
type: 'string',
enum: ['full', 'thumb', 'thumb16'],
description: 'Image size (default: full)',
},
},
required: ['id'],
},
},
{
name: 'import_recipe',
description: 'Import a recipe from a URL. The system will parse the website and extract recipe information.',
inputSchema: {
type: 'object',
properties: {
url: {
type: 'string',
description: 'URL of the recipe to import',
},
},
required: ['url'],
},
},
{
name: 'list_categories',
description: 'List all recipe categories with recipe counts.',
inputSchema: {
type: 'object',
properties: {},
required: [],
},
},
{
name: 'list_keywords',
description: 'List all recipe keywords/tags.',
inputSchema: {
type: 'object',
properties: {},
required: [],
},
},
{
name: 'get_recipes_by_keyword',
description: 'Get all recipes tagged with a specific keyword.',
inputSchema: {
type: 'object',
properties: {
keyword: {
type: 'string',
description: 'Keyword/tag to search for',
},
},
required: ['keyword'],
},
},
];
// Create MCP server
const server = new Server(
{
name: 'nextcloud-cookbook-mcp',
version: '1.0.0',
},
{
capabilities: {
tools: {},
},
}
);
// Handle list_tools request
server.setRequestHandler(ListToolsRequestSchema, async () => {
return {
tools,
};
});
// Handle call_tool request
server.setRequestHandler(CallToolRequestSchema, async (request) => {
const { name, arguments: args } = request.params;
try {
switch (name) {
case 'list_recipes': {
const recipes = await client.listRecipes();
return {
content: [
{
type: 'text',
text: JSON.stringify(recipes, null, 2),
},
],
};
}
case 'search_recipes': {
const recipes = await client.searchRecipes(args?.query as string);
return {
content: [
{
type: 'text',
text: JSON.stringify(recipes, null, 2),
},
],
};
}
case 'get_recipe': {
const recipe = await client.getRecipe(args?.id as number);
return {
content: [
{
type: 'text',
text: JSON.stringify(recipe, null, 2),
},
],
};
}
case 'create_recipe': {
const recipe = await client.createRecipe(args as any);
return {
content: [
{
type: 'text',
text: JSON.stringify(recipe, null, 2),
},
],
};
}
case 'update_recipe': {
const { id, ...recipeData } = args as any;
const recipe = await client.updateRecipe(id, recipeData);
return {
content: [
{
type: 'text',
text: JSON.stringify(recipe, null, 2),
},
],
};
}
case 'delete_recipe': {
await client.deleteRecipe(args?.id as number);
return {
content: [
{
type: 'text',
text: `Recipe ${args?.id} deleted successfully`,
},
],
};
}
case 'get_recipe_image_url': {
const url = client.getRecipeImageUrl(
args?.id as number,
(args?.size as 'full' | 'thumb' | 'thumb16') || 'full'
);
return {
content: [
{
type: 'text',
text: url,
},
],
};
}
case 'import_recipe': {
const recipe = await client.importRecipe(args?.url as string);
return {
content: [
{
type: 'text',
text: JSON.stringify(recipe, null, 2),
},
],
};
}
case 'list_categories': {
const categories = await client.listCategories();
return {
content: [
{
type: 'text',
text: JSON.stringify(categories, null, 2),
},
],
};
}
case 'list_keywords': {
const keywords = await client.listKeywords();
return {
content: [
{
type: 'text',
text: JSON.stringify(keywords, null, 2),
},
],
};
}
case 'get_recipes_by_keyword': {
const recipes = await client.getRecipesByKeyword(args?.keyword as string);
return {
content: [
{
type: 'text',
text: JSON.stringify(recipes, null, 2),
},
],
};
}
default:
throw new Error(`Unknown tool: ${name}`);
}
} catch (error: any) {
return {
content: [
{
type: 'text',
text: `Error: ${error.message}`,
},
],
isError: true,
};
}
});
// Start HTTP server
async function main() {
const app = express();
const PORT = process.env.PORT || 3000;
// Middleware
app.use(express.json());
app.use(express.urlencoded({ extended: true }));
// CORS headers for local development
app.use((req, res, next) => {
res.header('Access-Control-Allow-Origin', '*');
res.header('Access-Control-Allow-Methods', 'GET, POST, OPTIONS');
res.header('Access-Control-Allow-Headers', 'Content-Type');
if (req.method === 'OPTIONS') {
return res.sendStatus(200);
}
next();
});
// Health check endpoint
app.get('/health', (req, res) => {
res.json({ status: 'ok', service: 'nextcloud-cookbook-mcp' });
});
// HTTP MCP endpoint
app.post('/mcp', async (req, res) => {
console.log('Received MCP request:', req.body?.method);
try {
const request = req.body;
// Handle initialize
if (request.method === 'initialize') {
return res.json({
jsonrpc: '2.0',
id: request.id,
result: {
protocolVersion: '2024-11-05',
capabilities: {
tools: {},
},
serverInfo: {
name: 'nextcloud-cookbook-mcp',
version: '1.0.0',
},
},
});
}
// Handle tools/list
if (request.method === 'tools/list') {
return res.json({
jsonrpc: '2.0',
id: request.id,
result: { tools },
});
}
// Handle tools/call
if (request.method === 'tools/call') {
const { name, arguments: args } = request.params;
try {
let result;
switch (name) {
case 'list_recipes':
result = await client.listRecipes();
break;
case 'search_recipes':
result = await client.searchRecipes(args?.query);
break;
case 'get_recipe':
result = await client.getRecipe(args?.id);
break;
case 'create_recipe':
result = await client.createRecipe(args);
break;
case 'update_recipe':
const { id, ...recipeData } = args;
result = await client.updateRecipe(id, recipeData);
break;
case 'delete_recipe':
await client.deleteRecipe(args?.id);
result = `Recipe ${args?.id} deleted successfully`;
break;
case 'get_recipe_image_url':
result = client.getRecipeImageUrl(args?.id, args?.size || 'full');
break;
case 'import_recipe':
result = await client.importRecipe(args?.url);
break;
case 'list_categories':
result = await client.listCategories();
break;
case 'list_keywords':
result = await client.listKeywords();
break;
case 'get_recipes_by_keyword':
result = await client.getRecipesByKeyword(args?.keyword);
break;
default:
throw new Error(`Unknown tool: ${name}`);
}
return res.json({
jsonrpc: '2.0',
id: request.id,
result: {
content: [
{
type: 'text',
text: typeof result === 'string' ? result : JSON.stringify(result, null, 2),
},
],
},
});
} catch (toolError: any) {
return res.json({
jsonrpc: '2.0',
id: request.id,
result: {
content: [
{
type: 'text',
text: `Error: ${toolError.message}`,
},
],
isError: true,
},
});
}
}
// Method not found
return res.json({
jsonrpc: '2.0',
id: request.id,
error: {
code: -32601,
message: 'Method not found',
},
});
} catch (error: any) {
console.error('Error handling MCP request:', error);
return res.status(500).json({
jsonrpc: '2.0',
id: req.body?.id,
error: {
code: -32603,
message: error.message || 'Internal error',
},
});
}
});
app.listen(PORT, () => {
console.log(`Nextcloud Cookbook MCP server running on http://localhost:${PORT}`);
console.log(`MCP endpoint: http://localhost:${PORT}/mcp`);
console.log(`Health check: http://localhost:${PORT}/health`);
});
}
main().catch((error) => {
console.error('Fatal error:', error);
process.exit(1);
});