feat: 新增 HTTP MCP Server,让 AI Agent 接入选菜

新增 packages/mcp,基于 Streamable HTTP 暴露菜谱库能力,复用 packages/cook 的选菜算法:recommend_recipes 按现有食材推荐,search_recipes 条件检索,check_incompatible 食材相克,get_recipe 菜谱详情。容器内与 nginx 共存,映射 2230 端口供 Agent 连接。
This commit is contained in:
toor
2026-10-22 16:55:29 +08:00
committed by trae-solo-agent
parent 8ff7b879b9
commit 79baab6080
12 changed files with 919 additions and 1 deletions
+14
View File
@@ -0,0 +1,14 @@
import { defineBuildConfig } from 'unbuild'
export default defineBuildConfig({
entries: [
'src/index',
'src/server',
'src/catalog',
],
clean: true,
declaration: false,
rollup: {
emitCJS: false,
},
})
+25
View File
@@ -0,0 +1,25 @@
{
"name": "@cook/mcp",
"type": "module",
"version": "0.1.0",
"private": true,
"description": "食用手册 MCP Server —— 以 HTTP 暴露菜谱检索能力,供 AI Agent 选菜",
"license": "MIT",
"scripts": {
"build": "unbuild",
"dev": "tsx src/index.ts",
"start": "node dist/index.mjs"
},
"dependencies": {
"@modelcontextprotocol/sdk": "^1.32.1",
"@yunyoujun/cook": "workspace:*",
"express": "^5.2.1",
"zod": "^4.6.7"
},
"devDependencies": {
"@types/express": "^5.0.6",
"@types/node": "^25.9.5",
"tsx": "^4.23.12",
"unbuild": "^3.6.1"
}
}
+132
View File
@@ -0,0 +1,132 @@
import type {
Difficulty,
IncompatibleRule,
RecipeItem,
RecipeMatch,
RecommendationMode,
SearchOptions,
SearchResult,
} from '@yunyoujun/cook'
import fs from 'node:fs'
import path from 'node:path'
import process from 'node:process'
import { fileURLToPath } from 'node:url'
import {
parseIncompatibleFoodsCsv,
parseRecipeCsv,
searchRecipes,
uniqueStuff,
} from '@yunyoujun/cook'
import { createRecipeRecommender } from '@yunyoujun/cook/recommend'
/** 食材、厨具、标签在 CSV 里用中文顿号分隔,Agent 传参时也可能用逗号 */
const SEP_RE = /[,,、;;\n]/
export interface RecommendInput {
stuff: string[]
pantry?: string[]
tool?: string
keyword?: string
mode?: RecommendationMode
avoid?: string[]
methodGroups?: string[]
cuisines?: string[]
noSpicy?: boolean
difficulty?: Difficulty[]
limit?: number
}
export interface RecipeDetail extends RecipeItem {
videoUrl?: string
}
/**
* 数据目录:容器里由 COOK_DATA_DIR 指定,本地开发回退到 packages/data
*/
function resolveDataDir(): string {
const moduleDir = path.dirname(fileURLToPath(import.meta.url))
const candidates = [process.env.COOK_DATA_DIR, path.resolve(moduleDir, '../../data')]
for (const dir of candidates) {
if (dir && fs.existsSync(path.join(dir, 'recipe.csv')))
return dir
}
throw new Error(`找不到菜谱数据目录,已尝试:${candidates.filter(Boolean).join('、')}。请设置 COOK_DATA_DIR。`)
}
export interface Catalog {
/** 菜谱总数,让 Agent 对数据规模有预期 */
recipeCount: number
recommend: (input: RecommendInput) => RecipeMatch[]
search: (options: SearchOptions) => SearchResult
checkIncompatible: (foods: string[]) => IncompatibleRule[]
getRecipe: (idOrName: string | number) => RecipeDetail | undefined
}
let cached: Catalog | undefined
/** 菜谱数据只在启动时解析一次,之后所有工具调用复用同一份索引 */
export function loadCatalog(): Catalog {
if (cached)
return cached
const dataDir = resolveDataDir()
const recipes = parseRecipeCsv(fs.readFileSync(path.join(dataDir, 'recipe.csv'), 'utf8'))
if (!recipes.length)
throw new Error('菜谱数据为空,请检查 recipe.csv')
let incompatible: IncompatibleRule[] = []
const incompatibleFile = path.join(dataDir, 'incompatible-foods.csv')
if (fs.existsSync(incompatibleFile))
incompatible = parseIncompatibleFoodsCsv(fs.readFileSync(incompatibleFile, 'utf8'))
const recommend = createRecipeRecommender(recipes)
const byId = new Map(recipes.filter(r => r.id !== undefined).map(r => [r.id!, r]))
const byName = new Map(recipes.map(r => [r.name, r]))
cached = {
recipeCount: recipes.length,
recommend(input) {
const matches = recommend({
stuff: uniqueStuff(input.stuff.flatMap(item => item.split(SEP_RE))),
pantry: input.pantry?.flatMap(item => item.split(SEP_RE)),
tool: input.tool,
keyword: input.keyword,
mode: input.mode,
avoid: input.avoid?.flatMap(item => item.split(SEP_RE)),
methodGroups: input.methodGroups,
cuisines: input.cuisines,
noSpicy: input.noSpicy,
difficulty: input.difficulty,
})
return input.limit ? matches.slice(0, input.limit) : matches
},
search(options) {
return searchRecipes(recipes, incompatible, {
stuff: options.stuff?.flatMap(item => item.split(SEP_RE)),
tool: options.tool,
difficulty: options.difficulty,
tag: options.tag,
method: options.method,
limit: options.limit,
})
},
checkIncompatible(foods) {
const selected = new Set(uniqueStuff(foods.flatMap(item => item.split(SEP_RE))))
return incompatible.filter(rule => selected.has(rule.foodA) && selected.has(rule.foodB))
},
getRecipe(idOrName) {
const recipe = typeof idOrName === 'number'
? byId.get(idOrName)
: byId.get(Number(idOrName.trim())) ?? byName.get(idOrName.trim())
if (!recipe)
return undefined
return { ...recipe, videoUrl: recipe.bv ? `https://www.bilibili.com/video/${recipe.bv}` : undefined }
},
}
return cached
}
+52
View File
@@ -0,0 +1,52 @@
import process from 'node:process'
import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js'
import express from 'express'
import { loadCatalog } from './catalog'
import { createServer, logFilterHints } from './server'
const PORT = Number(process.env.COOK_MCP_PORT ?? 2230)
const HOST = process.env.COOK_MCP_HOST ?? '0.0.0.0'
const PATHNAME = process.env.COOK_MCP_PATH ?? '/mcp'
const catalog = loadCatalog()
logFilterHints(catalog.recipeCount)
const app = express()
app.use(express.json({ limit: '1mb' }))
app.get('/health', (_req, res) => {
res.json({ status: 'ok', recipes: catalog.recipeCount })
})
/**
* 无状态模式:每个请求新建一套 server + transport,不保留会话,
* 这样 Agent 端不需要管理 session id,负载均衡下也能随意扩缩。
*/
app.post(PATHNAME, async (req, res) => {
const server = createServer()
const transport = new StreamableHTTPServerTransport({ sessionIdGenerator: undefined })
res.on('close', () => {
transport.close()
server.close()
})
try {
await server.connect(transport)
await transport.handleRequest(req, res, req.body)
}
catch (error) {
process.stderr.write(`[cook-mcp] 请求处理失败: ${(error as Error).stack ?? error}\n`)
if (!res.headersSent)
res.status(500).json({ jsonrpc: '2.0', error: { code: -32603, message: 'Internal server error' }, id: null })
}
})
// StreamableHTTP 的 GET 用于开启 SSE 流,客户端会探测;无状态模式下直接告知不支持
app.get(PATHNAME, (_req, res) => {
res.status(405).json({ error: 'SSE 监听未启用,请使用 POST 发起 JSON-RPC 请求' })
})
app.listen(PORT, HOST, () => {
process.stderr.write(`[cook-mcp] HTTP MCP 已启动:http://${HOST}:${PORT}${PATHNAME}\n`)
})
+186
View File
@@ -0,0 +1,186 @@
import type { RecipeMatch } from '@yunyoujun/cook'
import process from 'node:process'
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
import { CUISINE_GROUPS, DIFFICULTY_LEVELS, METHOD_GROUPS } from '@yunyoujun/cook/recommend'
import { z } from 'zod'
import { loadCatalog } from './catalog'
/** 反复调用时按名称查找会变慢,超过这个数量就建议 Agent 改用关键词筛选 */
const MAX_RECIPES = 30
/**
* Agent 常把列表参数写成「牛肉,土豆」这样的字符串,
* 这里统一收敛成数组,省得在 description 里反复强调格式。
*/
const ingredientList = z.union([
z.array(z.string()),
z.string().transform(v => v.split(/[,,、;;\n]/).map(s => s.trim()).filter(Boolean)),
])
function toText(data: unknown): string {
return JSON.stringify(data, null, 2)
}
function describeMatch(match: RecipeMatch) {
const { recipe } = match
return {
id: recipe.id,
菜名: recipe.name,
所需食材: recipe.stuff,
你已有的: match.matchedStuff,
厨房常备已满足: match.pantryStuff,
还需购买: match.missingStuff,
大杂烩可随意加: match.optionalStuff,
难度: recipe.difficulty || '未标注',
标签: recipe.tags,
做法: recipe.methods,
所需厨具: recipe.tools,
视频教程: recipe.bv ? `https://www.bilibili.com/video/${recipe.bv}` : '无',
}
}
export function createServer(): McpServer {
const catalog = loadCatalog()
const server = new McpServer(
{ name: 'cook-recipe', version: '1.0.0' },
{ instructions: `食用手册菜谱库,共 ${catalog.recipeCount} 道菜。回答「今天吃什么」类问题时先调用 recommend_recipes,按用户手边食材推荐;需要精确查找或展开某道菜的完整信息时再调用 get_recipe。` },
)
server.registerTool(
'recommend_recipes',
{
title: '按现有食材推荐菜谱',
description: '根据用户手头已有的食材推荐能做的菜。与「食用手册」App 的选菜算法一致:优先少缺食材的菜,并给出匹配/缺少的食材明细。stuff 必填,至少给一种食材。',
inputSchema: {
stuff: ingredientList.describe('手头已有的食材,如 ["番茄","鸡蛋"],也接受 "番茄,鸡蛋"'),
pantry: ingredientList.optional().describe('厨房常备调料,如 ["油","盐"];常备品不算「缺」,可让结果更准'),
tool: z.string().optional().describe('只能用这个厨具做,如 "电饭煲"、"烤箱"、"空气炸锅"、"微波炉"、"一口大锅"'),
keyword: z.string().optional().describe('菜名关键词,用于在推荐结果里进一步收窄,如 "煲饭"、"鸡"'),
mode: z.enum(['loose', 'strict', 'survival']).optional().describe('loose=允许缺料(默认);strict=必须用上所有已选食材;survival=只推荐食材完全齐备的'),
avoid: ingredientList.optional().describe('忌口食材,用到就排除(如 ["香菜"])'),
methodGroups: z.array(z.enum(['炒菜', '汤羹', '炖煮', '蒸', '炸煎', '烤', '凉拌', '微波', '火锅'])).optional().describe('做法筛选,满足任意一个即可'),
cuisines: z.array(z.enum(['川菜', '湘菜', '粤菜', '苏菜', '东北菜', '西北菜', '日式', '意式', '法式', '西式', '国宴菜'])).optional().describe('菜系筛选,满足任意一个即可'),
noSpicy: z.boolean().optional().describe('不吃辣:排除带「辣」标签的菜'),
difficulty: z.array(z.enum(['简单', '普通', '困难'])).optional().describe('难度筛选,满足任意一个即可'),
limit: z.number().int().min(1).max(MAX_RECIPES).optional().describe(`最大返回数,默认 10,上限 ${MAX_RECIPES}`),
},
},
({ limit = 10, ...input }) => {
const matches = catalog.recommend(input)
if (!matches.length) {
return {
content: [{ type: 'text' as const, text: '没有匹配的菜谱。可以放宽条件:少给忌口/厨具限制,或提示用户再买一样食材。' }],
}
}
return {
content: [{ type: 'text' as const, text: toText({ 菜谱总数: matches.length, 菜谱: matches.slice(0, limit).map(describeMatch) }) }],
}
},
)
server.registerTool(
'search_recipes',
{
title: '按条件检索菜谱',
description: '按食材/厨具/难度/标签/做法精确检索菜谱,返回匹配与缺少的食材。stuff 可以不给,此时按其他条件筛选。适合「有哪些用烤箱的菜」这类查询。',
inputSchema: {
stuff: ingredientList.optional().describe('按食材筛选,命中任意一样即算匹配'),
tool: z.string().optional().describe('厨具,如 "电饭煲"'),
difficulty: z.enum(['简单', '普通', '困难']).optional().describe('难度,需完全一致'),
tag: z.string().optional().describe('标签关键词,如 "懒人"、"下饭"、"汤"'),
method: z.string().optional().describe('做法关键词,如 "炒"、"蒸"'),
limit: z.number().int().min(1).max(MAX_RECIPES).optional().describe(`最大返回数,默认 10,上限 ${MAX_RECIPES}`),
},
},
({ limit = 10, ...options }) => {
const result = catalog.search({ ...options, limit })
const payload = {
菜谱总数: result.recipes.length,
菜谱: result.recipes.map(r => ({
id: r.id,
菜名: r.name,
所需食材: r.stuff,
命中食材: r.matchedStuff,
缺少食材: r.missingStuff,
难度: r.difficulty || '未标注',
标签: r.tags,
做法: r.methods,
所需厨具: r.tools,
视频教程: r.videoUrl ?? '无',
})),
食物相克提醒: result.warnings.map(w => `${w.foodA} + ${w.foodB}:${w.reason}`),
}
return { content: [{ type: 'text' as const, text: toText(payload) }] }
},
)
server.registerTool(
'check_incompatible',
{
title: '检查食材相克',
description: '检查一组食材里是否存在「不宜同食」的搭配,依据项目自带的相克表。理性提示影响程度,不要夸大恐慌。',
inputSchema: {
foods: ingredientList.describe('要一起检查的食材,至少两种才可能命中相克规则'),
},
},
({ foods }) => {
if (!foods.length)
return { content: [{ type: 'text' as const, text: '请至少提供一种食材。' }] }
const warnings = catalog.checkIncompatible(foods)
if (!warnings.length)
return { content: [{ type: 'text' as const, text: '这组食材没有已知的相克记录,可以放心搭配。' }] }
return {
content: [{
type: 'text' as const,
text: toText({
提醒: warnings.map(w => ({
搭配: `${w.foodA} + ${w.foodB}`,
原因: w.reason,
})),
}),
}],
}
},
)
server.registerTool(
'get_recipe',
{
title: '查看菜谱详情',
description: '按菜名或 id 取单道菜的完整信息,含所需食材、厨具、难度、标签和 B 站视频教程链接。推荐结果里已含大部分信息,需要展开单道菜时再调用。',
inputSchema: {
idOrName: z.string().describe('菜名或 id,如 "电饭煲版广式腊肠煲饭" 或 "0"'),
},
},
({ idOrName }) => {
const recipe = catalog.getRecipe(idOrName)
if (!recipe)
return { content: [{ type: 'text' as const, text: '没找到这道菜,请确认菜名或 id 是否正确。' }] }
return {
content: [{
type: 'text' as const,
text: toText({
id: recipe.id,
菜名: recipe.name,
所需食材: recipe.stuff,
难度: recipe.difficulty || '未标注',
标签: recipe.tags,
做法: recipe.methods,
所需厨具: recipe.tools,
视频教程: recipe.videoUrl ?? '无',
}),
}],
}
},
)
return server
}
/** 打印筛选取值,方便直接命令行排查参数写没写错 */
export function logFilterHints(recipeCount: number) {
process.stderr.write(
`[cook-mcp] 菜谱 ${recipeCount} 道;做法 ${METHOD_GROUPS.map(g => g.label).join('/')};`
+ `菜系 ${CUISINE_GROUPS.map(g => g.label).join('/')};难度 ${DIFFICULTY_LEVELS.join('/')}\n`,
)
}
+16
View File
@@ -0,0 +1,16 @@
{
"compilerOptions": {
"target": "ESNext",
"lib": ["ESNext"],
"module": "ESNext",
"moduleResolution": "Bundler",
"resolveJsonModule": true,
"types": ["node"],
"strict": true,
"noEmit": true,
"esModuleInterop": true,
"skipLibCheck": true
},
"include": ["src/**/*.ts"],
"exclude": ["node_modules", "dist"]
}