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:
@@ -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,
|
||||
},
|
||||
})
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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`)
|
||||
})
|
||||
@@ -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`,
|
||||
)
|
||||
}
|
||||
@@ -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"]
|
||||
}
|
||||
Reference in New Issue
Block a user