Command Palette

Search for a command to run...

0GitHub stars
Blog

6 个只读 MCP 工具:一半功夫在说清它不保证什么

MCP 不提供智能,它提供接口。以 StyleKit 的 stylekit_lint_code 为例,讲清一个工具从 schema 到确定性规则再到结构化响应的链路,以及「通过」这两个字到底覆盖多大范围。

先把一件经常被混在一起的事拆开:MCP 约定的是工具发现、参数和结果传输。要不要调用、传什么参数,由模型或 host 决定;具体的检查、查询、计算,由工具服务器上的实现执行,再把结果返回去。

这个分工有个直接后果 —— MCP 本身不提供任何智能,它只负责把接口讲清楚。所以一个工具好不好用,很大程度上取决于它的接口有没有把边界说清楚,而不是取决于它能做多少事。

StyleKit 的 MCP Server 现在有 6 个工具,全部是只读的。下面拿其中的 stylekit_lint_code 走一遍完整链路。

一个工具从 schema 到结果

调用链其实很短:

MCP host / client
  → packages/mcp/src/index.ts  创建 McpServer,连接 stdio
  → packages/mcp/src/tools.ts  注册工具,Zod 定义输入 / 输出 schema
  → 校验 slug、确认规则是否可用
  → packages/mcp/src/data.ts   调用 stylekit-core 的 lintStyleCode
  → 返回违规类名、原因、行号与可用修复建议

这里有两层约束值得分开看。

Zod schema 是接口约束,它管的是参数长什么样、结果长什么样 —— 模型照着这个填,host 照着这个传。真正干活的是一段本地确定性规则函数,不涉及模型推理。这个区分很重要:工具的可信度来自"同样的输入必然得到同样的输出",而不是来自模型当时的状态。

这个工具还标了 readOnlyHint: true。只读意味着 host 可以放心调用它,不必担心它改动你的项目 —— 这是一条设计约束,不是实现细节。

一坏一好

演示这个工具,两组输入就够了。

会报违规的: 风格 slug 用 neo-brutalist,代码是

<div className="rounded-xl shadow-lg" />

这个风格的规则里禁用了圆角和阴影,所以工具应当返回被禁用的类名、原因,以及可用的修复建议。

合规的:

<div className="rounded-none border-2 border-black hidden md:block" />

这组应当没有违规。

两组对比能同时说明两件事:工具确实在按规则判断,以及它的输出是结构化的 —— 不是一段自然语言描述,而是可以直接被程序消费的违规列表。

「通过」覆盖多大范围

这是我认为最该反复讲的一点。

stylekit_lint_code 返回 ok: true,意思是当前规则没有报出违规。它不意味着这段代码好看,不意味着它满足可访问性要求,也不意味着产品验收通过。

三点具体的:

  • 它替代不了浏览器里的实际渲染 —— 布局是不是真的对了,规则看不出来。
  • 它替代不了视觉与无障碍验收 —— 对比度、焦点顺序、语义结构这些不在规则覆盖范围里。
  • 规则没覆盖到的情况会直接漏过去 —— 没报违规不等于没问题,只等于没被测到。

一个工具把能保证的说满、把不能保证的写清楚,比一个号称"能检查代码质量"的工具可靠得多。前者你知道什么时候该人工看一眼,后者会让你以为已经有人看过了。

评测链路和产品链路,不是一回事

StyleKit 里和检索有关的东西有两条,讲的时候很容易被合成一条,所以值得分开说。

产品侧的检索路径:MCP 的 stylekit_search_styles 走 searchStylesLive / stylekit-core/discovery,对外提供风格查询。

离线评测链路:把风格材料切成带元数据的 chunk,用同一个 tokenizer 处理文档和 query,BM25 出一路关键词排名,embedding 加向量库出一路语义排名,两路融合成一个排序,最后在标注 query 上算指标。

第二条链路里有个细节值得单独说。BM25 和向量召回的原始分数尺度不同,不能直接相加,所以这里用 RRF 按名次而不是按分数融合,形式是 score(d) = Σ 1 / (k + rank),默认 k = 60、candidateLimit = 50、topK = 10。

那为什么非要分开讲?

因为两条链路各自能支持的结论完全不同。评测链路产出的是 Recall@1、MRR、NDCG 这类离线指标,回答的是「这套方案在这份标注集上表现如何」;产品链路要回答的是「用户能不能找到想要的东西」。前者的数字不会自动变成后者的效果 —— 中间隔着真实的查询分布、真实的数据规模和真实的延迟预算。

tokenizer 是这一段里最容易翻车的地方。早期规则 split(/[^\p{L}\p{N}]+/u) 会把一整段连续中文当成一个 token,导致部分精确匹配的查询召回失败。当前实现改用 Intl.Segmenter 做词边界,并在搜索侧给长 CJK token 生成字符 bigram。这里有一条硬约束:文档和 query 必须共用同一个 tokenizer,两边不一致,打分就没有意义。(这个结论限于知识检索路径,和风格发现搜索是两回事。)

降级不是补丁,是接口的一部分

混合检索这条链路里,向量路径的服务可能没配置、调用失败或超时(默认 3 秒)。这时候的实现是:保留 BM25 的结果,标记为 degraded,并带上降级原因。

值得说的是"标记 degraded"这个动作。降级本身不难,难的是让调用方知道自己拿到的是降级结果。返回一份看起来正常的列表,和返回一份说明了自己残缺在哪的列表,对下游的可信度完全不同。这两者在代码里可能只差一个字段,在使用者那边差别很大。

几个我坚持的表述

做这类项目,最容易出问题的地方不是代码,是描述代码。

留了 hook 不等于功能上线。 默认路径没有调用 LLM reranker,代码里有一个可选 hook —— 那只能说明将来可以加,不能说明现在有。要不要加,取决于候选数、离线评测的实际增益、延迟和成本。

假向量不等于真实语义检索。 评测脚本需要真实 embedding 凭据;没有凭据时会走确定性假向量。假向量能让测试跑起来,但不能拿来当语义检索的效果证据。

离线评测不等于线上增益。 评测报告里的数字是在一份标注集上跑出来的。要说它对线上用户产生了什么影响,需要的是线上数据,不是把离线结论往前推一步。

这三条的共性是:把"仓库里有"和"线上生效"、"能跑通"和"效果好"分开。它们在代码里看起来是同一件事,在结论里是完全不同的强度。

所以工具设计到底难在哪

不难在写规则,难在知道这条规则不该管什么。

只读工具的价值在于它可以被放心地反复调用,代价是它能做的事很有限。所以这类工具的设计重点,不是把能力堆上去,而是让输入、输出和「不保证的部分」都能用一句话说清楚。代码可以很短,边界必须写死 —— 因为调用它的是一个不确定什么时候会误用接口的东西,你不说清楚,它就会自己假设。

Command Palette

Search for a command to run...