作者周末搭建了第一个 MCP Server 让 Claude Code 查询内部服务目录,发现给智能体提供工具本质是 UX 问题而非工程问题:工具命名、描述、返回值和错误提示的设计占了 14 小时中的 12 小时。
我花了整个周末构建了第一个 MCP server,让 Claude Code 能查询我们的内部服务目录,而不用我再把 JSON 复制粘贴到聊天窗口里。代码只用了两个小时。让 agent 真正用好它却花了剩下的十四个小时——而且几乎全花在了工具描述、输出大小和错误信息上,而不是 TypeScript。下面是这个可用的 server 和我会告诉过去自己的五条经验。
我一直卡在同一个蠢循环里。
Claude Code 每次执行到一半时会问类似这样的问题:"billing-events 服务归哪个团队所有,它的当前部署目标是什么?"这些数据在我们的内部服务目录里——一个部署在 VPN 后的只读 HTTP API。于是我会切换窗口,调用接口,用 jq 处理一下,把输出粘贴回去,然后看着 agent 继续。
每天十次。每次。
显而易见的解决方案是"给 agent 一个工具"。我低估的部分是:给 agent 一个工具是 UX 问题,不是接线问题。接线是已经解决好的、枯燥的、有完善文档的事情。真正的 UX——工具叫什么名字、它说自己是干什么的、它返回什么、失败时说什么——才是你真正花掉周末的地方。
一个有趣的约束:目录 API 返回很啰嗦。单个服务记录有约 40 个字段,其中大部分自 2023 年以来就没人看过。把这些全塞进 agent 的上下文,是用掉 8k token 来回答"谁负责这个"的正确方式。
给还没接触过 MCP 的人一个快速入门。
MCP(Model Context Protocol)是一个开放协议,用于向 LLM 客户端暴露工具、资源和 prompt。你写一个 server;客户端(这里是 Claude Code)启动它,询问它能做什么,然后调用它。重要的架构细节:server 是一个独立进程,传输层通常走 stdio——客户端启动你的进程,通过 stdin/stdout 发送 JSON-RPC。
flowchart LR
A[Claude Code] -->|spawns process| B[MCP server]
B -->|tools/list| A
A -->|tools/call| B
B -->|HTTPS| C[Internal catalog API]
C -->|JSON| B
B -->|text content| A
"通过 stdio 的独立进程"这个细节有一个后果会咬到每个人:你打印到 stdout 的任何东西都是协议流量。一条多余的 console.log 就会破坏数据流,你的 server 会报一个莫名其妙的解析错误然后挂掉。要打到 stderr。这个我后面会再说。
我用的是 TypeScript SDK(@modelcontextprotocol/sdk 1.x,Node.js 22.x)。下面是除了 API 客户端的完整 server,确实就这么小:
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import { z } from "zod";
import { fetchService, searchServices } from "./catalog.js";
const server = new McpServer({
name: "service-catalog",
version: "1.0.0",
});
server.registerTool(
"lookup_service",
{
title: "Look up a service",
description:
"Get ownership, deploy target, and on-call info for one service " +
"by its exact catalog name (e.g. 'billing-events'). Use this when " +
"you know the service name. If you only have a partial name or a " +
"team name, use search_services first.",
inputSchema: {
name: z.string().describe("Exact service name, lowercase-hyphenated"),
},
},
async ({ name }) => {
const svc = await fetchService(name);
if (!svc) {
return {
content: [
{
type: "text",
text:
`No service named "${name}". Service names are ` +
`lowercase-hyphenated. Try search_services with a partial name.`,
},
],
isError: true,
};
}
return { content: [{ type: "text", text: summarize(svc) }] };
},
);
await server.connect(new StdioServerTransport());
然后在仓库根目录的 .mcp.json 里,这样它会被提交进版本管理,我的整个团队都能用到:
{
"mcpServers": {
"service-catalog": {
"command": "node",
"args": ["./tools/catalog-mcp/dist/index.js"],
"env": { "CATALOG_TOKEN": "${CATALOG_TOKEN}" }
}
}
}
运行 claude mcp list 确认已连接,搞定。两小时,大部分时间在回忆 tsconfig 的模块解析是怎么工作的。
真正花掉周末的部分
第一个版本"能用"——工具可以调用,返回正确数据。但仍然没用, transcript 里这个模式告诉我问题在哪:
Me: Who owns billing-events? Claude: (calls get) → 6,200 tokens 的 JSON Claude: The billing-events service is owned by... let me check the owner_team_ref field... it's t_8813.
正确!但完全没用,贵,而且没能把团队 ID 解析成人名,因为我没给它这个能力。以下所有改进都是为了解决这个问题。
我最初写的描述是 description: "Look up a service"。那是 docstring,不是 description。
description 是模型在决定是否调用你的工具时唯一会读到的东西。它不是给会读源码的人类看的文档——它是被注入 agent 决策过程的一段 prompt。我的从 5 个词变成了 4 行,有用的补充全是关于路由的:
它期望什么输入格式(lowercase-hyphenated)
什么时候用这个工具 vs. 隔壁那个("如果你只知道部分名称或团队名,先用 search_services")
在我加上"先用 search_services"的提示之前,agent 会用"Billing Events"调用 lookup_service,什么都查不到,然后就放弃了。之后,它每次都会在第一次查询失败后自我修正。同一份代码,只是换了个句子。
如果你只调一个东西,就调 description 。它是整个 server 里每字符杠杆效应最高的工作。
那个 6,200 token 的数据 dump 才是真正的 bug。所以我不再返回 API 响应,而是返回一个摘要:
function summarize(svc: Service): string {
return [
`service: ${svc.name}`,
`owner: ${svc.ownerTeamName} (${svc.ownerSlackChannel})`,
`on-call: ${svc.oncallRotation ?? "none"}`,
`deploy target: ${svc.deployTarget}`,
`tier: ${svc.tier}`,
`repo: ${svc.repoPath}`,
`last deploy: ${svc.lastDeployAt}`,
].join("\n");
}
七行。约 60 token 而不是 6,200。注意 ownerTeamName——API 返回的是 owner_team_ref: "t_8813",所以 server 做第二次查询然后解析它。在你的 server 里做 join,不要在 agent 脑子里做。agent 要追的每个字段都是又一轮往返和又一次猜错的机会。
对于 search_services(可能匹配多行),我硬性限制结果为 20 条,并追加一行说明:
const shown = hits.slice(0, 20);
const note = hits.length > 20
? `\n\n(showing 20 of ${hits.length} matches — narrow your query)`
: "";
末尾的提示比限制本身更重要。一个被静默截断的列表对 agent 来说看起来像完整列表,它会自信满满地告诉你某服务不存在——因为它在第 21 条。
❌ Error: 404
✅ No service named "Billing Events". Service names are lowercase-hyphenated.
Try search_services with a partial name.
第一种让 agent 向我道歉。第二种让它自我修复然后重试——通常在同一轮对话里,不需要我介入。
MCP server 里的每个错误路径都应该回答:哪里出问题了,以及你应该怎么做?把 isError: true 的响应当作恢复指令,而不是状态码。这大概是对 server 改了 20 行,但它产生了单次最大幅度的提升——agent 在我没有介入的情况下完成问题的频率。
我的本能是映射 REST API:get_service、list_services、get_team、list_team_services、get_deploy_history。五个工具,一个端点一个。干净、对称,但错了。
结果:agent 链式调用三次来回答我本来一次就能回答的问题,而且大约有三分之一的时间它会选错第一个工具——因为从外部看它们之间的边界是模糊的。
我把它压缩成两个工具——lookup_service 和 search_services——把团队/部署查询折叠进去。选对工具的准确率基本达到 100%,因为剩下的有意义的决策只有一个:"我是知道确切名称还是不知道?"
这条规则的一般形态:
围绕用户会问的问题来设计工具,而不是围绕你的 API 暴露的端点。如果两个工具总是被一起调用,它们就是一个工具。
我宁愿有 2 个工具带一些内部分支,也不愿有 5 个工具让模型每轮都要区分。
最初几个小时我通过问 Claude Code 问题然后盯着答案看对不对来调试。这是一个糟糕的反馈循环——你和 bug 之间有两层非确定性。
MCP Inspector 解决了这个问题:
npx @modelcontextprotocol/inspector node ./dist/index.js
它给你一个 UI,列出你的工具并让你用手动写的参数调用它们,显示原始响应。现在这是一次正常的 API 调试会话:确定性输入,确定性输出,模型不在循环里。
还有那个 stdout 的事,因为它让我花了 40 分钟:server 里的一条 console.log 就会破坏协议。stdout 是传输层。你得到的是一个 JSON 解析错误,和你加的那行代码没有任何明显联系。在第一天就把它接好:
const log = (...args: unknown[]) => console.error("[catalog-mcp]", ...args);
任何不是协议消息的东西都打到 stderr。我现在在写任何其他代码之前先加上那行。
两个我正在做的东西。
资源,不只是工具。现在一切都是工具调用。但服务目录的 tier 定义和 deploy-target 词汇表是静态参考资料——这些更适合做成 MCP 资源,客户端可以直接附加到上下文而不用通过调用来回往返。
写访问,谨慎地来。目录有一个 PATCH 端点来更新 ownership,有个很诱人的场景是"agent 发现 ownership 过期了然后修掉它"。但同样也有一个明摆着会出岔子的方式。如果我做的话,会把它放在确认 prompt 后面加一个 dry-run 模式——返回 diff 但不实际应用——只读工具是宽容的,写工具不是。
我会给开始前的自己的总结:
描述就是 prompt。说清楚工具做什么、什么时候用、以及什么时候用另一个。
限制和塑形输出。摘要、在 server 端解析引用、截断时要明确说出来。
错误应该教人。"404"是死路;"试试 search_services"是恢复路径。
合并总是被一起调用的工具。建模问题,而不是建模端点。
用 Inspector 调试。把模型从循环里拿出来修管道。
协议本身真的很容易——如果你能写 Express handler,今天下午就能写一个 MCP server。把你的时间预算留给接口设计,因为那才是决定 agent 是用好你的工具还是只是用它的部分。
如果你正在构建自己的 MCP server 并遇到了什么奇怪的东西,评论里说一声——我想收集那些坑。关注我这里如果你想看 MCP 资源和安全写访问的后续;我边做边写。
使用的版本:@modelcontextprotocol/sdk 1.x,Node.js 22.x,TypeScript 5.x,Claude Code(2026-08)。