参考
本文记录了从零搭建一个飞书 AI 机器人的完整过程,核心技术栈为 模型 API + MCP 工具调用 + Skill 。项目追求最小依赖、清晰分层、易于扩展。
整体思路
想做一件事:在飞书群里@机器人,它能用 AI 回复,还能调用外部工具(如查数据库、控制硬件)。
拆解下来有三个核心问题:
- 怎么收发飞书消息? → 飞书开放平台 WebSocket 长连接
- 怎么让 AI 调用工具? → Claude 的 tool_use + MCP 协议
- 怎么切换 AI 的角色/行为? → Skill 文件系统(.md 文件定义人设)
最终形成这样一条数据流:
bash
飞书消息 → FeishuClient → AgentRunner → 模型 API
↕
McpClient → MCP Server(工具)
项目结构
PS D:\workspace\gitee\0\ming_agent> tree /f
卷 新加卷 的文件夹 PATH 列表
卷序列号为 1E8A-2CFF
ming_agent/
│
│ .gitignore # Git 忽略文件(node_modules 等)
│ package.json # 项目依赖与启动脚本
│ index.html # 项目说明页(可选)
│ init_readme.md # 初始化说明
│ readme.md # 项目文档
│
├─extlib/ # 底层 MCP 通信库(框架层,一般不需要改动)
│ BaseMcpServer.js # MCP Server 基类
│ StdioMcpServer.js # 基于 stdio 的 MCP Server 实现
│ StdioMcpClient.js # 基于 stdio 的 MCP Client 实现
│ SseMcpServer.js # 基于 SSE 的 MCP Server 实现
│ StreamableHttpMcpServer.js # 基于 Streamable HTTP 的 MCP Server 实现
│ ming_node_init.js # 初始化工具函数
│
├─mcpserver/ # MCP 工具注册层(业务工具在这里扩展)
│ mcp_server.js # 注册具体工具:加法、设置转速等
│ light_mcp_server.js # 注册具体工具:开关灯
├─skills/ # Skill 定义文件夹,每个 .md 文件是一个 AI 角色
│ main.md # 默认 skill(通用助手)
│ joker.md # 冷笑话测试 skill
│
├─src/ # Agent 核心源码
│ │ index.js # 程序入口:串联 Skill / MCP / Agent / 飞书四个模块
│ │ test-skill.js # 本地测试脚本:不启动飞书,直接在终端跑 Agent
│ │
│ ├─agent/
│ │ runner.js # agent Loop:驱动"思考→调工具→再思考"循环
│ │
│ ├─config/
│ │ index.js # 全局配置中心:所有参数集中在此,支持环境变量覆盖
│ │
│ ├─feishu/
│ │ client.js # 飞书客户端:WebSocket 收消息 + 消息卡片回复
│ │
│ ├─mcp/
│ │ client.js # MCP 客户端:连接 Server、拉取工具列表、代理工具调用
│ │
│ ├─skills/
│ │ manager.js # Skill 加载器:读取 .md 文件,解析 frontmatter 和 system prompt
│ │
│ └─utils/
│ logger.js # 分级日志:debug / info / warn / error,支持颜色和时间戳
│
└─test/ # 参考示例(开发调试用,不进生产)
anthropic_agent.js # 原始 Claude Agent 示例
feishu_client.js # 原始飞书接入示例
openai_agent.js # OpenAI 兼容接口示例
依赖 package.json
json
{
"name": "ming_agent",
"version": "1.0.0",
"main": "feishu.js",
"type": "module",
"scripts": {
"start": "node src/index.js",
"test:skill": "node test-skill.js"
},
"author": "",
"license": "ISC",
"description": "",
"dependencies": {
"@anthropic-ai/sdk": "^0.78.0",
"@larksuiteoapi/node-sdk": "^1.59.0",
"@modelcontextprotocol/sdk": "^1.27.1",
"ming_node": "^3.0.5",
"openai": "^6.27.0",
"zod": "^4.3.6"
}
}
配置中心 src/config/index.js
js
/**
* ╔══════════════════════════════════════════════════════╗
* ║ 全局配置中心 ║
* ║ 所有可调参数集中在此,业务代码不再散落硬编码 ║
* ╚══════════════════════════════════════════════════════╝
*
* 优先级:环境变量 > 此文件默认值
* 敏感信息建议通过 .env 注入,不要提交到 Git
*/
import path from "path";
export const CONFIG = {
// ══════════════════════════════════════════
// 日志
// ══════════════════════════════════════════
log: {
/** debug < info < warn < error;生产建议 "info" */
level: process.env.LOG_LEVEL ?? "debug",
color: process.env.LOG_COLOR !== "false",
timestamp: true,
},
// ══════════════════════════════════════════
// API 风格选择
// "anthropic" → 使用 Claude 原生 API
// "openai" → 使用 OpenAI 兼容 API(GLM / DeepSeek / Qwen 等均可)
// ══════════════════════════════════════════
apiStyle: process.env.API_STYLE ?? "openai",
// ══════════════════════════════════════════
// Anthropic / Claude 配置
// ══════════════════════════════════════════
anthropic: {
apiKey: process.env.ANTHROPIC_API_KEY ?? "",
baseURL: process.env.ANTHROPIC_BASE_URL ?? "https://api.longcat.chat/anthropic",
model: process.env.ANTHROPIC_MODEL ?? "LongCat-Flash-Chat",
maxTokens: 1024,
temperature: 1.0,
// topP: 0.9,
// toolChoice: "auto",
},
// ══════════════════════════════════════════
// OpenAI 兼容配置(apiStyle = "openai" 时生效)
// 兼容任何支持 OpenAI 格式的服务:GLM / DeepSeek / Qwen / Ollama ...
// ══════════════════════════════════════════
openai: {
apiKey: process.env.OPENAI_API_KEY ?? "",
baseURL: process.env.OPENAI_BASE_URL ?? "https://api.edgefn.net/v1",
model: process.env.OPENAI_MODEL ?? "GLM-4.7",
maxTokens: 1024,
temperature: 1.0,
// topP: 0.9,
},
// ══════════════════════════════════════════
// MCP Server 配置(支持同时连接多个)
//
// 每个 server 独立配置 transport,三选一:
// "stdio" 本地子进程(默认,开发常用)
// "sse" 旧版 HTTP+SSE(兼容旧服务端)
// "streamable-http" 新版推荐远程协议(SDK v1.10+)
//
// 所有 server 的工具会自动合并,工具名重复时后者覆盖前者
// ══════════════════════════════════════════
mcpServers: [
// ── 本地工具服务(stdio)────────────────
{
name: "local", // 标识名,仅用于日志
transport: "stdio",
command: "node",
args: ["D:\\workspace\\gitee\\0\\ming_agent\\mcpserver\\mcp_server.js"],
},
// npx 启动的第三方 MCP 包
{
name: "filesystem",
transport: "stdio",
command: "npx",
args: ["-y", "@modelcontextprotocol/server-filesystem", "E:\\agent_test"],
},
// ── 联网(fetch 网页内容)────────────────
// {
// name: "fetch",
// transport: "stdio",
// command: "npx",
// args: ["-y", "fetch-mcp"],
// },
// ── 远程工具服务(streamable-http)──────
// {
// name: "remote",
// transport: "streamable-http",
// url: "http://192.168.1.10:3000/mcp",
// headers: { "Authorization": "Bearer your_token" },
// },
// ── 旧版 SSE 服务 ────────────────────────
// {
// name: "legacy",
// transport: "sse",
// url: "http://192.168.1.10:3000/sse",
// },
],
// ══════════════════════════════════════════
// 飞书
// ══════════════════════════════════════════
feishu: {
appId: process.env.FEISHU_APP_ID ?? "",
appSecret: process.env.FEISHU_APP_SECRET ?? "",
replyTitlePrefix: "🤖 助手回复",
logIncoming: true,
},
// ══════════════════════════════════════════
// Skill
// ══════════════════════════════════════════
skillsDir: path.resolve(process.cwd(), "skills"),
activeSkill: process.env.ACTIVE_SKILL ?? "main",
};
优先级:环境变量 > 配置文件默认值 。敏感信息(Key、Secret)通过 .env 注入,不提交到 Git。
日志工具 src/utils/logger.js
js
/**
* ══════════════════════════════════════════════════════
* Logger --- 轻量级日志工具
* ══════════════════════════════════════════════════════
*
* 特性:
* - 四级日志:debug / info / warn / error
* - 可配置颜色、时间戳
* - 运行时动态修改级别:logger.setLevel("warn")
* - 通过 CONFIG.log 统一控制,无需散落 console.log
*/
import { CONFIG } from "../config/index.js";
// ANSI 终端颜色码
const COLORS = {
reset: "\x1b[0m",
gray: "\x1b[90m",
cyan: "\x1b[36m",
green: "\x1b[32m",
yellow: "\x1b[33m",
red: "\x1b[31m",
};
// 日志级别权重(数字越大越严重)
const LEVEL_WEIGHT = { debug: 0, info: 1, warn: 2, error: 3 };
// 各级别对应样式
const LEVEL_STYLE = {
debug: { label: "DEBUG", color: COLORS.gray },
info: { label: "INFO ", color: COLORS.cyan },
warn: { label: "WARN ", color: COLORS.yellow },
error: { label: "ERROR", color: COLORS.red },
};
class Logger {
constructor(cfg = CONFIG.log) {
this._level = cfg.level ?? "debug";
this._color = cfg.color ?? true;
this._timestamp = cfg.timestamp ?? true;
}
/** 运行时动态修改日志级别 */
setLevel(level) {
if (!(level in LEVEL_WEIGHT)) throw new Error(`无效日志级别: ${level}`);
this._level = level;
}
_shouldPrint(level) {
return LEVEL_WEIGHT[level] >= LEVEL_WEIGHT[this._level];
}
_format(level, parts) {
const style = LEVEL_STYLE[level];
const ts = this._timestamp ? `${COLORS.gray}${new Date().toISOString()}${COLORS.reset} ` : "";
const tag = this._color
? `${style.color}[${style.label}]${COLORS.reset}`
: `[${style.label}]`;
return `${ts}${tag} ${parts.join(" ")}`;
}
_print(level, ...args) {
if (!this._shouldPrint(level)) return;
const line = this._format(level, args.map(a =>
typeof a === "object" ? JSON.stringify(a, null, 2) : String(a)
));
if (level === "error") {
process.stderr.write(line + "\n");
} else {
process.stdout.write(line + "\n");
}
}
debug(...args) { this._print("debug", ...args); }
info (...args) { this._print("info", ...args); }
warn (...args) { this._print("warn", ...args); }
error(...args) { this._print("error", ...args); }
/** 打印醒目的分隔线,方便区分对话轮次 */
divider(char = "─", len = 52) {
if (this._shouldPrint("debug")) {
process.stdout.write(char.repeat(len) + "\n");
}
}
}
// 全局单例,所有模块共享同一个 logger
export const logger = new Logger();
日志分级控制:
bash
LOG_LEVEL=warn npm start # 生产:只看警告和错误
LOG_LEVEL=debug npm start # 开发:看所有细节
输出效果:
2026-03-13T08:17:22.008Z [INFO ] [MCP] 连接成功,可用工具:加法, 设置转速
2026-03-13T08:17:22.009Z [INFO ] [Agent] 👤 用户: 帮我算 4+3 (skill: joker)
2026-03-13T08:17:22.010Z [DEBUG] [Agent] 请求 Claude(messages=1 条,skill=joker)
Skill 加载器 src/skills/manager.js
js
/**
* ══════════════════════════════════════════════════════
* SkillManager --- 从 .md 文件加载 Skill
* ══════════════════════════════════════════════════════
*
* Skill 文件格式(标准 SKILL.md):
*
* ---
* name: joker
* description: 冷笑话段子手
* temperature: 1.0
* maxTokens: 256
* ---
*
* # 标题
* 正文即 system prompt,支持完整 Markdown
*
* 加载规则:
* - YAML frontmatter 提供元数据(name/description/temperature/maxTokens 等)
* - frontmatter 之后的全部 Markdown 文本作为 system prompt
* - 未在 frontmatter 中声明的字段继承 CONFIG.anthropic 全局默认值
*/
import fs from "fs";
import path from "path";
import { CONFIG } from "../config/index.js";
import { logger } from "../utils/logger.js";
export class SkillManager {
/**
* @param {string} [skillsDir] skill 文件夹路径,默认读取 CONFIG.skillsDir
*/
constructor(skillsDir) {
this._dir = skillsDir ?? CONFIG.skillsDir;
this._active = this._load(CONFIG.activeSkill);
}
// ──────────────────────────────────────────
// 公共接口
// ──────────────────────────────────────────
/** 返回当前激活的 skill(已合并全局默认值) */
getActive() { return this._active; }
/** 列出 skillsDir 下所有 .md 文件的 name + description */
list() {
return fs.readdirSync(this._dir)
.filter(f => f.endsWith(".md"))
.map(f => {
try {
const s = this._load(path.basename(f, ".md"));
return { name: s.name, description: s.description, active: s.name === this._active.name };
} catch { return null; }
})
.filter(Boolean);
}
// ──────────────────────────────────────────
// 私有:加载并解析单个 skill 文件
// ──────────────────────────────────────────
_load(skillName) {
const filePath = path.resolve(this._dir, `${skillName}.md`);
if (!fs.existsSync(filePath)) {
throw new Error(`[SkillManager] 找不到 skill 文件: ${filePath}`);
}
const raw = fs.readFileSync(filePath, "utf-8");
const { meta, system } = this._parse(raw);
// frontmatter 字段优先,全局配置兜底
const global = CONFIG.anthropic;
const resolved = {
name: meta.name ?? skillName,
description: meta.description ?? "(无描述)",
system: system.trim(),
maxTokens: meta.maxTokens ?? global.maxTokens,
temperature: meta.temperature ?? global.temperature,
...(meta.topP !== undefined && { topP: meta.topP }),
...(meta.topK !== undefined && { topK: meta.topK }),
...(meta.stopSequences !== undefined && { stopSequences: meta.stopSequences }),
...(meta.toolChoice !== undefined && { toolChoice: meta.toolChoice }),
};
logger.info(
`[Skill] 已加载 "${resolved.name}"(${resolved.description})` +
` temperature=${resolved.temperature} maxTokens=${resolved.maxTokens}`
);
return resolved;
}
/**
* 解析 SKILL.md 文本
* @returns {{ meta: object, system: string }}
*/
_parse(raw) {
// 匹配 --- frontmatter ---
const fmMatch = raw.match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n([\s\S]*)$/);
if (!fmMatch) {
// 没有 frontmatter,整个文件当 system prompt
return { meta: {}, system: raw };
}
const meta = this._parseYaml(fmMatch[1]);
const system = fmMatch[2];
return { meta, system };
}
/**
* 极简 YAML 解析(只支持 key: value 单层结构)
* 如需数组/嵌套,可替换为 js-yaml 库
*/
_parseYaml(text) {
const meta = {};
for (const line of text.split("\n")) {
const m = line.match(/^(\w+):\s*(.+)$/);
if (!m) continue;
const [, key, val] = m;
// 自动类型转换:数字 / 布尔 / 字符串
if (!isNaN(val)) meta[key] = Number(val);
else if (val === "true") meta[key] = true;
else if (val === "false") meta[key] = false;
else meta[key] = val.trim();
}
return meta;
}
}
Skill 文件格式
采用标准 SKILL.md 格式:YAML frontmatter 放参数,正文是 system prompt。
markdown
---
name: joker
description: 冷笑话段子手,专门讲让人沉默三秒的冷笑话
temperature: 1.0
maxTokens: 256
---
# 冷笑话段子手
你是一个专门讲冷笑话的段子手。
## 规则
- 每次只讲一个笑话,不超过 4 句话
- 笑话要足够冷,冷到让人沉默 3 秒才反应过来
- 结尾加一个 emoji 表情
- 用中文
frontmatter 支持的字段:
| 字段 | 类型 | 说明 |
|---|---|---|
name |
string | skill 标识名 |
description |
string | 描述(仅用于日志) |
temperature |
number | 随机性 0~1 |
maxTokens |
number | 最大输出 token |
topP |
number | 核采样(可选) |
toolChoice |
string | 工具选择策略(可选) |
未声明的字段自动继承 CONFIG.anthropic 全局默认值。
切换 Skill
bash
# 方式一:改 config 里的 activeSkill 字段
activeSkill: "translator"
# 方式二:环境变量(临时切换,不改文件)
ACTIVE_SKILL=coder npm start
新增一个 skill 只需在 skills/ 目录下新建一个 .md 文件,零改动业务代码。
MCP 工具调用 src/mcp/client.js
js
/**
* ══════════════════════════════════════════════════════
* MCP Client --- 同时连接多个 MCP Server
* ══════════════════════════════════════════════════════
*
* 支持同时连接 CONFIG.mcpServers 中配置的所有服务:
* - 所有服务并行连接,加快启动速度
* - 工具列表自动合并,工具名重复时后者覆盖前者(配置顺序决定优先级)
* - 工具调用时自动路由到注册该工具的 server
* - 任意一个 server 断开不影响其他 server 的工具继续使用
*
* transport 三选一(每个 server 独立配置):
* "stdio" 本地子进程
* "sse" 旧版 HTTP+SSE
* "streamable-http" 新版推荐远程协议
*/
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js";
import { SSEClientTransport } from "@modelcontextprotocol/sdk/client/sse.js";
import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
import { CONFIG } from "../config/index.js";
import { logger } from "../utils/logger.js";
export class McpClient {
constructor() {
/** @type {Array<{ name: string, client: Client }>} 已连接的 server 列表 */
this._connections = [];
/** @type {Map<string, Client>} 工具名 → 所属 client 的路由表 */
this._toolRouter = new Map();
/** @type {Array<ClaudeTool>} 合并后的工具列表 */
this._tools = [];
}
// ──────────────────────────────────────────
// 连接所有 Server
// ──────────────────────────────────────────
async connect() {
const servers = CONFIG.mcpServers;
if (!servers?.length) throw new Error("[MCP] mcpServers 配置为空");
logger.info(`[MCP] 开始连接 ${servers.length} 个 Server...`);
// 并行连接所有 server
const results = await Promise.allSettled(
servers.map(cfg => this._connectOne(cfg))
);
// 统计成功/失败
results.forEach((r, i) => {
if (r.status === "rejected") {
logger.error(`[MCP] Server "${servers[i].name}" 连接失败:`, r.reason?.message);
}
});
if (this._connections.length === 0) {
throw new Error("[MCP] 所有 Server 均连接失败,无法继续");
}
logger.info(
`[MCP] 已连接 ${this._connections.length}/${servers.length} 个 Server,` +
`共 ${this._tools.length} 个工具:${this._tools.map(t => t.name).join(", ")}`
);
}
// ──────────────────────────────────────────
// 工具列表 & 调用
// ──────────────────────────────────────────
/** 返回合并后的工具列表(Claude API 格式) */
getTools() { return this._tools; }
/**
* 调用工具,自动路由到注册该工具的 server
* @param {string} name
* @param {object} args
* @returns {Promise<string>}
*/
async callTool(name, args) {
const client = this._toolRouter.get(name);
if (!client) throw new Error(`[MCP] 未找到工具 "${name}",请检查 server 是否已注册`);
logger.debug(`[MCP] → ${name}`, args);
const result = await client.callTool({ name, arguments: args });
const text = result?.content?.[0]?.text ?? "";
logger.debug(`[MCP] ← ${text}`);
return text;
}
// ──────────────────────────────────────────
// 关闭所有连接
// ──────────────────────────────────────────
async close() {
await Promise.allSettled(
this._connections.map(({ name, client }) =>
client.close().then(() => logger.info(`[MCP] "${name}" 已关闭`))
)
);
this._connections = [];
this._toolRouter.clear();
this._tools = [];
}
// ──────────────────────────────────────────
// 私有:连接单个 Server 并注册其工具
// ──────────────────────────────────────────
async _connectOne(cfg) {
const { name } = cfg;
logger.info(`[MCP] 连接 "${name}"(${cfg.transport})...`);
const transport = this._createTransport(cfg);
const client = new Client(
{ name: `feishu-claude-agent/${name}`, version: "1.0.0" },
{ capabilities: {} },
);
await client.connect(transport);
// 拉取工具列表
const { tools } = await client.listTools();
logger.info(`[MCP] "${name}" 工具:${tools.map(t => t.name).join(", ")}`);
// 注册到路由表(同名工具后者覆盖前者)
for (const tool of tools) {
if (this._toolRouter.has(tool.name)) {
logger.warn(`[MCP] 工具 "${tool.name}" 已存在,由 "${name}" 覆盖`);
}
this._toolRouter.set(tool.name, client);
}
this._connections.push({ name, client });
// 重新生成合并后的工具列表(保持与路由表一致)
this._tools = [...this._toolRouter.entries()].map(([, c]) => {
// 从对应 server 的工具原始数据重新构造
return tools
.filter(t => this._toolRouter.get(t.name) === c)
.map(t => ({ name: t.name, description: t.description, input_schema: t.inputSchema }));
}).flat();
// 更完整的合并:重新从所有已连接 server 汇总
await this._rebuildToolList();
}
/**
* 根据路由表重建合并工具列表
* 遍历所有已连接 server,按路由表中存活的工具重建
*/
async _rebuildToolList() {
const all = [];
for (const { client } of this._connections) {
const { tools } = await client.listTools();
for (const t of tools) {
// 只保留路由表中指向当前 client 的工具(避免被覆盖的重复项)
if (this._toolRouter.get(t.name) === client) {
all.push({ name: t.name, description: t.description, input_schema: t.inputSchema });
}
}
}
this._tools = all;
}
// ──────────────────────────────────────────
// 私有:按配置创建 Transport
// ──────────────────────────────────────────
_createTransport(cfg) {
const { transport, command, args, url, headers = {} } = cfg;
switch (transport) {
case "stdio":
return new StdioClientTransport({ command, args });
case "sse":
return new SSEClientTransport(new URL(url), { headers });
case "streamable-http":
return new StreamableHTTPClientTransport(new URL(url), { headers });
default:
throw new Error(
`[MCP] "${cfg.name}" 不支持的 transport: "${transport}",` +
`可选值:stdio | sse | streamable-http`
);
}
}
}
MCP 服务 mcpserver/mcp_server.js
本文不展开可参考 express风格的mcpServer)注册mcp工具
js
import { z } from 'zod';
import M from "ming_node";
import MyMcpServer from "../extlib/StdioMcpServer.js";
const app=new MyMcpServer("my_mcp_server");
app.listen(3000);
app.begin((req,res)=>{
M.log("开始执行",req.mcpName,JSON.stringify(req.params));
})
app.end((req,res)=>{
M.log("执行完成",req.mcpName,res.result);
})
app.get("加法",{
a:z.number(),
b:z.number(),
},async (req,res)=>{
const {a,b}=req.params;
const c=a+b+9;
res.send(c);
});
app.get("设置转速",{ speed:z.number()},async (req,res)=>{
const {speed}=req.params;
M.log(`转速设置为 ${speed}`);
res.send("转速设置完成");
});
MCP 服务 mcpserver/light_mcp_server.js
控制开关灯的mcp
js
/**
* 开关灯 MCP Server --- Streamable HTTP
*
* 依赖:
* npm install @modelcontextprotocol/sdk zod express
*
* 启动:
* node light_mcp_server.js
*
* Agent config 加入:
* { name: "light", transport: "streamable-http", url: "http://localhost:3100/mcp" }
*
* **飞书直接说**
*
* 打开客厅的灯
* 关掉卧室灯
* 所有房间灯的状态
*/
import express from "express";
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
import { z } from "zod";
const PORT = 3100;
// ── 灯的状态(内存,重启后重置)──────────────
const lights = {}; // { [room]: true/false }
// ── MCP Server 定义 ──────────────────────────
const server = new McpServer({
name: "light-server",
version: "1.0.0",
});
// 开灯
server.tool(
"turn_on_light",
"开启指定房间的灯",
{ room: z.string().describe("房间名称,例如:客厅、卧室、厨房") },
async ({ room }) => {
lights[room] = true;
console.log(`[灯控] ${room} 开灯`);
return { content: [{ type: "text", text: `${room}的灯已开启 💡` }] };
}
);
// 关灯
server.tool(
"turn_off_light",
"关闭指定房间的灯",
{ room: z.string().describe("房间名称,例如:客厅、卧室、厨房") },
async ({ room }) => {
lights[room] = false;
console.log(`[灯控] ${room} 关灯`);
return { content: [{ type: "text", text: `${room}的灯已关闭 🌙` }] };
}
);
// 查询状态
server.tool(
"get_light_status",
"查询灯的状态,不传 room 则返回所有房间",
{ room: z.string().describe("房间名称,不填则查询所有房间").optional() },
async ({ room }) => {
if (room) {
const s = lights[room];
const text = s === undefined
? `${room} 尚未操作过`
: `${room}:${s ? "开启 💡" : "关闭 🌙"}`;
return { content: [{ type: "text", text }] };
}
const all = Object.entries(lights);
const text = all.length === 0
? "暂无灯控记录"
: all.map(([r, s]) => `${r}:${s ? "开 💡" : "关 🌙"}`).join("\n");
return { content: [{ type: "text", text }] };
}
);
// ── HTTP Server ──────────────────────────────
const app = express();
app.use(express.json());
// 每个请求创建独立 transport(无状态模式,最简单)
app.post("/mcp", async (req, res) => {
const transport = new StreamableHTTPServerTransport({
sessionIdGenerator: undefined, // 无状态
});
res.on("close", () => transport.close());
await server.connect(transport);
await transport.handleRequest(req, res, req.body);
});
// GET /mcp 不支持
app.get("/mcp", (_req, res) => {
res.status(405).json({ error: "Method Not Allowed" });
});
app.listen(PORT, () => {
console.log(`[灯控 MCP] 已启动,监听 http://localhost:${PORT}/mcp`);
});
Agent 主循环 src/agent/runner.js
Agent 的核心是一个 while 循环:Claude 思考 → 决定调工具 → 拿到结果 → 继续思考 → 输出最终答案。
js
/**
* ══════════════════════════════════════════════════════
* Agent Runner --- 支持 Anthropic / OpenAI 双风格
* ══════════════════════════════════════════════════════
*
* 通过 CONFIG.apiStyle 选择后端:
* "anthropic" → Claude 原生 API(tool_use / tool_result)
* "openai" → OpenAI 兼容 API(tool_calls / tool 消息)
* 兼容 GLM / DeepSeek / Qwen / Ollama 等任意兼容服务
*
* 对外接口保持不变:runner.run(userMessage) → Promise<string>
* 切换风格只需改 CONFIG.apiStyle,上层代码零修改。
*/
import Anthropic from "@anthropic-ai/sdk";
import OpenAI from "openai";
import { CONFIG } from "../config/index.js";
import { logger } from "../utils/logger.js";
export class AgentRunner {
/**
* @param {import('../mcp/client.js').McpClient} mcpClient
* @param {import('../skills/manager.js').SkillManager} skillManager
*/
constructor(mcpClient, skillManager) {
this._mcp = mcpClient;
this._skillManager = skillManager;
this._style = CONFIG.apiStyle; // "anthropic" | "openai"
this._client = this._createClient();
logger.info(`[Agent] API 风格: ${this._style}`);
}
// ──────────────────────────────────────────
// 公共接口
// ──────────────────────────────────────────
/**
* 处理一条用户消息,返回最终纯文本回复
* @param {string} userMessage
* @returns {Promise<string>}
*/
async run(userMessage) {
const skill = this._skillManager.getActive();
logger.divider();
logger.info(`[Agent] 👤 用户: ${userMessage} (skill: ${skill.name})`);
logger.divider();
return this._style === "openai"
? this._runOpenAI(userMessage, skill)
: this._runAnthropic(userMessage, skill);
}
// ══════════════════════════════════════════
// Anthropic 风格 Agentic Loop
// ══════════════════════════════════════════
async _runAnthropic(userMessage, skill) {
const messages = [{ role: "user", content: userMessage }];
const tools = this._mcp.getTools(); // { name, description, input_schema }
while (true) {
const cfg = CONFIG.anthropic;
const params = {
model: cfg.model,
max_tokens: skill.maxTokens,
system: skill.system, // Anthropic 独有:顶层 system 字段
temperature: skill.temperature,
messages,
tools,
...(skill.topP !== undefined && { top_p: skill.topP }),
...(skill.topK !== undefined && { top_k: skill.topK }),
...(skill.stopSequences !== undefined && { stop_sequences: skill.stopSequences }),
...(skill.toolChoice !== undefined && { tool_choice: skill.toolChoice }),
};
logger.debug(`[Agent/Anthropic] 请求(messages=${messages.length} 条)`);
const response = await this._client.messages.create(params);
// ① 正常结束
if (response.stop_reason === "end_turn") {
const text = response.content.find(b => b.type === "text")?.text ?? "";
logger.info(`[Agent] 🤖 ${text}`);
return text;
}
// ② 调工具
if (response.stop_reason === "tool_use") {
messages.push({ role: "assistant", content: response.content });
// 执行工具,结果格式:[{ type:"tool_result", tool_use_id, content }]
const toolResults = await this._execToolsAnthropic(response.content);
messages.push({ role: "user", content: toolResults });
continue;
}
// ③ 兜底(max_tokens 等)
const fallback = response.content.find(b => b.type === "text")?.text ?? "(无响应)";
logger.warn(`[Agent/Anthropic] stop_reason=${response.stop_reason}`);
return fallback;
}
}
/**
* 执行 Anthropic 风格的工具调用
* 结果追加为 { type:"tool_result", tool_use_id, content } 块
*/
async _execToolsAnthropic(contentBlocks) {
const results = [];
for (const block of contentBlocks.filter(b => b.type === "tool_use")) {
const text = await this._callTool(block.name, block.input);
results.push({ type: "tool_result", tool_use_id: block.id, content: text });
}
return results;
}
// ══════════════════════════════════════════
// OpenAI 风格 Agentic Loop
// ══════════════════════════════════════════
async _runOpenAI(userMessage, skill) {
const cfg = CONFIG.openai;
// OpenAI 的 system 通过消息数组第一条传入
const messages = [
{ role: "system", content: skill.system },
{ role: "user", content: userMessage },
];
// MCP 工具格式 → OpenAI function 格式
const tools = this._mcp.getTools().map(t => ({
type: "function",
function: {
name: t.name,
description: t.description,
parameters: t.input_schema, // 字段名不同:input_schema → parameters
},
}));
while (true) {
const params = {
model: cfg.model,
max_tokens: skill.maxTokens,
temperature: skill.temperature,
messages,
tools,
tool_choice: skill.toolChoice ?? "auto",
...(skill.topP !== undefined && { top_p: skill.topP }),
};
logger.debug(`[Agent/OpenAI] 请求(messages=${messages.length} 条)`);
const response = await this._client.chat.completions.create(params);
const choice = response.choices[0];
const message = choice.message;
const finishReason = choice.finish_reason;
// ① 正常结束
if (finishReason === "stop") {
const text = message.content ?? "";
logger.info(`[Agent] 🤖 ${text}`);
return text;
}
// ② 调工具
if (finishReason === "tool_calls") {
messages.push(message); // 追加 assistant 消息(含 tool_calls)
// 执行工具,结果追加为 { role:"tool", tool_call_id, content } 消息
const toolResults = await this._execToolsOpenAI(message.tool_calls);
messages.push(...toolResults);
continue;
}
// ③ 兜底(length 等)
logger.warn(`[Agent/OpenAI] finish_reason=${finishReason}`);
return message.content ?? "(无响应)";
}
}
/**
* 执行 OpenAI 风格的工具调用
* 结果追加为 { role:"tool", tool_call_id, content } 消息
*/
async _execToolsOpenAI(toolCalls) {
const results = [];
for (const call of toolCalls) {
const name = call.function.name;
const input = JSON.parse(call.function.arguments);
const text = await this._callTool(name, input);
results.push({ role: "tool", tool_call_id: call.id, content: text });
}
return results;
}
// ══════════════════════════════════════════
// 公共:执行单次工具调用(两种风格共用)
// ══════════════════════════════════════════
async _callTool(name, input) {
logger.info(`[Agent] 🔧 调用工具: ${name}`, input);
let text;
try {
text = await this._mcp.callTool(name, input);
} catch (err) {
logger.error(`[Agent] 工具调用失败: ${name}`, err.message);
text = `工具调用出错: ${err.message}`;
}
logger.info(`[Agent] ✅ 工具结果: ${text}`);
return text;
}
// ══════════════════════════════════════════
// 初始化客户端
// ══════════════════════════════════════════
_createClient() {
if (this._style === "openai") {
const { apiKey, baseURL } = CONFIG.openai;
logger.debug(`[Agent] OpenAI 客户端 baseURL=${baseURL}`);
return new OpenAI({ apiKey, baseURL });
}
// 默认 Anthropic
const { apiKey, baseURL } = CONFIG.anthropic;
logger.debug(`[Agent] Anthropic 客户端 baseURL=${baseURL}`);
return new Anthropic({
apiKey,
baseURL,
defaultHeaders: {
"Authorization": `Bearer ${apiKey}`,
"x-api-key": apiKey,
},
});
}
}
飞书接入 src/feishu/client.js
忽略历史消息,忽略重复触发的消息
js
/**
* ══════════════════════════════════════════════════════
* Feishu Client --- 飞书 WebSocket 长连接客户端
* ══════════════════════════════════════════════════════
*
* 职责:
* 1. 建立飞书 WebSocket 长连接,监听 im.message.receive_v1 事件
* 2. 消息去重:同一条消息只处理一次(防飞书网络抖动重推)
* 3. 解析消息,提取文本内容和 chat_id
* 4. 将消息文本交给 onMessage 回调(由外部注入 Agent 逻辑)
* 5. 将回复以消息卡片形式发回飞书
*
* 设计原则:
* - Feishu 层不感知 Agent 细节,只负责 I/O
* - 通过 onMessage 钩子解耦,方便单元测试和替换
*/
import * as Lark from "@larksuiteoapi/node-sdk";
import { CONFIG } from "../config/index.js";
import { logger } from "../utils/logger.js";
export class FeishuClient {
/**
* @param {(text: string, ctx: MessageContext) => Promise<string>} onMessage
* 收到用户消息时的回调,接收消息文本,返回待发送的回复文本
*/
constructor(onMessage) {
this._onMessage = onMessage;
const { appId, appSecret } = CONFIG.feishu;
// HTTP 客户端(用于主动发消息)
this._client = new Lark.Client({ appId, appSecret });
// WebSocket 客户端(用于接收事件推送)
this._wsClient = new Lark.WSClient({
appId,
appSecret,
// 将 Lark SDK 自身日志映射到我们的 logger
loggerLevel: this._larkLogLevel(),
});
/** 启动时间戳(ms),早于此时间的消息一律丢弃 */
this._startTime = Date.now();
/**
* 去重层1:message_id 级别
* 防止飞书重推同一条事件(message_id 相同)
*/
this._handledIds = new Set();
/**
* 去重层2:内容级别 Map<chat_id, text>
* 只记录每个会话上一条内容,连续发相同内容才过滤
* 中间插了别的内容则不算重复,正常处理
*/
this._lastText = new Map();
}
// ──────────────────────────────────────────
// 启动监听
// ──────────────────────────────────────────
/**
* 启动 WebSocket 长连接,开始接收飞书消息
*/
start() {
logger.info("[Feishu] 正在连接 WebSocket...");
this._wsClient.start({
eventDispatcher: new Lark.EventDispatcher({}).register({
// 注册消息已读事件(空处理器,消除 SDK "no handler" 警告)
"im.message.message_read_v1": (_data) => {},
/**
* 监听「接收消息」事件
* 文档:https://open.feishu.cn/document/uAjLw4CM/ukTMukTMukTM/reference/im-v1/message/events/receive
*/
"im.message.receive_v1": async (data) => {
await this._handleIncoming(data);
},
}),
});
logger.info("[Feishu] WebSocket 已启动,等待消息...");
}
// ──────────────────────────────────────────
// 私有:处理收到的消息
// ──────────────────────────────────────────
/**
* @param {object} data 飞书事件原始数据
*/
async _handleIncoming(data) {
const { message } = data;
const { message_id, chat_id, content, message_type } = message;
// ── 过滤历史消息:只处理启动后收到的消息 ──
// 飞书重连时会补推历史消息,一律丢弃
const msgTimeMs = Number(message.create_time); // 飞书时间戳单位:毫秒
if (msgTimeMs < this._startTime) {
logger.debug(`[Feishu] 历史消息跳过,消息时间=${new Date(msgTimeMs).toISOString()}`);
return;
}
// ── 去重层1:同一 message_id 只处理一次(防飞书重推同一事件)──
if (this._handledIds.has(message_id)) {
logger.debug(`[Feishu] 重复事件,跳过 message_id=${message_id}`);
return;
}
this._handledIds.add(message_id);
if (this._handledIds.size > 1000) this._handledIds.clear();
// ── 去重层2:连续发送相同内容才算重复 ──────────────────────
// 只对比同一 chat_id 的【上一条】内容,中间插了别的内容就不算重复
const userTextRaw = (() => { try { return JSON.parse(content).text?.trim() ?? ""; } catch { return ""; } })();
if (this._lastText.get(chat_id) === userTextRaw) {
logger.debug(`[Feishu] 连续重复内容,跳过 [chat_id=${chat_id}]: ${userTextRaw}`);
return;
}
this._lastText.set(chat_id, userTextRaw);
// ── 只处理文本消息 ───────────────────────
if (message_type !== "text") {
logger.debug(`[Feishu] 忽略非文本消息,类型: ${message_type}`);
return;
}
// content 格式:{"text": "用户输入内容"}
let userText;
try {
userText = JSON.parse(content).text?.trim();
} catch {
logger.warn("[Feishu] 无法解析消息内容", content);
return;
}
if (!userText) {
logger.debug("[Feishu] 空消息,跳过");
return;
}
if (CONFIG.feishu.logIncoming) {
logger.info(`[Feishu] 收到消息 [chat_id=${chat_id}]: ${userText}`);
}
// ── 调用 Agent 处理 ──────────────────────
let replyText;
try {
replyText = await this._onMessage(userText, { chat_id, raw: data });
} catch (err) {
logger.error("[Feishu] Agent 处理失败", err.message);
replyText = `⚠️ 处理出错:${err.message}`;
}
await this._reply(chat_id, userText, replyText);
}
// ──────────────────────────────────────────
// 私有:发送消息卡片回复
// ──────────────────────────────────────────
/**
* @param {string} chatId 目标会话 ID
* @param {string} userText 用户原文(用作卡片标题)
* @param {string} replyText 回复正文
*/
async _reply(chatId, userText, replyText) {
const { replyTitlePrefix } = CONFIG.feishu;
try {
await this._client.im.v1.message.create({
params: { receive_id_type: "chat_id" },
data: {
receive_id: chatId,
msg_type: "interactive",
content: Lark.messageCard.defaultCard({
title: `${replyTitlePrefix}:${userText}`,
content: replyText,
}),
},
});
logger.info(`[Feishu] ✅ 回复已发送 [chat_id=${chatId}]`);
} catch (err) {
logger.error("[Feishu] 发送回复失败", err.message);
}
}
// ──────────────────────────────────────────
// 私有:映射日志级别
// ──────────────────────────────────────────
/** 将我们的日志级别映射到 Lark SDK 的 LoggerLevel */
_larkLogLevel() {
const map = {
debug: Lark.LoggerLevel.debug,
info: Lark.LoggerLevel.info,
warn: Lark.LoggerLevel.warn,
error: Lark.LoggerLevel.error,
};
return map[CONFIG.log.level] ?? Lark.LoggerLevel.info;
}
}
/**
* @typedef {object} MessageContext
* @property {string} chat_id 飞书会话 ID
* @property {object} raw 飞书原始事件数据
*/
onMessage 是从外部注入的回调,FeishuClient 完全不依赖 Agent,两侧可以独立测试。
程序入口 src/index.js
js
/**
* ══════════════════════════════════════════════════════
* index.js --- 程序入口
* ══════════════════════════════════════════════════════
*
* 启动顺序:
* 1. 连接 MCP Server(获取工具列表)
* 2. 创建 Claude AgentRunner(注入 MCP 客户端)
* 3. 启动飞书 WebSocket(注入 Agent 作为消息处理器)
*
* 扩展建议:
* - 多用户并发:AgentRunner.run() 本身是无状态的,可直接并发调用
* - 多轮对话:将 messages 历史按 chat_id 维护在 Map 中
* - 限流/队列:在 onMessage 前包装一层 p-limit 或 Bull 队列
*/
import { McpClient } from "./mcp/client.js";
import { AgentRunner } from "./agent/runner.js";
import { FeishuClient } from "./feishu/client.js";
import { SkillManager } from "./skills/manager.js";
import { logger } from "./utils/logger.js";
async function main() {
logger.info("═".repeat(52));
logger.info(" Feishu × Claude × MCP Agent 启动中...");
logger.info("═".repeat(52));
// ── Step 1: 加载 Skill ────────────────────
// 从 CONFIG.activeSkill 读取,失败时立即抛错退出
const skillManager = new SkillManager();
const skill = skillManager.getActive();
logger.info(`[Main] 当前 skill: ${skill.name} --- ${skill.description}`);
// ── Step 2: 连接 MCP Server ───────────────
const mcpClient = new McpClient();
await mcpClient.connect();
// ── Step 3: 创建 Agent(注入 MCP + Skill)──
const agent = new AgentRunner(mcpClient, skillManager);
// ── Step 4: 启动飞书,注入 Agent 处理逻辑 ──
const feishu = new FeishuClient(
/**
* onMessage 钩子:每条飞书消息触发一次
* @param {string} userText 用户输入文本
* @param {MessageContext} ctx 飞书上下文(chat_id 等)
* @returns {Promise<string>} Claude 的最终回复文本
*/
async (userText, ctx) => {
logger.debug(`[Main] 开始处理消息,chat_id=${ctx.chat_id}`);
const reply = await agent.run(userText);
logger.debug(`[Main] 处理完成,chat_id=${ctx.chat_id}`);
return reply;
}
);
feishu.start();
// ── 优雅退出 ──────────────────────────────
const shutdown = async (signal) => {
logger.warn(`[Main] 收到信号 ${signal},正在关闭...`);
await mcpClient.close();
process.exit(0);
};
process.on("SIGINT", () => shutdown("SIGINT"));
process.on("SIGTERM", () => shutdown("SIGTERM"));
}
main().catch(err => {
logger.error("[Main] 启动失败", err.message);
process.exit(1);
});
启动顺序清晰,四步串联,任何一步失败都会 throw 让进程退出而不是静默失败。
本地测试 src/test-skill.js
开发阶段不想每次都打开飞书,用
test-skill.js直接在终端跑 Agent:
js
/**
* ══════════════════════════════════════════════════════
* test-skill.js --- Skill 独立测试脚本
* ══════════════════════════════════════════════════════
*
* 不启动飞书,直接在终端跑 Agent,快速验证 skill 效果。
*
* 用法:
* node src/test-skill.js # 使用 activeSkill
* ACTIVE_SKILL=translator node src/test-skill.js # 临时切换
*/
import { McpClient } from "./mcp/client.js";
import { AgentRunner } from "./agent/runner.js";
import { SkillManager } from "./skills/manager.js";
import { logger } from "./utils/logger.js";
// ── 测试用例:在这里添加你想测试的消息 ──────────────
const TEST_CASES = [
"给我讲个笑话",
"再来一个",
"这个不够冷,更冷一点",
];
async function main() {
logger.info("═".repeat(52));
logger.info(" Skill 测试模式(无飞书)");
logger.info("═".repeat(52));
// 加载 skill
const skillManager = new SkillManager();
const skill = skillManager.getActive();
logger.info(`当前 skill: "${skill.name}" --- ${skill.description}\n`);
// 连接 MCP
const mcpClient = new McpClient();
await mcpClient.connect();
const agent = new AgentRunner(mcpClient, skillManager);
// 逐条跑测试用例
for (const msg of TEST_CASES) {
const reply = await agent.run(msg);
// 用醒目格式打印最终回复
console.log(` 💬 输入:${msg}`);
console.log(` 🤖 回复:${reply}`);
}
await mcpClient.close();
logger.info("测试完成 ✅");
}
main().catch(err => {
logger.error("测试失败", err.message);
process.exit(1);
});
bash
npm run test:skill
# 临时测试其他 skill
ACTIVE_SKILL=translator npm run test:skill
运行效果
启动日志:
════════════════════════════════════════════════════
Feishu × Claude × MCP Agent 启动中...
════════════════════════════════════════════════════
[INFO ] [Skill] 已加载 "joker"(冷笑话段子手) temperature=1 maxTokens=256
[INFO ] [MCP] 连接成功,可用工具:加法, 设置转速
[INFO ] [Feishu] WebSocket 已启动,等待消息...
收到消息后:
────────────────────────────────────────────────────
[INFO ] [Agent] 👤 用户: 帮我算 4+3 (skill: joker)
────────────────────────────────────────────────────
[DEBUG] [Agent] 请求 Claude(messages=1 条,skill=joker)
[INFO ] [Agent] 🔧 调用工具: 加法 {"a":4,"b":3}
[INFO ] [Agent] ✅ 工具结果: 7
[INFO ] [Agent] 🤖 Claude: 4+3=7,数学告诉我们加法不会骗人 🧮
[INFO ] [Feishu] ✅ 回复已发送 [chat_id=oc_xxx]
skill例子
main.md
bash
---
name: main
description: 通用 MCP 执行助手,专注工具调用,默认操作 E:\agent_test 目录
temperature: 0.7
maxTokens: 1024
---
# MCP 执行助手
你是一个专注执行 MCP 工具的智能助手,忠实反馈工具结果,不加主观判断。
## 工作目录
默认工作目录为 `E:\agent_test`,涉及文件、目录相关操作时无需用户重复指定路径,自动补全。
## 行为规则
- 优先调用 MCP 工具完成任务,不用文字推测代替实际调用
- 忠实呈现工具返回结果,不质疑、不修正、不过度解释
- 工具调用失败时,如实告知错误信息,不自行编造结果
- 回复简洁,结尾附 2 个贴合场景的 emoji
- 全程使用中文
joker.md
bash
---
name: joker
description: 冷笑话段子手,专门讲让人沉默三秒的冷笑话
temperature: 1.0
maxTokens: 256
---
# 冷笑话段子手
你是一个专门讲冷笑话的段子手。
## 规则
- 每次只讲一个笑话,不超过 4 句话
- 笑话要足够冷,冷到让人沉默 3 秒才反应过来
- 结尾加一个 emoji 表情
- 如果用户夸你,继续讲下一个,不要重复
- 用中文
小结
| 模块 | 核心设计 |
|---|---|
config/ |
所有参数集中管理,支持环境变量覆盖 |
utils/logger |
四级日志,运行时可动态修改级别 |
skills/ |
.md 文件定义 AI 角色,frontmatter 控制参数 |
mcp/ |
工具协议封装,切换 Server 只改两行配置 |
agent/ |
Agentic Loop,驱动 Claude 多轮工具调用 |
feishu/ |
纯 I/O 层,通过回调与 Agent 解耦 |