UNPKG

howtocook-mcp

Version:

MCP Server for howtocook recipe database - 炫一周好饭,拒绝拼好饭

310 lines (309 loc) 12.2 kB
#!/usr/bin/env node import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import { fetchRecipes, getAllCategories } from "./data/recipes.js"; import { registerGetAllRecipesTool } from "./tools/getAllRecipes.js"; import { registerGetRecipesByCategoryTool } from "./tools/getRecipesByCategory.js"; import { registerRecommendMealsTool } from "./tools/recommendMeals.js"; import { registerWhatToEatTool } from "./tools/whatToEat.js"; import { registerGetRecipeByIdTool } from "./tools/getRecipeById.js"; import { createServer } from 'http'; import { URL } from 'url'; import { randomUUID } from 'crypto'; // 全局会话存储 const sessions = new Map(); const SESSION_TIMEOUT = 30 * 60 * 1000; // 30分钟超时 const MAX_MESSAGE_HISTORY = 1000; // 最大消息历史记录 // 清理过期会话 function cleanupExpiredSessions() { const now = Date.now(); for (const [sessionId, session] of sessions.entries()) { if (now - session.lastActivity > SESSION_TIMEOUT) { sessions.delete(sessionId); console.log(`清理过期会话: ${sessionId}`); } } } // 定期清理过期会话 setInterval(cleanupExpiredSessions, 5 * 60 * 1000); // 每5分钟清理一次 // 创建或获取会话 function getOrCreateSession(sessionId) { if (sessionId && sessions.has(sessionId)) { const session = sessions.get(sessionId); session.lastActivity = Date.now(); return session; } const newSession = { id: sessionId || randomUUID(), lastEventId: 0, messages: [], createdAt: Date.now(), lastActivity: Date.now() }; sessions.set(newSession.id, newSession); console.log(`创建新会话: ${newSession.id}`); return newSession; } // 添加消息到会话 function addMessageToSession(session, data) { const eventId = ++session.lastEventId; const message = { id: eventId, timestamp: Date.now(), data }; session.messages.push(message); session.lastActivity = Date.now(); // 限制消息历史记录数量 if (session.messages.length > MAX_MESSAGE_HISTORY) { session.messages = session.messages.slice(-MAX_MESSAGE_HISTORY); } return eventId; } // 获取从指定事件ID之后的消息 function getMessagesAfter(session, afterEventId) { return session.messages.filter(msg => msg.id > afterEventId); } // 设置CORS和安全头 function setCorsHeaders(res, origin) { // 验证Origin头(安全措施) const allowedOrigins = ['http://localhost:3000', 'http://127.0.0.1:3000']; const requestOrigin = origin || ''; if (allowedOrigins.includes(requestOrigin) || requestOrigin.startsWith('http://localhost:')) { res.setHeader('Access-Control-Allow-Origin', requestOrigin); } else { res.setHeader('Access-Control-Allow-Origin', 'http://localhost:3000'); } res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS'); res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization, X-Session-ID, Last-Event-ID'); res.setHeader('Access-Control-Allow-Credentials', 'true'); res.setHeader('Access-Control-Max-Age', '86400'); } // 发送JSON响应 function sendJsonResponse(res, data, statusCode = 200) { res.statusCode = statusCode; res.setHeader('Content-Type', 'application/json'); res.end(JSON.stringify(data)); } // 发送流式响应 function sendStreamResponse(res, session, data) { const eventId = addMessageToSession(session, data); res.setHeader('Content-Type', 'text/plain; charset=utf-8'); res.setHeader('Cache-Control', 'no-cache'); res.setHeader('Connection', 'keep-alive'); res.setHeader('X-Session-ID', session.id); res.setHeader('X-Event-ID', eventId.toString()); // 发送数据 const jsonData = JSON.stringify(data); res.write(`event: message\n`); res.write(`id: ${eventId}\n`); res.write(`data: ${jsonData}\n\n`); } // 处理MCP消息 async function handleMcpMessage(server, message) { try { // 模拟MCP服务器处理消息 // 这里需要根据实际的MCP SDK API来实现 console.log('处理MCP消息:', message); // 返回响应 return { jsonrpc: '2.0', id: message.id, result: { content: [{ type: 'text', text: '消息处理成功' }] } }; } catch (error) { console.error('处理MCP消息时出错:', error); return { jsonrpc: '2.0', id: message.id, error: { code: -32603, message: 'Internal error', data: error instanceof Error ? error.message : String(error) } }; } } // 启动HTTP服务器 export async function startHttpServer() { // 获取菜谱数据 const recipes = await fetchRecipes(); if (recipes.length === 0) { console.error('无法获取菜谱数据,服务退出'); process.exit(1); } // 获取所有分类 const categories = getAllCategories(recipes); // 创建MCP服务器 const mcpServer = new McpServer({ name: "howtocook-mcp-http", version: "0.1.1", capabilities: { resources: {}, tools: {}, }, }); // 注册所有工具 registerGetAllRecipesTool(mcpServer, recipes); registerGetRecipesByCategoryTool(mcpServer, recipes, categories); registerRecommendMealsTool(mcpServer, recipes); registerWhatToEatTool(mcpServer, recipes); registerGetRecipeByIdTool(mcpServer, recipes); // 创建HTTP服务器 const server = createServer(async (req, res) => { const url = new URL(req.url || '/', `http://${req.headers.host}`); const pathname = url.pathname; const origin = req.headers.origin; // 设置CORS头 setCorsHeaders(res, origin); // 处理OPTIONS预检请求 if (req.method === 'OPTIONS') { res.statusCode = 200; res.end(); return; } try { // 健康检查端点 if (pathname === '/health' && req.method === 'GET') { sendJsonResponse(res, { status: 'healthy', timestamp: new Date().toISOString(), sessions: sessions.size }); return; } // 会话信息端点 if (pathname === '/session' && req.method === 'GET') { const sessionId = req.headers['x-session-id']; const session = getOrCreateSession(sessionId); sendJsonResponse(res, { sessionId: session.id, lastEventId: session.lastEventId, messageCount: session.messages.length, createdAt: new Date(session.createdAt).toISOString(), lastActivity: new Date(session.lastActivity).toISOString() }); return; } // 流式消息端点 if (pathname === '/stream' && req.method === 'POST') { const sessionId = req.headers['x-session-id']; const lastEventId = parseInt(req.headers['last-event-id'] || '0'); const session = getOrCreateSession(sessionId); // 读取请求体 let body = ''; req.on('data', chunk => { body += chunk.toString(); }); req.on('end', async () => { try { const message = JSON.parse(body); // 处理消息重传请求 if (message.type === 'replay' && lastEventId > 0) { const missedMessages = getMessagesAfter(session, lastEventId); res.setHeader('Content-Type', 'application/json'); res.setHeader('X-Session-ID', session.id); sendJsonResponse(res, { type: 'replay', messages: missedMessages, currentEventId: session.lastEventId }); return; } // 处理MCP消息 const response = await handleMcpMessage(mcpServer, message); sendStreamResponse(res, session, response); res.end(); } catch (error) { console.error('解析消息时出错:', error); sendJsonResponse(res, { error: 'Invalid JSON message', details: error instanceof Error ? error.message : String(error) }, 400); } }); return; } // 获取历史消息端点 if (pathname === '/messages' && req.method === 'GET') { const sessionId = req.headers['x-session-id']; const afterEventId = parseInt(url.searchParams.get('after') || '0'); if (!sessionId || !sessions.has(sessionId)) { sendJsonResponse(res, { error: 'Invalid session ID' }, 400); return; } const session = sessions.get(sessionId); const messages = getMessagesAfter(session, afterEventId); sendJsonResponse(res, { sessionId: session.id, messages, currentEventId: session.lastEventId }); return; } // 根路径 - 返回API信息 if (pathname === '/' && req.method === 'GET') { sendJsonResponse(res, { name: 'HowToCook MCP HTTP Server', version: '0.1.1', description: 'MCP Server for howtocook recipe database with Streamable HTTP transport', endpoints: { '/health': 'GET - 健康检查', '/session': 'GET - 获取或创建会话', '/stream': 'POST - 发送流式消息', '/messages': 'GET - 获取历史消息' }, transport: 'Streamable HTTP', features: [ '会话管理', '消息重传', '事件ID追踪', '自动会话清理' ] }); return; } // 404 - 未找到 sendJsonResponse(res, { error: 'Not Found' }, 404); } catch (error) { console.error('处理请求时出错:', error); sendJsonResponse(res, { error: 'Internal Server Error', details: error instanceof Error ? error.message : String(error) }, 500); } }); // 启动服务器 const port = parseInt(process.env.PORT || '3001'); const host = process.env.HOST || 'localhost'; server.listen(port, host, () => { console.log(`HowToCook MCP HTTP服务器启动成功`); console.log(`地址: http://${host}:${port}`); console.log(`健康检查: http://${host}:${port}/health`); console.log(`API信息: http://${host}:${port}/`); }); // 优雅关闭 process.on('SIGINT', () => { console.log('\n正在关闭服务器...'); server.close(() => { console.log('服务器已关闭'); process.exit(0); }); }); return server; } // 如果直接运行此文件,启动服务器 if (import.meta.url === `file://${process.argv[1]}`) { startHttpServer().catch(error => { console.error('启动服务器失败:', error); process.exit(1); }); }