上一篇聊 MCP Server 的测试,结尾留了个钩子:可观测性。这篇把它补上。
先描述一个上线后最常见的场景:用户反馈"AI 助手答非所问",你怀疑是模型乱调了工具,但手头没有任何记录——不知道那次会话里模型看到了哪些工具、发了什么参数、工具返回了什么。你唯一能做的是在自己电脑上连一遍,复现看看。"感觉修好了",但你其实说不清刚才那次的真实原因。
传统 Web 服务有成熟的可观测性方案,日志、指标、链路追踪三大件。MCP Server 大体也能套用,但有两个本质差异,直接决定了方案怎么做:
这篇文章讲我落地的做法:协议内日志、结构化调用日志、按工具分维度的指标、可回放的审计记录。代码仍是 TypeScript 官方 SDK。
MCP 协议自带 logging 能力,这是很多人没注意到的。Server 声明 capabilities.logging 之后,可以随时向客户端推日志消息:
const server = new Server(
{ name: "todo-server", version: "1.0.0" },
{ capabilities: { tools: {}, logging: {} } },
);
// 工具执行中向客户端/用户报告进度和状态
await server.sendLoggingMessage({
level: "info",
logger: "todo-server",
data: "已扫描 120 个文件,开始匹配关键词",
});客户端(如果实现了 logging)会把消息展示给用户。这适合放过程可见的信息:长任务做到哪一步、准备批量处理什么。对用户来说,工具不再是"按下回车后黑箱三分钟"。
但要清楚它的边界:协议内日志是给"这次会话的用户"看的,不可检索、不会持久化(取决于客户端)、你自己的排查也用不上。所以本地结构化日志同样要做,两者不互相替代:
还有一个 stdio 特有的约束,测试篇提过再强调一次:stdio 模式下 stdout 是协议通道,本地日志只能写 stderr 或文件。日志框架配置错目的地,是 stdio server 的头号自毁方式。
核心原则:一次 tools/call 记一行结构化日志,够排查问题,但不复制用户数据。
type ToolCallLog = {
ts: string; // ISO 时间
sessionId: string; // 会话标识(自增即可)
tool: string; // 工具名
argDigest: string; // 参数摘要,不是参数全文
outcome: "ok" | "tool_error" | "thrown";
durationMs: number;
resultDigest?: string; // 结果摘要
};关键设计是 argDigest(参数摘要):
function digestArgs(args: Record<string, unknown>): string {
return Object.entries(args)
.map(([k, v]) => {
const s = String(v);
const shown = s.length > 24 ? s.slice(0, 24) + `…(${s.length})` : s;
return `${k}=${shown}`;
})
.join("&");
}记参数的"形状"而不记内容:路径记到文件名、查询记前 24 个字符加长度。排查"模型传了空参数""参数类型不对"这类问题完全够用,同时避免把用户邮件正文、数据库记录写进日志。如果某个工具确实需要完整参数才能排查,做成开关配置 + 默认关闭,并在文档里写明这会记录用户内容。
用装饰器把记录逻辑统一包住所有工具,避免每个 handler 里手写:
const timed = <T>(
name: string,
fn: (args: Record<string, unknown>) => Promise<T>
) =>
async (args: Record<string, unknown>) => {
const start = Date.now();
let outcome: ToolCallLog["outcome"] = "ok";
try {
return await fn(args);
} catch (e) {
outcome = "thrown";
throw e;
} finally {
log.info({
tool: name,
argDigest: digestArgs(args),
outcome,
durationMs: Date.now() - start,
});
}
};注意 outcome 只在 catch 里改标记、finally 里统一落日志——isError 形式的"业务失败"不抛异常,会在正常返回路径记录,想区分的话在返回前检查一次 result.isError 把它标成 tool_error。
有了结构化日志,指标就是聚合问题。最少要这三个,全部按工具名分维度:
tool_error + thrown / total。某个工具错误率突然抬升,往往是依赖的外部服务在抖动;为什么 P99 特别重要?模型客户端普遍对工具调用有超时,慢工具会触发模型侧重试,重试又把负载打回去,形成恶性循环。平均值 200ms 的工具如果 P99 是 8 秒,用户看到的就是"经常转圈失败",而你的平均值监控一片绿。
实践里我给每个工具设一个软预算(比如 3 秒),接近预算就先推协议内日志("正在查询更多数据…"),让用户和模型都知道工具还活着。
《MCP 权限模型》那篇讲过:工具粒度的授权需要知道"谁在什么上下文里调了什么"。落到实现就是审计日志,和第二节的运行日志相比,它多了两个诉求:
审计事件的最小集:时间、身份、工具名、参数摘要、结果状态、会话 ID。和运行日志几乎同构——所以我直接复用同一套 digest 逻辑,只是输出到不同 sink。每条记录加一行换行符分隔的 JSON(NDJSON),出安全事件时 grep 工具名 就能拉出完整时间线。
给一个务实的边界建议:单机个人工具不必上审计,运行日志足够;凡是"连了公司数据源、有多个使用者"的 server,审计从第一天就要有——它不能事后补。
最后把工具串起来。收到"AI 回答不对"的反馈时:
sessionId 过滤,看模型实际调了哪些工具、参数摘要是什么形状;tool_error → 工具自身问题,看错误率和下游依赖;这条路径的价值在于把"玄学问题"变成分层排查:先工具层,再模型层,而不是对着聊天记录猜。
清单版总结,给要上线的 MCP Server:
到这里,MCP Server 的工程化三篇(测试、可观测性,加上安全四篇)就齐了。下一篇可能聊聊部署形态的选择:stdio 子进程、本地 HTTP 和远程托管各自适合什么场景。
原创声明:本文系作者授权腾讯云开发者社区发表,未经许可,不得转载。
如有侵权,请联系 cloudcommunity@tencent.com 删除。