把 800 行 index.ts 拆成 MCP 架构这件事,我踩了不少坑
上个月用 claude-sonnet-4-6 的 Tool Use 给团队 ESLint 加 AI 增强,一下午就跑通了 demo。爽了三天。然后产品说要加组件生成,再加代码审查自动提 PR comment,我打开那个 index.ts 一看------800 多行,tools 数组和 if-else 分支全焊死在一起,每加一个能力就要改三处。完犊子了。
Client 端怎么串
Agent 主循环------整个系统的心脏
怎么说呢,这部分看着代码量不大,但坑的密度最高。本质就是一个 while 循环:给 LLM 发消息,看返回里有没有 tool_use block,有就转发给 MCP Server 执行,把结果拼成 tool_result 追加到对话,再来一轮。
ts
async function agentLoop(
anthropic: Anthropic,
mcpClient: Client,
mcpTools: Tool[],
userMessage: string
) {
const claudeTools = mcpTools.map(t => ({
name: t.name,
description: t.description ?? '',
input_schema: t.inputSchema as Anthropic.Tool['input_schema']
}))
const messages: Anthropic.MessageParam[] = [
{ role: 'user', content: userMessage }
]
let keepGoing = true
while (keepGoing) {
const response = await anthropic.messages.create({
model: 'claude-sonnet-4-6-20250514',
max_tokens: 8192,
system: SYSTEM_PROMPT,
tools: claudeTools,
messages
})
messages.push({ role: 'assistant', content: response.content })
if (response.stop_reason === 'end_turn') {
keepGoing = false
break
}
const toolUseBlocks = response.content.filter(
(b): b is Anthropic.ToolUseBlock => b.type === 'tool_use'
)
if (toolUseBlocks.length === 0) {
keepGoing = false
break
}
const toolResults: Anthropic.ToolResultBlockParam[] = []
for (const block of toolUseBlocks) {
const result = await mcpClient.callTool({
name: block.name,
arguments: block.input as Record<string, unknown>
})
toolResults.push({
type: 'tool_result',
tool_use_id: block.id,
content: result.content as Anthropic.ToolResultBlockParam['content']
})
}
messages.push({ role: 'user', content: toolResults })
}
return messages
}
三个坑,一个比一个隐蔽。
第一个:tool_result 必须放在 role: 'user' 的消息里。
第二个:模型一次回复里可能包含多个 tool_use block------比如先 read_file 再 lint_and_fix,你得全部执行完把结果一起返回,只处理第一个的话?模型直接懵掉,后续输出全是胡话。
嗯,继续。
第三个涉及 stop_reason 的判断。正常来说 stop_reason === 'tool_use' 的时候就表示模型在等你执行 tool,end_turn 表示说完了。我两个条件都判断了,多一层保险,因为在调试阶段遇到过一些奇怪的边界情况,虽然后来复现不出来了。
System Prompt 不是随便写写
这东西容易被忽略但真的关键。prompt 写得好不好直接决定模型会不会瞎调 tool:
ts
const SYSTEM_PROMPT = `你是一个前端工程 AI Agent,可以通过工具来分析和修改代码。
工作流程:
1. 用户描述需求后,先用 read_file 了解相关代码
2. 如果是代码审查任务,用 lint_and_fix 获取问题列表,逐个分析
3. 如果需要修改代码,用 write_file 写入修改后的内容
4. 修改完成后再次运行 lint_and_fix 验证修复是否有效
重要约束:
- 每次修改前必须先读取文件当前内容
- 不要一次修改超过 3 个文件,分批处理
- 遇到不确定的问题,先用 read_file 看上下文再决策
- 修复 lint 问题时优先使用 autoFix,人工修复仅用于 autoFix 无法处理的情况`
"不要一次修改超过 3 个文件"这条是我自己加的经验规则。不加的话模型有时候会一口气改十几个文件,改着改着上下文就hold不住了,开始出现幻觉式的代码。限制步长反而效果好。3 这个数字是不是最优解?不知道。够用了。
场景一:代码审查干到自动修复
diff 太大的问题
跑通之后第一个撞上的墙就是 diff 体积。
我最后用了分层策略------先拿文件列表,也就是 filesOnly: true,让模型自己判断优先看哪些文件,然后逐个 read_file。比一股脑全灌进去效果好太多了,模型竟然真的会根据文件名和路径判断哪些更可能有问题。
ts
// 别这样干------一次塞全量 diff 进去
// const diff = await getDiff({ filesOnly: false })
// 让模型自己挑文件看
const prompt = `这个 PR 修改了以下文件,请逐个审查关键文件:
${fileList}
对每个文件:
1. 先 read_file 看完整内容
2. 再 lint_and_fix 检查问题
3. 给出审查意见`
不是所有问题都该自动修
讲道理,semi 和 quotes 这种纯格式问题让 ESLint --fix 直接处理掉就完了,根本不需要浪费 LLM 的 token 去"思考"一个分号该不该加。但 no-any 这种呢?你得理解业务逻辑才能给出正确的类型标注------先别急着反驳,这种事 ESLint 的 autofix 搞不定,得交给模型。
所以我在 server 端做了个简单的分类:
ts
const AUTO_FIXABLE_RULES = new Set([
'semi', 'quotes', 'indent', 'comma-dangle',
'no-trailing-spaces', 'eol-last', 'no-multiple-empty-lines',
'@typescript-eslint/consistent-type-imports'
])
function classifyIssues(messages: LintMessage[]) {
return {
autoFixable: messages.filter(m => AUTO_FIXABLE_RULES.has(m.ruleId ?? '')),
needsReview: messages.filter(m => !AUTO_FIXABLE_RULES.has(m.ruleId ?? ''))
}
}
Server 做多了还是做少了
这个问题我纠结了很久。
一开始我的思路是让 server 尽量"聪明"------lint_and_fix 不光返回错误列表,还在 server 端排优先级、分类、甚至生成修复建议再一起返回。后来发现这条路走不通。
两个原因。第一,server 端的规则逻辑跟 LLM 的判断标准会打架,你觉得 severity: error 最重要应该先处理,模型可能觉得某个 warning 级别的逻辑问题才是关键,两边标准不一致的时候输出一团糟。第二,server 端逻辑复杂了调试起来想死------MCP Server 跑在子进程里,你 console.log 打印的东西跑到 stdio 上去了,会被 client 端当成协议消息解析。够呛。我的日志直接把 JSON-RPC 通信搞崩了,排查了两小时才意识到问题出在哪。
最后定下来的原则很简单:server 只干确定性的活儿,需要判断的全交给模型。
arduino
Server 负责(确定性操作): 模型负责(需要判断的):
├── 读写文件 ├── 决定先看哪个文件
├── 运行 ESLint / Prettier ├── 分析问题严重程度
├── 解析 AST 提取结构 ├── 设计修复方案
├── 执行 git 命令 ├── 生成代码
└── 返回原始数据 └── 规划下一步动作
stdio 和 SSE 怎么选
MCP 支持两种传输。stdio 是默认的,server 当 client 子进程跑,不对,应该说是LINE_CODE_73__ 是默认的,server 当 client 子进程跑。SSE 走 HTTP,server 独立部署。
好吧这个问题比我想的复杂。
我一开始用 stdio,因为简单。
ts
import { SSEServerTransport } from '@modelcontextprotocol/sdk/server/sse.js'
import express from 'express'
const app = express()
app.get('/sse', async (req, res) => {
const transport = new SSEServerTransport('/messages', res)
await server.connect(transport)
})
app.post('/messages', async (req, res) => {
await transport.handlePostMessage(req, res)
})
app.listen(3100)
稳定性------别让 Agent 翻车
token 超限
agent 循环跑多了消息历史像滚雪球一样膨胀。一个中等复杂度的审查任务可能 10 轮以上的 tool call,每轮 read_file 读回来的文件内容全存在 messages 数组里。跑到一半 context_length 超限,前面做的全白费。
我写了一个很粗糙的上下文裁剪函数,但它管用:
ts
function trimMessages(
messages: Anthropic.MessageParam[],
maxTokenEstimate: number = 150_000
) {
const estimateTokens = (msg: Anthropic.MessageParam) => {
const text = JSON.stringify(msg.content)
return Math.ceil(text.length * 0.7)
}
let total = messages.reduce((sum, m) => sum + estimateTokens(m), 0)
while (total > maxTokenEstimate && messages.length > 7) {
const removed = messages.splice(1, 1)
total -= estimateTokens(removed[0])
}
return messages
}
思路就是保住头和尾------第一条是用户原始需求不能丢,最后六条是最近的交互上下文不能丢,中间的历史轮次?删了就删了。模型要是后面又需要之前读过的文件内容,它会再调一次 read_file,不影响最终结果。text.length * 0.7 这个系数是拍脑袋定的------好吧这个有点绕,中英文混合场景下误差大概在 20% 以内。够用了。
死循环
模型偶尔会陷进去------调 lint_and_fix 发现错误,改了,再调,还有错误(改出新 bug 了),再改,再查。循环往复。
硬限制伺候:
ts
const MAX_ITERATIONS = 15
let iteration = 0
while (keepGoing && iteration < MAX_ITERATIONS) {
iteration++
// ... agent loop
}
if (iteration >= MAX_ITERATIONS) {
console.warn('Agent 达到最大迭代次数,强制终止')
}
15 轮。
异常处理不能少
MCP Server 里的 tool handler 抛异常怎么办?总不能让整个 agent 进程跟着崩。
ts
for (const block of toolUseBlocks) {
try {
const result = await mcpClient.callTool({
name: block.name,
arguments: block.input as Record<string, unknown>
})
toolResults.push({
type: 'tool_result',
tool_use_id: block.id,
content: result.content as any
})
} catch (error) {
toolResults.push({
type: 'tool_result',
tool_use_id: block.id,
content: [{ type: 'text', text: `工具执行失败: ${error.message}` }],
is_error: true
})
}
}
关键在 is_error: true,加了这个字段模型就知道这次调用挂了,会尝试换思路或者跳过。不加的话?模型会把错误堆栈当正常返回值理解,然后基于一段 TypeError: Cannot read properties of undefined 给你分析代码逻辑。场面一度非常魔幻。