MCP的七宗罪:运营罪
Source: Dev.to
请提供您希望翻译的正文内容,我将按照要求将其翻译为简体中文并保留原有的格式、Markdown 语法以及技术术语。谢谢!
运营罪恶:懒惰(Sloth) 与 愤怒(Wrath)
这些罪恶属于此类,因为它们决定了实时 MCP 系统在压力下的行为方式:是诚实地失败、理智地恢复,还是在故障期间让操作员能够信任他们所看到的内容。
两者都会在系统受压时出现。
- 懒惰(Sloth) 隐藏问题,使用模糊错误、薄弱的校验或马虎的传输处理。
- 愤怒(Wrath) 将本可存活的问题通过盲目重试、重连风暴以及对不确定性的强硬反应放大。
当访问边界更严格时,下一个问题是系统在出错时的表现。这正是这两种罪恶接管的地方。
在 MCP 中,运营层尤其明显,因为传输和协议行为是产品的一部分。标准输入/输出(Std‑io)卫生、重连行为、可恢复性、通知以及会话处理,一旦模型对接接口上线,就不再是旁枝末节。
懒惰(Sloth)
懒惰 是指回避精确校验、具体错误以及基本的运营卫生。这种罪恶在代码审查时很少显得戏剧化;它通常看起来无害:
- 捕获块隐藏了细节。
- 校验规则被推迟。
- 调试打印输出到错误的流。
没有人认为自己在做危险的选择——他们认为自己在省时间。但 MCP 对马虎的边界容忍度极低。
如何发现它
- 当某些东西失败时,日志模糊、重复或毫无用处。
- 操作员不断看到诸如 “MCP error” 之类的通用错误,而不是实际的故障原因。
- 标准输入/输出集成神秘地中断,因为非协议输出泄漏到了
stdout。 - 团队花更多时间复现故障,而不是修复它们。
示例
这类情况经常出现在支持和运维队列中。客服代表在聊天中等待客户时请求助手获取客户 cus_1234,或工程师在排查时按 ID 请求最新的事件。在那一刻,错误输入、未找到 与 依赖宕机 是三种截然不同的情形,且对应不同的后续步骤。如果工具把它们合并为一个模糊的失败,用户就失去了正确响应所需的上下文。
改进前
server.tool("get_customer", async ({ id }) => {
try {
return await db.customers.findById(id);
} catch {
throw new Error("MCP error");
}
});改进后
class ToolError extends Error {
constructor(
public code: "invalid_input" | "not_found" | "dependency_unavailable",
message: string,
public retryable: boolean
) {
super(message);
}
}
server.tool("get_customer", async ({ id }) => {
if (typeof id !== "string" || id.trim() === "") {
throw new ToolError(
"invalid_input",
"id must be a non‑empty string",
false
);
}
try {
const customer = await db.customers.findById(id);
if (!customer) {
throw new ToolError("not_found", `customer ${id} not found`, false);
}
return customer;
} catch (error) {
console.error("get_customer failed", { id, error });
if (error instanceof ToolError) {
throw error;
}
throw new ToolError(
"dependency_unavailable",
"customer lookup is temporarily unavailable",
true
);
}
});同时,修复你的传输卫生
// 对于 stdio 服务器来说是错误的写法
console.log("server started");
// 对于 stdio 服务器来说是正确的写法
console.error("server started");在协议边缘诚实地呈现失败
// `code` 和 `retryable` 是该服务器错误合约的一部分,
// 不是 MCP 自动为你生成的字段。
function toMcpErrorResult(error: ToolError) {
return {
isError: true,
code: error.code,
retryable: error.retryable,
content: [{ type: "text", text: error.message }],
};
}如何修复
修复应从边界开始。对工具入口处的输入进行校验,而不是在请求已经变得难以推理的调用栈深处进行。当某些…
Source: …
g 确实会失败,请在可能的情况下保留真实的失败模式。运维人员需要有用的错误信息,而不是模糊的戏剧化错误;调用方需要了解 未找到 结果与依赖损坏之间的区别。
在实践中,这通常意味着:
- 稳定的错误码。
- 清晰的人类可读信息。
- 为内部诊断细节提供单独的存放位置。
将运维卫生视为合同的一部分。保持协议流量与诊断信息分离,尤其是在标准输入输出上,stdout 为数据,stderr 为日志。 在远程 HTTP 传输中,等效的规范是会话生命周期、重新连接行为以及可恢复性:如果这些不一致,即使处理程序本身是正确的,系统也会变得难以推理。
为格式错误的输入、缺失字段、下游超时以及未找到的情况添加负面测试,然后在服务器端统一错误结构,避免每个工具自行创造混乱的私有版本。关键在于,使类型化的失败能够穿过 MCP 边界而不被在输出时合并为单一的通用错误。MCP 为你提供传输层和结果通道;使失败可操作的稳定字段仍需作为你自己的服务器合同的一部分。
来自实战的经验
该模式出现在 modelcontextprotocol/typescript-sdk #699 中,真实的工具异常被误导性的 -32602 结构化内容错误所取代。 一旦系统开始对失败原因说谎,所有下游的调试步骤都会变得更加昂贵。 模糊的错误不再是有用的信号——它会成为一种负担。
Source: …
愤怒
愤怒 是对不确定性或失败的反应,以强硬而非控制的方式表现出来。你通常会在设计讨论中先听到愤怒的声音,再在代码中看到它的实现:
- “如果失败,就重试。”
- “如果慢,就加快轮询频率。”
- “如果流断开,立即重新连接。”
这就是失去耐心的运维版表现。
如何识别
- 在故障期间,日志中会出现重试循环或重新连接风暴。
- 单个失效的依赖可能突然导致请求重复、作业重复或服务器启动重复。
- 客户端不断敲击已经降级的端点。
- 超时图 与 请求量图 同时上升。
示例
在一次真实的故障中,内部应用或助手在有人已经在进行事件响应时失去了 MCP 连接。用户会看到一次掉线或一个转圈加载时间过长。实际上,一个不耐烦的客户端会把这一次中断转化为重复的进程启动、重复请求,进而给已经出现故障的系统增加更多负载。
之前
async function ensureConnection(client: McpClient, serverCommand: string) {
while (true) {
try {
await client.connect(new StdioTransport(serverCommand));
return;
} catch {
await sleep(100);
}
}
}之后
function sleepWithAbort(ms: number, signal: AbortSignal) {
return new Promise((resolve, reject) => {
const timeout = setTimeout(() => {
signal.removeEventListener("abort", onAbort);
resolve();
}, ms);
const onAbort = () => {
clearTimeout(timeout);
signal.removeEventListener("abort", onAbort);
reject(new Error("connection cancelled"));
};
if (signal.aborted) {
onAbort();
return;
}
signal.addEventListener("abort", onAbort, { once: true });
});
}
async function ensureConnection(
client: McpClient,
serverCommand: string,
abortSignal: AbortSignal
) {
for (let attempt = 1; attempt <= 5; attempt += 1) {
try {
if (abortSignal.aborted) throw new Error("connection cancelled");
await client.connect(new StdioTransport(serverCommand));
return;
} catch (error) {
if (abortSignal.aborted) throw error;
if (attempt === 5) throw error;
const backoffMs = attempt * 1000 + Math.floor(Math.random() * 250);
await sleepWithAbort(backoffMs, abortSignal);
}
}
}如何修复
- 学会停下来。 对重试次数设定硬性上限,并加入带抖动的渐进式退避,以防所有客户端同步重新连接。
- 线程取消。 将 abort 信号传播到每一次外发请求和长时间运行的操作,使系统能够在必要时停下来,而不是盲目升级。
- 决定哪些操作可以安全重试。
- 幂等的读取和重新连接尝试通常是安全的。
- 有副作用的操作应避免自动重试,除非你拥有显式的幂等键或其他去重机制。
- 让重试可见。 为重试次数、退避延迟和重新连接风暴添加监控和度量。如果不对它们进行测量,就只能等到生产环境硬核报错才发现问题。
同样的警告也适用于受管边缘和网关。限流、代理重试以及策略强制可以减轻损害,但它们并不能修复后端非幂等、对失败描述模糊或不安全的重复操作。
来自实战的教训
modelcontextprotocol/inspector #293– 连接导致服务器重复启动。modelcontextprotocol/inspector #723– 重新连接逻辑未保留足够状态,导致无法安全恢复。
教训很简单:重试是系统设计的一部分,而不是你在边缘随意贴上的创可贴。如果你的重试策略不够完善……
explicit,你实际上并没有一个。
为什么运营罪难以修复
运营罪通常需要共享基础设施,而不是孤立的补丁。
- 懒惰(Sloth)修复 通常意味着构建验证层、错误策略、结构化日志以及针对不良路径的测试框架。
- 愤怒(Wrath)修复 往往涉及传输客户端、作业运行器、后台工作者和 UI 状态处理等多个层面。你可能需要:
- 重试助手,
- 退避策略,
- 取消模型,
- 幂等性保护,以及
- 显示重试和重新连接的仪表盘。
这类工作容易被推迟,因为它们不容易演示。但一旦实现,所有后续工具的运行、调试和可信度都会变得更低成本。