howtocook-mcp
Version:
MCP Server for howtocook recipe database - 炫一周好饭,拒绝拼好饭
310 lines (309 loc) • 12.2 kB
JavaScript
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);
});
}