
MCP 服务开发踩坑:一个 "Invalid session ID" 背后的 CORS 问题
最近在用 ChatWise 连接自己部署的 MCP 服务时,遇到了一个挺隐蔽的问题。报错信息是 "Invalid session ID",但真正的原因跟 Session 管理没有半毛钱关系——是 CORS。
这个坑的触发条件很特殊,排查过程也挺有意思,记录下来给同样在开发 MCP 服务的朋友参考。
问题现象
我有两个 MCP 服务部署在同一台服务器上,都通过 Caddy 反向代理对外提供服务,分别叫它们 服务 A 和服务 B。两个服务的 Caddy 配置几乎一模一样,都是最基础的反向代理:
1 2 3 4 5 6 7 8 9 10mcp-b.example.com { encode gzip zstd reverse_proxy 127.0.0.1:8087 { header_up Host {host} header_up X-Real-IP {remote} } log { output file /var/log/caddy/mcp-b.example.com.log } }
在 ChatWise 中连接这两个服务时,服务 A 一切正常,服务 B 却报错:
1Streamable HTTP error: Error POSTing to endpoint: Invalid session ID
更奇怪的是,同一个服务 B:
- 在 Claude 官方客户端(claude.ai)上一直好好的
- 在 ChatWise 中通过 Tailscale 局域网 IP 直连也没问题
- 只有通过 HTTPS 域名访问时才报错
为什么之前没问题,现在突然出问题
这要从 ChatWise 最近的一次技术栈迁移说起。
ChatWise 之前是用 Tauri 开发的,以轻量著称——安装包只有十几 MB,内存占用低,冷启动快。但 Tauri 的坑也不少,作者 EGOIST 去年在推特上吐槽过 "Too many bugs in ChatWise are due to Tauri",最终决定用 Electron 重写。
这个迁移带来了一个关键的技术变化:
同一个 MCP 服务,换了个客户端运行时,就从能用变成不能用。
这也解释了为什么在 Claude 官方客户端上没问题——Claude 的服务端(User-Agent: Claude-User)是通过 HTTP/1.1 直连的,根本不走浏览器引擎,自然不会触发 CORS 预检。
为什么局域网 IP 没问题,域名才出问题
这里还有一个细节:同样是 ChatWise(Electron),通过 Tailscale 局域网 IP 直连服务 B 没有问题,只有通过 HTTPS 域名访问才触发报错。
这大概率跟 Electron 内部的请求路径有关。Electron 应用有两个网络层:Node.js 主进程和 Chromium 渲染进程。对于不同类型的 URL,ChatWise 可能走了不同的路径:
- 局域网 IP(HTTP):请求可能走 Node.js 原生 HTTP 模块,不经过 Chromium 网络栈,不触发 CORS 预检
- HTTPS 域名:请求走 Chromium 渲染进程的 fetch/XHR,严格执行浏览器的同源策略,触发 CORS 预检
也就是说,这个问题的完整触发条件是:Electron 客户端 + HTTPS 域名访问 + MCP 服务端未处理 CORS。三个条件同时满足才会出问题,缺一个都不会报错——这也是为什么这个坑这么隐蔽。
翻日志找根因
报错信息是 "Invalid session ID",直觉上会去查 Session 管理的逻辑,但这条路走不通。真正的线索在 Caddy 的访问日志里。
服务 B 的日志(有问题的)
1 2 3 4 5 6 7 8 9// 第一步:OPTIONS 预检 {"method":"OPTIONS","uri":"/mcp","status":404} // 第二步:POST initialize(拿到了 Session ID,但浏览器读不到) {"method":"POST","uri":"/mcp","status":200, "resp_headers":{"Mcp-Session-Id":["mcp-session-a328a031-..."]}} // 第三步:POST tools/list(没带 Session ID → 400) {"method":"POST","uri":"/mcp","status":400}
服务 A 的日志(正常的)
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16// 第一步:OPTIONS 预检 → 204 + 完整 CORS 头 {"method":"OPTIONS","uri":"/mcp","status":204, "resp_headers":{ "Access-Control-Allow-Origin":["*"], "Access-Control-Allow-Headers":["Content-Type, Mcp-Session-Id"], "Access-Control-Expose-Headers":["Mcp-Session-Id"], "Access-Control-Allow-Methods":["GET, POST, DELETE, OPTIONS"] }} // 第二步:POST initialize → 200 + Session ID {"method":"POST","status":200, "resp_headers":{"Mcp-Session-Id":["AF833E85-..."]}} // 第三步:POST tools/list → 带上了 Session ID → 202 {"method":"POST","status":202, "request":{"headers":{"Mcp-Session-Id":["AF833E85-..."]}}}
一目了然。
故障链路
把日志串起来,完整的崩溃过程是这样的:
1 2 3 4 5 6 7 8 9 10 11 121. Chromium 发 OPTIONS 预检 → MCP 服务端不认识 OPTIONS 方法 → 返回 404(没有任何 CORS 头) 2. 浏览器收到 404,没有 CORS 许可 → POST initialize 虽然返回了 200 → 但响应头里的 Mcp-Session-Id 被浏览器拦截 (因为没有 Access-Control-Expose-Headers) 3. 客户端拿不到 Session ID → 下一个请求(tools/list)没带 Mcp-Session-Id 头 → 服务端校验失败 → 返回 400 Invalid session ID
报错是 "Invalid session ID",但根因是 CORS,中间隔了三层。 不看日志根本定位不到。
为什么两个服务表现不同
两个服务的 Caddy 配置一样,差别在 MCP 服务端自身:
- 服务 A 的后端内置了 CORS 处理,OPTIONS 请求穿透到后端后,后端自己返回了 204 + 完整 CORS 头。即使 Caddy 没配 CORS,整个流程照样跑通。
- 服务 B 的后端没处理 CORS,OPTIONS 穿透到后端后返回 404,后续请求全部崩溃。
这也是为什么同样的 Caddy 配置,一个能用一个不能用——问题不在代理层,在服务本身。
MCP 规范怎么说
翻了 MCP 官方规范,对 Streamable HTTP 传输协议有明确的安全要求。
Origin 验证(强制)
Servers MUST validate the Origin header on all incoming connections to prevent DNS rebinding attacks.2025-11-25 版规范进一步明确:
If the Origin header is present and invalid, servers MUST respond with HTTP 403 Forbidden.CORS 暴露 Session ID(必要)
MCP 官方 TypeScript SDK 的文档明确指出:
Browsers restrict access to response headers unless explicitly exposed via CORS. Without this configuration, browser-based clients won't be able to read the session ID from initialization responses.
也就是说,如果你的 MCP 服务需要被浏览器环境的客户端访问,CORS 不是可选的"最佳实践",而是必须实现的功能。
SDK 层面的支持
TypeScript SDK 的 StreamableHTTPServerTransport 已经提供了相关配置:
1 2 3 4 5const transport = new StreamableHTTPServerTransport({ sessionIdGenerator: () => randomUUID(), enableDnsRebindingProtection: true, allowedOrigins: ['https://yourdomain.com'] });
一些第三方框架(如 mcp-framework)也提供了完整的 CORS 配置块,作为 HTTP 传输的内置选项。
怎么修
临时方案:在反向代理层补 CORS
如果你暂时没法改 MCP 服务端代码,可以在 Caddy / Nginx 层拦截 OPTIONS 并注入 CORS 头:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24mcp.example.com { encode gzip zstd @options method OPTIONS handle @options { header Access-Control-Allow-Origin "*" header Access-Control-Allow-Methods "GET, POST, DELETE, OPTIONS" header Access-Control-Allow-Headers "Content-Type, Mcp-Session-Id, Mcp-Protocol-Version" header Access-Control-Expose-Headers "Mcp-Session-Id" header Access-Control-Max-Age "86400" respond 204 } header { Access-Control-Allow-Origin "*" Access-Control-Expose-Headers "Mcp-Session-Id" } reverse_proxy 127.0.0.1:8087 log { output file /var/log/caddy/mcp.example.com.log } }
改完 caddy reload 即可生效。
根本方案:MCP 服务端自己处理 CORS
正确的做法是让 MCP 服务端自身处理 CORS,而不是依赖反向代理。需要实现以下几点:
1. 处理 OPTIONS 预检请求
收到 OPTIONS 方法时,返回 204 No Content + CORS 响应头,不要传递到业务逻辑层。
2. 所有 HTTP 响应注入 CORS 头
3. 验证 Origin 头
按 MCP 规范的 MUST 要求:
Origin存在且不在允许列表中 → 返回403 ForbiddenOrigin不存在(服务端直连场景)→ 放行
给 MCP 开发者的建议
如果你也在开发基于 Streamable HTTP 的 MCP 服务,这里有几个经验:
1. 别只在 Claude Desktop 上测
Claude 官方客户端是服务端直连(HTTP/1.1,User-Agent: Claude-User),永远不会触发 CORS 预检。你的服务在上面跑得好好的,不代表在其他客户端上也没问题。
2. 用 Electron 类客户端 + HTTPS 域名测一遍
ChatWise、Cursor、Windsurf 等 AI 客户端很多都基于 Electron,底层是 Chromium,CORS 预检是必然的。注意:局域网 IP 直连可能不会触发问题,一定要用 HTTPS 域名测,这才是最接近真实用户环境的方式。
3. 看日志,看日志,看日志
这次的报错信息("Invalid session ID")和根因(CORS)之间隔了好几层。如果不是翻了 Caddy 日志看到 OPTIONS 404,可能还会在 Session 管理的逻辑里绕半天。反向代理的访问日志是排查这类问题的第一手资料。
4. 把 CORS 处理加在服务端,而不是代理层
反向代理补 CORS 可以作为临时方案,但正确做法是 MCP 服务自己处理。这样不管部署在什么环境、用什么代理,都不会出问题。
写在最后
这次踩坑的直接原因是 ChatWise 从 Tauri 迁移到了 Electron——之前 Tauri 的 Rust 后端直接发 HTTP 请求,不受浏览器同源策略限制,所以服务 B 的 CORS 缺陷一直没暴露。换成 Electron(Chromium)之后,CORS 预检立刻把问题暴露了出来。
与其说这是一个 bug,不如说是一个完善 MCP 服务的机会。CORS 处理和 Origin 验证本来就是 MCP 规范的强制要求,只是在纯服务端直连的场景下没有被触发而已。早发现早修复,总比上线后被用户反馈好。
参考资料
- MCP 规范 - Transports(2025-03-26)
- MCP 规范 - Transports(2025-11-25)
- MCP TypeScript SDK - Server 文档(CORS & DNS Rebinding)
- MCP Python SDK
- mcp-framework - HTTP Stream Transport(CORS 配置)
- Why MCP's Move Away from SSE Simplifies Security - Auth0
- MDN - 跨源资源共享(CORS)
- DNS Rebinding in Official MCP SDKs(CVE-2025-66414 / CVE-2025-66416)



