• 首页
  • 博客
  • 项目
  • 留言墙
  • 问题现象
  • 为什么之前没问题,现在突然出问题
  • 为什么局域网 IP 没问题,域名才出问题
  • 翻日志找根因
  • 服务 B 的日志(有问题的)
  • 服务 A 的日志(正常的)
  • 故障链路
  • 为什么两个服务表现不同
  • MCP 规范怎么说
  • Origin 验证(强制)
  • CORS 暴露 Session ID(必要)
  • SDK 层面的支持
  • 怎么修
  • 临时方案:在反向代理层补 CORS
  • 根本方案:MCP 服务端自己处理 CORS
  • 给 MCP 开发者的建议
  • 写在最后
  • 参考资料
MCP 服务开发踩坑:一个 "Invalid session ID" 背后的 CORS 问题
2026/03/16AI, 产品

MCP 服务开发踩坑:一个 "Invalid session ID" 背后的 CORS 问题

最近在用 ChatWise 连接自己部署的 MCP 服务时,遇到了一个挺隐蔽的问题。报错信息是 "Invalid session ID",但真正的原因跟 Session 管理没有半毛钱关系——是 CORS。

635次点击9分钟阅读

这个坑的触发条件很特殊,排查过程也挺有意思,记录下来给同样在开发 MCP 服务的朋友参考。

问题现象

我有两个 MCP 服务部署在同一台服务器上,都通过 Caddy 反向代理对外提供服务,分别叫它们 服务 A 和服务 B。两个服务的 Caddy 配置几乎一模一样,都是最基础的反向代理:

1
2
3
4
5
6
7
8
9
10
mcp-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 却报错:

1
Streamable HTTP error: Error POSTing to endpoint: Invalid session ID

更奇怪的是,同一个服务 B:

  • 在 Claude 官方客户端(claude.ai)上一直好好的
  • 在 ChatWise 中通过 Tailscale 局域网 IP 直连也没问题
  • 只有通过 HTTPS 域名访问时才报错
mcp-invalid-session-id-caused-by-cors-1

为什么之前没问题,现在突然出问题

这要从 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
12
1. 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,中间隔了三层。 不看日志根本定位不到。

mcp-invalid-session-id-caused-by-cors-2

为什么两个服务表现不同

两个服务的 Caddy 配置一样,差别在 MCP 服务端自身:

  • 服务 A 的后端内置了 CORS 处理,OPTIONS 请求穿透到后端后,后端自己返回了 204 + 完整 CORS 头。即使 Caddy 没配 CORS,整个流程照样跑通。
  • 服务 B 的后端没处理 CORS,OPTIONS 穿透到后端后返回 404,后续请求全部崩溃。

这也是为什么同样的 Caddy 配置,一个能用一个不能用——问题不在代理层,在服务本身。

MCP 规范怎么说

翻了 MCP 官方规范,对 Streamable HTTP 传输协议有明确的安全要求。

Origin 验证(强制)

2025-03-26 版规范:

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
5
const 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
24
mcp.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 Forbidden
  • Origin 不存在(服务端直连场景)→ 放行

给 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)

☕️支持一下

如果我的内容对你有帮助,可以请我喝杯咖啡 🫶

相关文章

我做了一个纸盒小人 Skill,专门拯救文章配图

2026/06/08AI4087分钟阅读

Claude Code 团队怎么选文档格式(MD vs HTML)以及我的看法

2026/05/09AI7884分钟阅读

MCP 太重了——我给 Orchard 补了一个 CLI

2026/04/22产品2835分钟阅读

© 2026 • 5km • Fork 自cali.so

首页博客项目留言墙
总浏览量 —
支持一下