MCP 从 stdio 迁到 SSE,踩了 5 个传输层坑

前阵子写了个 MCP 私有代码只读服务,用 stdio transport 在本机跑得稳稳的。

后来想让其他机器也能调,想着迁到 SSE 吧。MCP SDK 不都内置了 SSEClientTransportSSEServerTransport 么?改几行配一下就完事。

结果第一天,连不上、连上就断、断了谁都不知道、并发状态串了、错误信息被 HTTP 状态码吃了。五个坑一个没落。


先搞清楚 stdio 和 SSE 的差异

两个不只是 "MCP SDK 里不同的 transport 类"。

维度 stdio SSE
连接建立 子进程管道,宿主启动即绑定 HTTP 长连接,客户端主动发起
生命周期 进程退出 = 连接断裂,即时感知 TCP 断开不通知应用层,需要心跳检测
并发模型 天然一对一串行 默认多连接并发,状态隔离自己管

stdio 是本地子进程管道,起一个子进程,标准输入输出就是通信通道。

SSE 是独立 HTTP 长连接,起一个 HTTP 服务,客户端主动连,连接双方法地位对等,生命周期各管各的。

换 transport 不是改配置,是换架构认知。


坑 1:换个 transport 而已

现象

代码从 StdioServerTransport 换成 SSEServerTransport,启动没报错,但 Agent 连上来发第一条消息就卡住。没有超时报错,也没有响应,消息像丢进了黑洞。

触发条件:Agent 启动后立即发请求,服务看起来起来了,但就是不理人。

排查过程

第一反应是 SSE transport 实现有问题。毕竟第一次用,怀疑传参传错了。

curl 测 SSE endpoint,能正常收到 event。那说明服务端没问题。

开始怀疑网络配置。检查端口、防火墙、代理,都没问题。

绕了一大圈才回头怀疑时序。加了一行启动日志,发现 Agent 发请求的时候,HTTP 服务 listen 回调还没触发。

csharp 复制代码
[INFO] HTTP server listening on port 3456   ← 这条出现在 Agent 请求之后

问题不在连不连得上,是服务还没就绪客户端就已经开始发了。

根因分析

stdio 没这个问题:它是子进程管道,进程启动即就绪,宿主 spawn 后 stdin/stdout 立即可用。

SSE 是独立 HTTP 服务,server.listen() 是异步的。服务启动到真正就绪之间有时间窗口。MCP SDK 的 SSEServerTransport 默认不做任何连接等待,服务没就绪时请求来了,连接建立失败或者消息丢失,都不会有明确报错。

两者启动模型从根本上不同。

解决方案

服务端加一个 /health 健康检查 endpoint:

typescript 复制代码
import http from "node:http";

const server = http.createServer((req, res) => {
  if (req.url === "/health" && req.method === "GET") {
    res.writeHead(200, { "Content-Type": "application/json" });
    res.end(JSON.stringify({ status: "ok" }));
    return;
  }
  // ... 其余路由
});

server.listen(3456, () => {
  console.error(`MCP server listening on port 3456`);
});

客户端实现连接等待,等服务就绪了再发真正请求:

typescript 复制代码
async function waitForReady(url: string, maxRetries = 5): Promise<void> {
  for (let i = 0; i < maxRetries; i++) {
    try {
      const res = await fetch(`${url}/health`);
      if (res.ok) return;
    } catch {
      // 服务还没就绪
    }
    await new Promise((r) => setTimeout(r, Math.pow(2, i) * 200)); // 指数退避
  }
  throw new Error(`Server at ${url} not ready after ${maxRetries} retries`);
}

长期预防

MCP Server 启动入口统一加上服务就绪信号,要么等 listen 回调触发后再接受连接,要么客户端侧封装 waitForReady() 函数,项目模板自带。


坑 2:断连了,两边都不知道

现象

Agent 调了一个搜索代码的 tool,几十秒没响应。第一反应是 tool 执行太慢。

去 Server 看日志,tool 几毫秒就跑完了。结果也写好了,就是没送到 Agent 那边。

再一看,连接早就断了。

排查过程

先给 tool 加耗时日志,确认不是 tool 实现的问题:

typescript 复制代码
console.error(`search_code started at ${Date.now()}`);
const results = await searchCode(query);
console.error(`search_code finished at ${Date.now()}, found ${results.length} results`);

两条日志时间差只有 8 毫秒。tool 没问题。

那为什么 Agent 没收到?怀疑是响应没发出去。

翻 MCP 协议和 SDK 源码,发现 SSE transport 默认没有任何心跳机制。没有 ping,没有保活,没有任何应用层的连接健康检查。

再加日志,发现 Server 在尝试写 SSE 消息时才报错------连接已经断了,但 Server 完全不知道,直到调了 res.write() 才抛出 ERR_STREAM_DESTROYED

根因分析

stdio 下进程退出等于管道断裂,父进程立刻感知。

SSE 下 TCP 断开不会通知应用层。Server 只有在尝试写响应时才知道连接已死。如果 Server 没有在写东西(比如 tool 在等外部服务返回),它永远不会发现。

MCP 协议截至当前版本没有定义传输层心跳,完全依赖底层 TCP keepalive。默认间隔 2 小时,对编码 Agent 来说等于没有。

解决方案

Server 端独立一个定时器做应用层心跳:

typescript 复制代码
const HEARTBEAT_INTERVAL_MS = 15_000; // 15 秒

function startHeartbeat(res: http.ServerResponse): void {
  const timer = setInterval(() => {
    try {
      res.write(": heartbeat\n\n"); // SSE comment 作为心跳
    } catch {
      clearInterval(timer);
      cleanupSession(res);
    }
  }, HEARTBEAT_INTERVAL_MS);
  
  // 把 timer 存起来,后面优雅关闭要用
  (res as any)._heartbeatTimer = timer;
}

客户端超时检测:

typescript 复制代码
const RESPONSE_TIMEOUT_MS = 60_000; // 60 秒无 event 触发重连

let lastEventTime = Date.now();

eventSource.onmessage = () => {
  lastEventTime = Date.now();
};

setInterval(() => {
  if (Date.now() - lastEventTime > RESPONSE_TIMEOUT_MS) {
    console.error("No events received for 60s, reconnecting...");
    reconnect();
  }
}, 10_000);

Server 在写事件时捕获连接异常并主动清理会话:

typescript 复制代码
function sendEvent(res: http.ServerResponse, event: string, data: unknown): boolean {
  try {
    res.write(`event: ${event}\ndata: ${JSON.stringify(data)}\n\n`);
    return true;
  } catch (err) {
    cleanupSession(res);
    return false;
  }
}

长期预防

MCP Server 启动时自动启动独立心跳定时器。Agent 客户端层内置断连检测和自动恢复,对上层 tool 透明。


坑 3:并发请求一来,状态全串了

现象

本地测试一切正常。一上生产,多个 Agent 同时调用同一台 Server,数据乱了:A 的搜索请求返回了 B 的结果,B 在读的文件句柄被 C 关了。

本地怎么测都复现不出来,一上多人用就出问题。

排查过程

先怀疑 Agent 端消息路由有 bug。翻日志,路由没问题,A 的请求确实发到了正确的 endpoint。

去看 Server 日志,多个请求的 session 信息混在一起,A 连接的 session token 出现在 B 的请求处理日志里。

检查代码找到了问题:

typescript 复制代码
// 问题代码:用全局变量存请求上下文
let currentRequestContext: RequestContext | null = null;

function handleToolCall(toolName: string, args: unknown) {
  currentRequestContext = { toolName, args, startedAt: Date.now() };
  // ... 处理逻辑
}

两个连接同时进来,currentRequestContext 被后一个覆盖了。

根因分析

stdio 是一对一串行,同一时刻只处理一个请求,全局变量没事。

SSE/HTTP 默认多连接并发。用了全局变量、单例缓存、共享文件句柄,状态串扰是必然的。

MCP SDK 的 tool handler 没有自动 session 隔离。req 参数里有 session 信息,但 handler 用不用看开发者。

解决方案

每连接分配唯一 sessionId:

typescript 复制代码
import { randomUUID } from "node:crypto";

const sessions = new Map<string, SessionState>();

// 连接建立时分配 session
app.get("/mcp", async (req, res) => {
  const sessionId = randomUUID();
  const transport = new SSEServerTransport("/mcp", res);
  sessions.set(sessionId, { transport, createdAt: Date.now() });
  
  // 连接关闭时清理
  req.on("close", () => {
    sessions.delete(sessionId);
  });
  
  await server.connect(transport);
});

Map<sessionId, State> 替代全局变量(重构前后对比):

typescript 复制代码
// ❌ 重构前:全局变量
let currentRequestContext: RequestContext | null = null;

// ✅ 重构后:按 session 隔离
const sessionContexts = new Map<string, RequestContext>();

function getContext(sessionId: string): RequestContext {
  let ctx = sessionContexts.get(sessionId);
  if (!ctx) {
    ctx = { toolCalls: [], startedAt: Date.now() };
    sessionContexts.set(sessionId, ctx);
  }
  return ctx;
}

共享资源(文件、数据库连接)加锁或用无状态设计。如果是共享文件读取,用读写锁;如果是数据库连接池,每个 session 用独立的连接实例。

长期预防

MCP Server 模板代码从第一天就按多会话架构设计:连接级状态用 Map 管理,不做全局可变状态,共享资源显式加锁。


坑 4:SIGTERM 来了,连接没人关

现象

通过 CI/CD 更新部署,kill 旧进程启动新版本。结果新进程报 EADDRINUSE,端口被占了。

不是每次更新都出问题,但出问题就得等 TIME_WAIT 超时。

排查过程

看 Server 日志,收到 SIGTERM 后直接 process.exit(),没关任何活跃的 SSE 连接:

csharp 复制代码
[15:30:01] Received SIGTERM, exiting...
[15:30:01] Process exited with code 0

Agent 端没有收到正常的 connection close 事件,连接被操作系统强行终止。

查 HTTP Server 状态,发现内核 socket 进入 TIME_WAIT 状态,端口没释放。

根因分析

stdio 下父进程杀子进程,管道自动关闭,操作系统回收所有资源。

HTTP Server 收到 SIGTERM 不会自动关闭活跃长连接。process.exit() 只让自己退出,不管还有多少连接没关。

SSE 连接是持久 HTTP 连接,不主动关闭 socket 会留在 TIME_WAIT。Node 的 http.Server.close() 只停止接新连接,不会关掉已活跃的 keep-alive 连接。

解决方案

捕获 SIGTERM/SIGINT,走优雅关闭流程:

typescript 复制代码
function gracefulShutdown(server: http.Server, sessions: Map<string, SessionState>) {
  console.error("Received shutdown signal, closing gracefully...");
  
  // 1. 停止接新连接
  server.close(() => {
    console.error("HTTP server closed");
  });
  
  // 2. 关闭所有活跃 SSE 连接
  for (const [sessionId, session] of sessions) {
    session.transport.close();
    sessions.delete(sessionId);
  }
  
  // 3. 超时兜底,防止永远关不掉
  const forceExit = setTimeout(() => {
    console.error("Graceful shutdown timeout, forcing exit");
    process.exit(1);
  }, 30_000); // 30 秒超时
  
  // 如果所有连接都关完了,取消超时
  if (sessions.size === 0) {
    clearTimeout(forceExit);
    process.exit(0);
  }
}

process.on("SIGTERM", () => gracefulShutdown(server, sessions));
process.on("SIGINT", () => gracefulShutdown(server, sessions));

Docker 部署时配置 STOPSIGNAL:

dockerfile 复制代码
STOPSIGNAL SIGTERM

K8s 用 preStop hook 做连接 drain:

yaml 复制代码
lifecycle:
  preStop:
    exec:
      command: ["sleep", "5"]

给 Agent 端留出时间感知断连并重连到新实例。

长期预防

Docker STOPSIGNAL 和 graceful shutdown 的超时时间一起调。K8s preStop hook 至少 5 秒,让活跃请求有时间 drain 完。


坑 5:传输层错误被 HTTP 吞了

现象

Server 内部抛异常 ------ 文件找不到、参数校验失败、第三方服务超时 ------ Agent 端只看到一行:

vbscript 复制代码
HTTP 500 Internal Server Error

同一段代码在 stdio 下跑:

javascript 复制代码
Error: ENOENT: no such file or directory, open '/data/config.json'
    at Object.openSync (node:fs:585:3)
    ...

在 SSE 下跑:

vbscript 复制代码
HTTP 500 Internal Server Error

完整的错误信息全没了。

排查过程

看 Agent 日志,只有一行 500

看 Server 日志,才知道具体是什么错。

但 MCP 协议层收不到这个错误结构。Agent 只能根据 500 判断出错了,不知道错在哪里、该不该重试、该不该报给用户。

根因分析

stdio 的 stderr 是独立通道。错误信息和协议数据物理隔离,Agent 可以同时读到协议响应(stdout)和错误信息(stderr),互不干扰。

SSE 的错误从协议内部错误变成 HTTP 状态码,所有信息挤在一个通道里。

JSON-RPC 的 error 对象有 data 字段可以传详细信息,但大多数实现只填了 codemessage

解决方案

定义三层错误模型,每层保持结构化:

css 复制代码
传输层(HTTP)  → 状态码 + body
协议层(JSON-RPC)→ code + message + data
业务层(tool error)→ 原始错误信息

HTTP 500 时在 body 里带结构化错误信息:

typescript 复制代码
function sendError(res: http.ServerResponse, status: number, err: Error) {
  res.writeHead(status, { "Content-Type": "application/json" });
  res.end(JSON.stringify({
    error: {
      code: -32000,
      message: err.message || "Internal server error",
      data: {
        name: err.name,
        stack: process.env.NODE_ENV === "development" ? err.stack : undefined,
        timestamp: new Date().toISOString(),
      },
    },
  }));
}

统一错误处理中间件,确保所有异常走同一通道:

typescript 复制代码
function errorHandler(err: Error, req: http.IncomingMessage, res: http.ServerResponse) {
  console.error(`[${req.method}] ${req.url}:`, err.message);
  
  if (err.message.includes("ENOENT")) {
    return sendError(res, 404, err);
  }
  if (err.message.includes("timeout")) {
    return sendError(res, 504, err);
  }
  
  sendError(res, 500, err);
}

Agent 端解析 error.data 做精细化处理:

typescript 复制代码
interface McpError {
  code: number;
  message: string;
  data?: {
    name: string;
    stack?: string;
    timestamp: string;
  };
}

function handleToolError(error: McpError) {
  if (error.data?.name === "ENOENT") {
    return { retryable: false, message: `文件不存在: ${error.message}` };
  }
  if (error.code === -32000 && error.message.includes("timeout")) {
    return { retryable: true, message: "服务超时,正在重试...", delay: 1000 };
  }
  return { retryable: false, message: error.message };
}

长期预防

MCP Server 错误处理三层模型从一开始就定义好:传输层(HTTP)→ 协议层(JSON-RPC)→ 业务层(tool error),每层都保持结构化,不丢信息。


5 个坑的根因指向同一个问题:从 stdio 到 SSE,本质是从本地进程间通信变成网络通信。传输层的假设变了。

发生产前对一遍:

  • 启动时序:服务就绪后才接受连接,客户端 waitForReady() 等待加重试
  • 心跳检测:Server 应用层心跳(15s 间隔),客户端超时检测(60s 无 event 触发重连)
  • 状态隔离:每连接分配 sessionId,用 Map 替代全局变量
  • 优雅关闭:捕获 SIGTERM,遍历关闭所有活跃 SSE 连接,30 秒超时兜底
  • 错误传递:结构化错误信息(HTTP → JSON-RPC error.data → tool error),不依赖 HTTP 状态码

你从 stdio 切 SSE 时踩过哪些坑?有没有遇到上面没写到的?欢迎评论区补充。


参考资料

相关推荐
Conan在掘金1 小时前
鸿蒙报错速查:struct 里嵌 enum 声明就炸,根因 + 真解法
后端
写代码的强哥1 小时前
云原生数据库与传统数据库相比“新”在哪里
数据库·云原生·架构
金斗潼关1 小时前
使用MLP神经网络模型预测质数
人工智能·深度学习·神经网络
吴佳浩2 小时前
一文讲透AI算力单位:TFLOPS、PFLOPS、TOPS、稀疏算力,到底怎么算、怎么比?
人工智能·ai编程·gpu
guoyuhan2 小时前
用 OpenAI SDK 一行代码接入国产大模型:DeepSeek/Qwen/GLM 实战指南
人工智能
回家路上绕了弯2 小时前
Java双数据源实战全指南:Spring Boot多数据源配置、原理与踩坑避坑
后端
维基框架2 小时前
GitHub重构漏洞赏金计划 向AI批量报告说不
人工智能·重构·github
神奇小汤圆2 小时前
Redis主从切换后,旧主恢复竟触发全量同步?——我猜错了,原因比想象中更严谨
后端
不加辣椒2 小时前
第5章:智能检索系统——从 Naive RAG 到 Agentic RAG
人工智能