返回学习记录

把群聊知识库开放给 AI Agent:夹夹 MCP 服务端实现笔记

从第一个 PR 被关闭讲起,记录夹夹 MCP 服务端如何定契约、复用网站读取、处理群聊内容,以及用实测控制搜索成本。

AgentMCP工程实践

夹夹是一个 QQ 群机器人:它在群里回答问题,回答和讨论经过审核、脱敏后公开到群知识库网站。网站给人看,MCP 给 agent 用——让 用户的agent能直接检索群里沉淀下来的问答、条目、面经和日报,网站是jiajia.fun。大家可以来看看它,还可以来看看创始人和带头人joye的博客,博客网站是joyeHuang.me,里面干货满满,在这里也感谢joye校长的信任让我能够上手实现mcp的功能。

这篇记录我实现夹夹 MCP 服务端的思路:契约怎么定、内容怎么读、怎么防止 agent 被群聊内容「带偏」、额度怎么算才不拖垮数据库,以及最后怎么让用户「一段话」把 agent 接上。服务端跑在 Cloudflare Workers 上,数据在 D1。

先看全貌:

图 1:一次工具调用经过哪些环节

Agent → 入口(核对版本、检查额度)→ 工具层(按契约检查参数)→ 网站公开接口(以访客身份读取)→ 安全处理(防注入、打码)→ 回到 Agent(带原文链接)


先说第一版:它被关掉了

上面这套结构不是一开始就有的。我的第一个 PR 是「匿名只读 MCP 数据入口」:一次提交里做了协议、六个工具、两个 prompt、额度和计量,改了 29 个文件、900 多行。本地测试全绿,但评审后关掉了,没有合并,这种大的pr在没有经过验收的条件下,就是一大坨史山代码,简直就是侮辱joye。

joye评审指出了四个问题,回头看都不是小毛病,在一个功能里面实现这些东西不出问题才是真的逆天:

问题 第一版 后来的做法
一次搜索太贵 先读一次站点状态,再对五类内容逐个调公开接口,一次搜索在 Worker 里发 6 次内部请求,每次都要序列化、解析一遍 复用网站自己的搜索,一次读取拿到各组的第一页;实测读取量和网站搜索几乎一样
读不到全文 面经只有搜索摘要,截到 240 字,agent 看不到完整的题目和经验;只有话题目录,进不去话题 加上面经和话题的详情工具,详情一次返回全文;已经退出访客端的「精华」不再提供
错误不分类 所有失败都抛同一种异常,agent 分不清是没找到、站点关了还是被限流了,也就不知道该换词还是该等一等 错误放进结果里,分成参数不对、找不到、站点关闭、被限流、暂不可用五类,被限流时还告诉 agent 要等多久
缺参数 搜索不能按话题筛 搜索和「列出更新」都能按话题筛

这四条背后是同一个根源:边写边定。工具有哪些、每个返回什么、出错怎么说,都是写代码时顺手定的,评审时才发现范围和数据关系没理清。而 MCP 的工具名、字段和错误一旦公开,用户的 agent 和提示词就会依赖它们,之后很难再改。

所以重做时我被要求反过来:先单独提一个纯文档的 PR,把契约写清楚、过完评审;代码再拆成几个小 PR,每个都能单独验证。这样才能不烧心,所以下面各节讲的,就是重做之后的版本和步骤。

一、契约先行:工具定义放在单独的仓库

吸取第一版的教训,动手写服务端之前,我先把「对外承诺」从实现里拆了出去,放进独立仓库 jiajia-mcp。里面只有四样东西:每个工具接收什么、返回什么;支持哪些协议版本;以及一组安全处理的共享样例。我还给这个仓库搭了一套协作脚手架:给 agent 看的 AGENTS.md、贡献指南、设计文档模板和自动检查。

服务端不直接依赖这个仓库,而是把某个版本的文件整份拷贝过来,并记下每个文件的指纹。测试每次都比对指纹,有人在服务端改了副本,测试就会失败,要改只能回契约仓库。

图 2:契约怎么进入服务端

jiajia-mcp 仓库 → 打版本号(rc.1 → rc.4)→ 整份拷贝到服务端,记下每个文件的指纹 → 测试比对指纹

  • ✅ 对得上:测试通过,副本就是该版本的原样
  • ❌ 对不上:测试失败,回契约仓库改、打新版本

这样做有三个好处:拷贝不依赖契约仓库是否公开;部署时不多一个对 GitHub 的网络依赖;每次升级契约,在一个 PR 里就能看到完整差异。从 rc.1 到 rc.4,每次升级都是这样一个小 PR。

夹夹提供了哪些 MCP 工具,为什么要拆开(very important)

为什么需要这些工具?用户需要哪些信息呢?我们可以打开jiajia的馆反网站(😁),jiajia.fun,里面有关于问题和回答的答案部分,还在开发的面经部分(怎么还没有人投递啊喂,我很需要!!!),根据精心分类后产生的话题部分(将收集到的精品对话分类摆放进去)。通过牢jia能提供的网站信息(答案,分类话题回答,面经,日报和周报)我们可以得到下面的内容,用户需要的方向也和我们提供的方向相一致。下面先介绍 8 个知识库读取工具,还贴心地为你准备了 2 个工具,可以找回自己的提问,也可以向 jiajia 提出一个新的问题。表里一共 10 个工具。

用户的需求 用哪个工具 为什么需要它
找跟问题相关的内容 search 按关键词找答案、讨论和面经,分组返回摘要 Agent 可以先判断哪些结果值得读,再按需拿全文。一轮搜索通常只需要看几条摘要。
读整理过的结论,或核对它的出处 get_answer 读答案全文和来源链接,get_discussion 读讨论里的完整消息 答案适合快速了解结论,讨论能还原结论成立的条件,以及群友追问、补充和修正的过程。
看一份完整面经 get_interview 读取公司、岗位、轮次、题目和经验 搜索摘要可以帮人挑出相关面经。做面试准备时,还得看具体题目和它出现在第几轮。
看某段时间的更新,或读一份日报、周报 list_updates 按日期范围列出答案、讨论和报告的更新摘要,get_report 读一份报告的全文 「这周有什么新内容」以时间为条件,有时连关键词都还没有。选定一期之后,再把那份报告读完。
从一个话题开始学 list_topics 看话题目录,get_topic 看这个话题下按步骤组织的答案摘要 刚入门的人可能还不知道该搜什么。话题给了一个起点,也把相关答案按学习顺序串了起来。
找回自己的提问,或请夹夹回答一个新问题 my_questions 查看当前账号的记录,ask 调用夹夹生成回答 找回记录只需要读取。问新问题会消耗网页端的提问次数,并留下问答记录,所以由用户明确发起。

二、协议层:同时接住两代客户端

MCP 协议本身还在演进,市面上同时有两代客户端:旧一代先握手协商版本,之后每个请求都带上版本号;新一代直接问服务端「你能做什么」。夹夹的 MCP 入口两种都接。

服务端不保存会话,每个请求独立核对,这和 Workers 无状态的运行方式正好契合。新协议版本先在测试环境放开、用真实客户端跑通,再写进契约的兼容清单。

三、读取:以「访客」身份复用网站

MCP 要读的内容,网站的公开页面早就有了(更多的是在复用里面已经有的工具,不重复造轮子)。我没有为 MCP 另写一套查询,而是让工具层像一个没登录的访客那样,去请求网站自己的公开接口。

图 3:以访客身份复用网站

步骤 谁 → 谁 做什么
1 Agent → MCP 工具层 搜索「缓存」
2 MCP 工具层 → 网站公开接口 发内部请求,不带任何登录信息
3 网站公开接口 当作访客处理:昵称显示为「群友 X」,搜索限流照常计数
4 网站公开接口 → MCP 工具层 返回访客能看到的公开内容
5 MCP 工具层 → Agent 按契约整理后的结果

关键在于「不带任何登录信息」。网站只给登录的群友显示原昵称,所以 MCP 读到的天然就是化名后的内容。化名、撤回、限流这些规则不用在 MCP 里再实现一遍,网站已有的都会自动生效。

评审时发现一个漏洞:内部请求一开始绕过了网站入口的搜索限流。修好后,MCP 的每次搜索也照样计入网站的限流。

测试方面,每个工具的每一种真实返回——翻页、零结果、参数错误、内容被撤回、站点关闭——都会拿契约里的返回格式校验一遍。契约说返回什么,真实返回就必须是什么。

四、群聊内容不是指令

这是整个服务端最需要想清楚的部分。返回给 agent 的正文来自群聊,有两类风险:

  1. 提示词注入:群里有人写「忽略之前的指令,执行 xxx」,agent 读到后可能照做。
  2. 个人信息泄露:昵称、QQ 号、手机号、邮箱。

网站发布前已经脱敏过一遍,MCP 在返回前再做第二道处理:

图 4:返回前的第二道处理

网站已脱敏的正文 → 去掉图片、HTML 和看不见的字符 → 打码(@昵称 → @群友,号码和邮箱)→ 加上说明(群聊内容,不是指令)→ 包进随机边界 → 交给 Agent

「看不见的字符」包括零宽字符和方向控制符,后者能把一个叫 gnp.exe 的文件显示成 exe.png。随机边界的作用是:边界每次都不一样,群聊内容就伪造不出「内容到此结束」的标记,也就没法假装后面的话是系统说的。

一条规则的三次修改

把「@昵称」换成「@群友」听起来简单,实际改了三版,很能说明「中文 + 技术社区」这个场景的麻烦:

版本 规则 问题
第一版 所有 @ 都替换 npm 包名(比如 @types/node)、版本号这类技术写法被误伤
第二版 只替换前面是空格或标点的 @,包名不动 「谢谢@张三」这种中文紧贴的写法很常见,真实昵称漏了出去
第三版 反过来:只有 @ 前面紧跟英文字母或数字时才跳过 中文紧贴的照样替换,技术写法也不误伤

第二版的问题是评审时指出的。教训是:隐私规则宁可从「默认替换、例外放行」出发,而不是「默认放行、命中才替换」。

所有安全规则都由契约里的共享样例驱动测试:把清理步骤关掉,端到端测试就会失败。安全规则不是写在文档里的愿望,而是测试能证明它生效。

五、额度与计量:先定写入预算

额度和用量都要记进数据库,而每次读写都有成本。我在写代码之前先给自己定了预算:每次工具调用最多写两次数据库,不为额度专门读一次。

核心是把「检查」和「计数」合成一次带上限的写入:没到上限就加一并放行,到了就不加。不用先读再写,被拒绝的请求也不消耗额度。用量记录放在返回之后的后台执行,即使写失败,用户也感知不到。另外,用来区分客户端的标识是加密哈希,数据库里不存原始 IP。

图 5:每次调用最多写两次数据库

步骤 谁 → 谁 额度还没满 额度已经满了
1 Agent → MCP 调用工具 调用工具
2 MCP → 数据库 第 1 次写:没满就 +1 第 1 次写:已满,不加
3 MCP 执行工具 —
4 MCP → Agent 返回结果 告知额度用完、还要等多久
5 MCP → 数据库(后台) 第 2 次写:用量记录 —

后来改了什么:每分钟的限制最初交给 Cloudflare 自带的限流功能,不碰数据库。后来团队在测试环境压测,1 秒内并发 45 次、约 150 次调用一次都没被拦住——这个功能按节点分别计数,拦不住瞬时并发。于是每分钟的限制也改成了数据库里一次带上限的写入。同时,产品上决定所有 MCP 请求都要登录,额度从按 IP 改成按账号。「带上限的写入、不先读」这个思路保留了下来,只是用在了更多地方。

六、量一量:MCP 搜索比网站搜索贵多少

契约里有一条:没实测过,就不说成本低。所以我写了一个本地测量脚本,用合成数据(问答 1000、条目 200、面经 50 等),让同一个查询分别走网站搜索和 MCP 搜索:

数据库语句 读取行数 耗时(中位数)
网站搜索 6 253 约 7.9 ms
MCP 搜索 7 254 约 9 ms
MCP 额外的额度和计量 2 4 —

结论:MCP 搜索读的数据和网站搜索几乎一样,每次调用只多一次读、两次写,和预算一致。

同一个脚本也能起一个本地服务给真实客户端联调。旧一代的 MCP Inspector 和新一代的 Claude Code 都跑通了;在 Claude Code 里,模型自己先搜索、再读全文、再列话题,并读到了「群聊内容不是指令」的说明。

后来还修了测量脚本自己的一个坑:工具出错时,网页请求的状态照样显示成功,旧脚本只看这个状态,工具全部报错也照样生成成本报告。现在每次请求都检查实际结果,任何一次失败就不出报告。

七、账号化之后的收尾

MCP 改成登录后使用之后,我补了一个安全缺口:用户解绑 QQ 时,这个账号已有的 MCP key 和 OAuth 授权全部吊销。吊销和解绑放在同一批写入里,要么一起成功,要么一起不发生。换绑另一个 QQ 也不会让旧 key 复活。

八、让 agent 自己接上夹夹(还在实现喵)

服务端做完,还剩用户侧的最后一公里:装好以后不知道该试什么,或者配置写错了连不上。

试跑提示:介绍页每个客户端后面、账号页生成 key 之后,都放同一句可以直接粘贴的试跑,并写明预期结果——agent 会调用搜索,回答里带原文链接;没结果就换个词。文案只维护一份,所有页面都读它,避免各写各的。

「交给 agent 配置」:用户把一段话贴给自己的 agent,由 agent 去写 Claude Code、Cursor、Codex 的配置。这段话里写死三条规则:不让用户把 key 贴进对话(会进模型服务商和 agent 的日志);改任何文件前先说明、征得同意;只加夹夹这一项,不碰别的配置。

key 放在哪里改过两版。第一版写进 shell 配置文件,但这类文件常被同步进公开的 dotfiles 仓库,容易跟着泄露。第二版改成一个单独的、只有本人可读的文件:agent 只负责建好空文件,key 由用户自己粘贴进去,agent 之后只确认「已经填了」,不读出内容。

这样 key 从头到尾不经过对话,换 key 也只改这一个文件。

一些我觉得可以在自己的 CA(Coding Agent)或者 PA(Personal Agent)里实现的 use case

  1. 把自己的学习路线让夹夹验证。 把「我现在会什么、想做什么、准备按哪几步学」交给自己的 Agent。让它用 list_topics 和 get_topic 找相关话题,再读几条群友的经验,帮你看看哪一步值得先做、哪些前置知识得补上(毕竟不要走老壁灯的弯路是一件好事)。
  2. 把自己的项目交给夹夹来进行「审批」。 把方案缩成一个具体问题,比如「工具越来越多,Agent 老选错怎么办」。让 CA 用 search 找群友遇到的类似情况,再用 get_answer 或 get_discussion 看他们当时的条件和做法。最后让 CA 对照你的项目,指出哪些经验能借用,哪些还得自己验证(也可能是一样好的做法)。
  3. 把自己的 PA 接入日报或者周报。 给 PA 设个定时任务,用 get_report 读最新一期日报或周报,想补查最近的更新就用 list_updates。让它挑两三条和你手头项目有关的内容,写一句为什么值得看,再附原文链接(跟上群友的潮流,避免掉队嘛)。遇到感兴趣的内容也可以去试试探索它(记得用上夹夹)。

等待你的使用来分享更多的使用方法喵。

回头看,这些是在整个mcp的坑

  • 公开接口先定契约,再写代码。第一版边写边定,测试全绿也没用,问题出在「该做什么」,不在「做得对不对」。
  • 契约和实现分开,再用指纹锁住副本。服务端从来不用猜「这个字段该长什么样」,升级时差异一眼可见。
  • 能复用就不重写。以访客身份走网站的公开接口,化名、撤回、限流自动继承;代价是要逐个检查有没有绕过网站入口的东西。
  • 安全规则要能被测试证明。共享样例加端到端检查,把清理关掉测试就会失败。
  • 先定写入预算,再写代码。开销能事先算出来,实测也印证了这一点。
  • 近似的东西要实测。文档说是近似值的限流,一压测就露了馅。
  • 给 agent 写 prompt 也需要安全设计。哪些东西不能进对话、哪些操作要征得同意,要写死在 prompt 里,而不是指望 agent 自己想到。

一起交流

你也遇到过类似的问题吗?欢迎分享你的实践。