MCP 关闭连接时,Shutting 日志触发 JSON 解析错误
stdio 工具的退出日志进入 stdout,会被当成 MCP 消息解析。区分服务器日志与宿主输出,核对 SDK 1.29.0 的关闭路径、正确等待连接和错误处理。
Agent 通过 stdio 启动工具服务器,工具能连接,退出时却出现这样的堆栈:
SyntaxError: JSON Parse error: Unexpected identifier "Shutting"
deserializeMessage
processReadBuffer
这里的 Shutting 通常是退出日志的开头。只有确认这段文字来自工具子进程的 stdout,才能按日志污染处理;宿主客户端自己在终端打印一行日志,并不等于把文字送进了 MCP 连接。
下面按 TypeScript SDK @modelcontextprotocol/sdk 1.29.0 的 stdio 实现说明关闭路径,并采用 MCP 2025-11-25 的传输规则。不同版本可能有不同解析和清理行为,先从项目锁文件确认实际装载的包,不能仅凭报错文字认定当前 SDK 都有同一个缺陷。
工具进程的 stdout 不能用来打印日志
stdio 模式下,客户端启动服务器子进程,用它的 stdin 和 stdout 交换 MCP JSON-RPC 消息。消息按换行分隔,但每一行都必须是有效 MCP 消息;普通文字、启动横幅,甚至一个格式正确却不符合 MCP 消息结构的 JSON,都不能随意写进去。
服务器的调试、运行和退出日志应写 stderr。以 Node.js 工具服务器中的日志语句为例:
// 放在工具服务器的日志位置,不是宿主客户端的日志位置。
// 错误:console.log("Shutting down...");
console.error("Shutting down...");
这只替换一条日志的输出位置,不是一份完整服务器程序。还要检查第三方库、启动脚本和包装器有没有写 stdout,尤其是关闭处理器。把业务日志改到 stderr 后,不能再通过 2>&1 合并回 stdout;否则进入解析器的内容仍然被污染。
stderr 可以包含普通运行信息。客户端应按自己的日志规则记录、筛选或忽略,不能把“stderr 有输出”直接当成工具调用失败。诊断时保留 stdout 与 stderr 的区别,分享日志前去掉令牌、路径和业务数据。
close 期间仍可能收到子进程输出
1.29.0 的 StdioClientTransport 在 stdout 的 data 回调里追加数据,再按行调用 JSON.parse() 和消息结构校验。close() 会关闭子进程 stdin、等待退出,并在需要时发送终止信号;这份实现没有在开始关闭时解绑那个 stdout 回调。
因此,子进程因 stdin 关闭而写出的退出日志,仍可能在关闭过程中进入解析器。看到“已经开始 close,随后才报 SyntaxError”,并不能证明错误来自业务工具结果,也不能证明所有流监听都已移除。要同时检查服务器的日志去向和所用 SDK 的关闭实现。
客户端自己的连接流程也应完整等待初始化,避免用固定延时猜连接是否建立。下面是 1.29.0 API 形态的客户端示例,服务器路径需要替换为本机已有、可信且支持 stdio 的 Node.js MCP 程序:
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js";
async function main() {
const transport = new StdioClientTransport({
command: "node",
args: ["/absolute/path/to/mcp-server.js"],
stderr: "pipe",
});
const forwardStderr = (chunk: Buffer) => {
process.stderr.write(chunk);
};
transport.stderr?.on("data", forwardStderr);
const client = new Client(
{ name: "agent-tools", version: "1.0.0" },
{ capabilities: {} },
);
client.onerror = (error) => {
console.error("MCP client error:", error);
};
try {
await client.connect(transport);
const result = await client.listTools();
console.error("Available tools:", result.tools.length);
} finally {
try {
await client.close();
} finally {
transport.stderr?.off("data", forwardStderr);
}
}
}
void main().catch((error) => {
console.error(error);
process.exitCode = 1;
});
示例面向配置了 Node.js 类型的 TypeScript 项目;/absolute/path/to/mcp-server.js 是占位路径。它会启动服务器并读取工具列表,不执行工具。connect() 自己启动 transport,不要再提前调用 transport.start()。让 client 管理连接关闭,也不必同时对 client 和 transport 重复发起关闭。
这里手动解绑的只是示例自己注册的 stderr 转发函数。不要用 removeAllListeners() 清空 SDK 或其他调用方的监听器。这份连接写法能理顺客户端的等待和关闭顺序,但仍要求服务器遵守 stdout 协议。
容错解析不能作为日志输出约定
有些后续开发分支会跳过不能解析成 JSON 的行。项目如果准备升级,应核对实际发布包和变更范围;开发分支里的代码不等于某个发行版已经包含相同处理,也不证明关闭监听器的问题一并解决。
即便解析器跳过了 Shutting down...,服务器向 stdout 写普通日志仍然违反传输约定。不要把 onerror 改成空函数,或一概过滤 SyntaxError:真正截断、损坏的消息也可能产生解析错误,屏蔽后客户端会更难定位请求为什么没有完成。
修改后分别检查启动、正常列举工具和关闭三个阶段。stdout 应只出现协议消息,stderr 中可以看到必要日志;客户端应完成初始化、收到工具列表并结束子进程,不能只以“终端不再打印错误”判断成功。若 SDK 升级后出现其他兼容问题,按原锁文件回退包版本,保留已经修正的日志分流。


