部署与扩展
你的服务器已经能跑了。现在它需要一个真实的主机名,后面还要挂不止一个 worker。
这些事几乎都不归 MCP 管。ASGI 服务器、进程管理器、负载均衡器都由你自己带。这一页只讲确实归 MCP 管的那几件事:一个卡住所有部署的设置,以及"不止一个 worker"会改变 SDK 行为的两个地方。
首先:Host 白名单
streamable_http_app() 无从知道自己会被放在哪个主机名后面,所以它假设最安全的答案:localhost。没有传 transport_security= 时,应用会开启 DNS 重绑定防护,只接受 Host 头为 127.0.0.1:<port>、localhost:<port> 或 [::1]:<port> 的请求。如果有 Origin 头,它必须是同一地址的 http:// 形式。在你自己的机器上这正合适:它能阻止恶意网页通过一个重绑定到 127.0.0.1 的 DNS 名称操纵你的本地服务器。
部署到真实主机名后面,同样的默认值会拒绝所有请求,直到你另有说明。这项检查在任何 MCP 逻辑之前运行,所以你写的东西根本不会被调用:
421 Misdirected Request Invalid Host header the Host is not in the allowlist
403 Forbidden Invalid Origin header the Origin is not in the allowlist
解决办法是 transport_security=。把你实际对外服务的地址加入白名单:
from mcp.server import MCPServer
from mcp.server.transport_security import TransportSecuritySettings
mcp = MCPServer("Notes")
@mcp.tool()
def add_note(text: str) -> str:
"""Save a note."""
return f"Saved: {text}"
security = TransportSecuritySettings(
allowed_hosts=["mcp.example.com", "mcp.example.com:*"],
allowed_origins=["https://app.example.com"],
)
app = mcp.streamable_http_app(transport_security=security)
allowed_hosts的条目是精确字符串:"mcp.example.com"匹配不带端口的Host头,"mcp.example.com:*"匹配任意端口。两个都要列上。allowed_origins只对浏览器有意义,因为别的客户端不发Origin。它是 添加到现有应用 中 CORS 配置在服务器端的对应项。- 如果前面有一个已经控制
Host头的反向代理,直接关掉这项检查才是诚实的配置:TransportSecuritySettings(enable_dns_rebinding_protection=False)。 - 传一个非 localhost 的
host=(例如host="mcp.example.com")并不会把该主机名加入白名单。它只是让 localhost 默认值不再触发防护,结果是所有 Host 和 Origin 都被接受。想表达什么,就用transport_security=明确说出来。
Check
删掉 transport_security=security 参数,照样部署这个应用。它能启动,/mcp 能路由,而每一个请求(包括一个普通的 curl)都会返回:
HTTP/1.1 421 Misdirected Request
Invalid Host header
在客户端那边你找不到这几个字。421 是纯文本的 HTTP 响应,不是 JSON-RPC 错误,所以 MCP 客户端抛出的是一个泛泛的传输错误;它不认可的那个主机名只出现在服务器的日志里,是一条警告。一个刚部署好、拒绝所有连接的服务器,在证明是别的原因之前,就是 Host 白名单的问题。故障排查 也从这里讲起。
Worker,以及谁需要粘性
主机名能响应之后,就在后面放不止一个 worker。SDK 没有这方面的开关;扩展一个 Starlette 应用和扩展任何 ASGI 应用一样,把对象交给一个会 fork 的东西:
uvicorn server:app --workers 4
四个进程,一个套接字。接下来是每个部署都必须回答的问题:一个请求是否必须到达处理了上一个请求的那个 worker?
对使用 2026-07-28 协议的客户端来说,不需要。现代请求是一个自包含的 POST:前面没有 initialize 握手,响应上没有 Mcp-Session-Id,第二个请求没有任何东西需要"回到"。路由到任意 worker 即可。
这不是一个需要打开的模式。stateless_http=True 看起来像是,但传输层按 MCP-Protocol-Version 请求头路由,把现代请求交给现代处理函数,然后就返回了。读取 stateless_http 的那一行在这个返回之后。不是这个标志在 2026-07-28 路径上被忽略,而是根本走不到它。stateless_http 只是旧版那一支的开关,现代路径从构造上就是无会话的。
对使用规范版本 2025-11-25 或更早的旧版客户端,答案取决于这个标志:
| 客户端的协议版本 | 会话 | 负载均衡器必须做什么 |
|---|---|---|
| 2026-07-28 | 无。Mcp-Session-Id 从不设置。 |
什么都不用。任意 worker 处理任意请求。 |
| 2025-11-25 及更早(默认) | Mcp-Session-Id,保存在某一个 worker 的内存里。 |
粘性会话。 后续请求到达另一个 worker 会得到 404 "Session not found"。 |
2025-11-25 及更早,加上 stateless_http=True |
无。 | 什么都不用。代价是服务器到客户端的反向通道(back-channel)(采样(sampling)、推送式征询(elicitation)、roots/list)和可恢复性。 |
粘性会话以及旧版那一支的代价单独有一页:服务旧版客户端;两个时代本身见 协议版本。这里重要的是答案的形状:在 2026-07-28 上你已经是无状态的,没有什么需要配置。
这一页剩下的部分,是无状态并不能帮你解决的两件事。
跨 worker 的 requestState
多轮往返(multi-round-trip) 工具需要客户端去取某样东西(一次确认、一个选择、一份凭据),所以它返回一个问题而不是答案,在重试时完成。两轮之间,客户端持有服务器铸造的一个不透明的 request_state 令牌。重试时服务器必须重新打开这个令牌。
用什么密钥封存的? 默认是服务器在构造时用 os.urandom(32) 生成的那一个。在 --workers 4 下就是四次构造、四个进程:四把不同的密钥,没写到任何地方,互不共享,重启即失。
下面是一个先问后做的工具,所在的服务器什么都没配置:
from mcp.server.mcpserver import Context, MCPServer
from mcp.types import ElicitRequest, ElicitRequestFormParams, ElicitResult, InputRequiredResult
CONFIRM = ElicitRequest(
params=ElicitRequestFormParams(
message="Issue this refund?",
requested_schema={"type": "object", "properties": {"ok": {"type": "boolean"}}, "required": ["ok"]},
)
)
def make_server() -> MCPServer:
"""Every worker process builds one of these, once, at import."""
mcp = MCPServer("billing")
@mcp.tool()
async def refund(amount: int, ctx: Context) -> str | InputRequiredResult:
"""Refund an amount, once a human has confirmed it."""
if ctx.input_responses is None:
return InputRequiredResult(input_requests={"ok": CONFIRM}, request_state=f"refund:{amount}")
answer = (ctx.input_responses or {}).get("ok")
if not isinstance(answer, ElicitResult) or answer.action != "accept" or not (answer.content or {}).get("ok"):
return "refund cancelled"
return f"refunded ${amount}"
return mcp
第一轮到达 worker A。worker A 用它的密钥封存 refund:120 并返回令牌。客户端把问题摆到人面前,得到一个"是",然后重试。重试是一个全新的 HTTP 请求。
Check
让这次重试到达 worker B。B 尝试解封一个不是它铸造的令牌,做不到,于是拒绝整轮请求。refund 从未被调用;客户端得到一个 JSON-RPC 错误:
{
"code": -32602,
"message": "Invalid or expired requestState",
"data": {"reason": "invalid_request_state"}
}
这条消息是固定的。过期、被篡改、针对不同参数重放,或者(真实部署中最常见的原因)由兄弟 worker 封存:客户端每次收到的都一样,线路上从不透露是哪项检查失败了。真正的原因是服务器日志里的一条 WARNING:
requestState rejected on tools/call: unknown key
一个在单 worker 下正常、到两个 worker 时开始时而失败的多轮往返工具,就是这个问题。两轮仍然必须到达同一个进程,所以它失败的频率恰好等于负载均衡器把它们分开的频率。
两轮是两个独立的 HTTP 请求,好几种平常的情况都会把它们分开:按请求均衡的代理、中途断开的连接、一次部署或重启、一个持久化了 request_state 并从完全不同的进程恢复的客户端(自己驱动循环)。这些都算"另一个 worker"。
解决办法是一个参数。它有两半。
from mcp.server.mcpserver import Context, MCPServer, RequestStateSecurity
from mcp.types import ElicitRequest, ElicitRequestFormParams, ElicitResult, InputRequiredResult
CONFIRM = ElicitRequest(
params=ElicitRequestFormParams(
message="Issue this refund?",
requested_schema={"type": "object", "properties": {"ok": {"type": "boolean"}}, "required": ["ok"]},
)
)
def make_server(key: str) -> MCPServer:
"""Every worker process: the same key, and the same name."""
mcp = MCPServer("billing", request_state_security=RequestStateSecurity(keys=[key]))
@mcp.tool()
async def refund(amount: int, ctx: Context) -> str | InputRequiredResult:
"""Refund an amount, once a human has confirmed it."""
if ctx.input_responses is None:
return InputRequiredResult(input_requests={"ok": CONFIRM}, request_state=f"refund:{amount}")
answer = (ctx.input_responses or {}).get("ok")
if not isinstance(answer, ElicitResult) or answer.action != "accept" or not (answer.content or {}).get("ok"):
return "refund cancelled"
return f"refunded ${amount}"
return mcp
keys=[...]是大家都能找到的那一半。给每个实例同一个密钥(至少 32 字节),每个实例就能解封任何兄弟实例铸造的令牌。keys[0]封存,列表中的每一把都能解封,这就是轮换环;轮换密钥 讲的是如何不停机地转动它。- 服务器的名字是几乎没人找得到的那一半,也是共享了密钥之后跨实例重试仍然失败的原因。每个封存的令牌都把服务器的
name作为 audience 声明带上,解封时严格校验。从同一份代码构建的两个实例名字相同,永远不会察觉这一点。把它们命名区分开(MCPServer(f"billing-{POD}")看上去像是良好的可观测性习惯),每一次跨实例重试就会和上面一模一样地被拒绝,不管有没有共享密钥。日志里写的是audience而不是unknown key;客户端分辨不出区别。
密钥铸造一次,把同一个值交给每个实例。这就是 SDK 自己的错误消息在你传入不足 32 字节时让你运行的命令:
python -c "import secrets; print(secrets.token_hex(32))"
相同的密钥,以及相同的名字
多实例部署两者都必须共享。如果每实例的名字对你来说不可或缺,那就给整个集群一个显式的 audience:RequestStateSecurity(keys=[...], audience="billing")。这样每个实例无论叫什么,都在 "billing" 下铸造和接受令牌。
关于封存的其余一切见 保护 requestState:它绑定什么、每轮的 ttl(默认 600 秒)、自带编解码器、为什么未配置的默认值在 stdio 上正合适。这一页的全部贡献是一张两项的清单:相同的密钥,相同的名字。
Info
即使你从没写过 InputRequiredResult,你也在这条路径上。参数里用了 Resolve(...)(依赖)的工具就是多轮往返工具,SDK 替它铸造并封存 request_state。同样的默认密钥,同样的跨 worker 失败,同样的修复办法。
跨副本的变更通知
客户端的 subscriptions/listen 流是一个长时间存活的响应,所以它整个生命期都钉在一个副本上。在另一个副本上发布的 ctx.notify_resource_updated(...) 必须能到达它。
两者之间的接缝是 SubscriptionBus。你给服务器的总线就是所有发布进入、所有打开的流监听的那一个,所以把同一个总线交给每个副本:
from mcp.server.mcpserver import Context, MCPServer
from mcp.server.subscriptions import SubscriptionBus
NOTES = {"todo": "buy milk"}
def make_server(bus: SubscriptionBus) -> MCPServer:
"""Every replica gets its own server object; all of them hold the same bus."""
mcp = MCPServer("Notebook", subscriptions=bus)
@mcp.resource("note://{name}")
def note(name: str) -> str:
"""One note, by name."""
return NOTES[name]
@mcp.tool()
async def edit_note(name: str, text: str, ctx: Context) -> str:
"""Replace a note's text."""
NOTES[name] = text
await ctx.notify_resource_updated(f"note://{name}")
return "saved"
return mcp
扇出的过程完全不关心一个流挂在哪个服务器对象上。持有同一个 InMemorySubscriptionBus 的两个服务器已经是这样:在其中一个上打开监听流,在另一个上 edit_note,流就能收到。这个内存总线只能跨同一进程内的服务器对象,所以它是模型,不是部署方案:
- 跨真正的进程时,SDK 没有提供任何能帮上忙的总线。
SubscriptionBus是一个两方法的Protocol(publish和subscribe),你在自己的 pub/sub 后端(Redis、NATS,或任何你已经在跑的东西)上实现它,并作为MCPServer(subscriptions=...)传入。订阅 有示意代码和契约。 - 总线承载的是四种小的有类型事件,从来不是 JSON-RPC。确认、过滤和流的生命周期都留在 SDK 里,所以你的总线不可能破坏协议;它只能在进程之间搬运事件。
- 流不可恢复,事件不会重放。丢失一个副本就丢掉它的流;客户端重新监听、重新获取。没有需要共享的事件存储,也没有别的需要配置。这是横向扩展真正只是"多来几份"的唯一一处。
SDK 不提供什么
MCPServer 是一个协议实现,不是应用服务器。你接下来会去找的那些部署开关是故意缺席的:
- 没有
workers=。mcp.run("streamable-http")启动恰好一个 uvicorn 进程,也永远只会启动一个。多进程就是把streamable_http_app()交给你本来部署 ASGI 用的东西:uvicorn --workers、gunicorn、你平台的进程管理器。这一页刻意不做它们任何一个的教程;它们自己的文档比这里照抄一份要好。 - 没有健康检查路由。
@mcp.custom_route("/health", methods=["GET"])就是全部答案,而且即使服务器其余部分有认证,它也从不认证。这对存活探针是对的,对任何私密内容是错的。添加到现有应用 有一个示例。 - 没有生产设置对象。
MCPServer上没有地方写超时、TLS、优雅关闭或连接数限制,因为这些都不是它的职责。它们属于你的 ASGI 服务器,在那里配置。运行你的服务器 讲了构造函数确实接受的那几个设置。 - 没有自带的
EventStore,在 2026-07-28 上也用不着。 可恢复性是旧版有状态那一支的特性;现代交换是一个 POST、一个响应,没有什么可恢复的。
回顾
- 默认情况下,这个应用只响应发往 localhost 的请求。
transport_security=TransportSecuritySettings(allowed_hosts=[...], allowed_origins=[...])是上线的关卡:在你传入它之前,真实主机名后面的每个请求都是421,原因只在服务器日志里。 - 在 2026-07-28 上没有会话,负载均衡器没有什么可粘的。
stateless_http=True是只对旧版有效的开关,因为现代请求在读到这个标志之前就已经被路由并响应了。 - 默认的
requestState密钥是os.urandom(32),按进程铸造。到达另一个 worker 的多轮往返重试会以-32602“Invalid or expired requestState” 失败。 - 修复办法是
RequestStateSecurity(keys=[...])并且每个实例使用相同的服务器名字。名字是令牌默认的 audience 声明。相同的密钥,相同的名字。 - 变更通知通过一个共享的
SubscriptionBus跨副本传递。SDK 唯一的实现是进程内的;在你自己的 pub/sub 上实现那个两方法的Protocol要由你来写。 - 没有
workers=,没有健康路由,没有生产设置对象。自带 ASGI 服务器。
真实主机名前面还需要的另一样东西是令牌:授权。