ARTICLE DETAIL

建站实战干货

来自一线的建站与推广经验沉淀,每一条都经过真实交付验证。

python-sdk 部署与水平扩展实战:Host 白名单、Worker 粘性与跨副本一致性

2026/9/21 0:10:40 拓冰建站 浏览量
python-sdk 部署与水平扩展实战:Host 白名单、Worker 粘性与跨副本一致性 人工智能MCP 服务MCP Clients【免费下载链接】python-sdkThe official Python SDK for Model Context Protocol servers and clients项目地址https://gitcode.com/gh_mirrors/pythonsd/python-sdk点击查看免费下载导读本文基于 Model Context Protocol 官方 Python SDKpython-sdk的部署与扩展指南讲解一个MCPServer从本地可运行到真实 hostname 多 worker上线过程中真正属于 MCP 的少数几件事一个卡住所有部署的transport_security设置以及超过一个 worker时 SDK 行为发生变化的两个位置——requestState密封与订阅通知扇出。读完本文你将掌握 Host/Origin 白名单配置、TLS 终结代理后的 uvicorn 参数、2026-07-28 协议下的无状态部署、跨 worker 共享requestState密钥的正确姿势以及跨副本广播资源变更通知的SubscriptionBus接缝。ASGI server、进程管理器、负载均衡器都不是 MCP 的分内事由你自带本文只聚焦那些确实是 MCP 的事一处设置、两处多 worker 行为变化。第一步Host 白名单DNS-rebinding 防护streamable_http_app()无法预知自己会被部署在哪个 hostname 后面因此它默认采用最安全的答案localhost。在没有传transport_security时应用会开启DNS-rebinding protection只有Hostheader 为127.0.0.1:port、localhost:port或[::1]:port的请求才会被接受Originheader若存在必须是同名的http://形式。这在本地机器上完全正确它能阻止恶意网页通过一个被它 rebind 到127.0.0.1的 DNS 名称驱动你的本地 server。部署到真实 hostname 后面时同样的默认值会拒绝每一个请求直到你显式声明。该检查运行在任何 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白名单修复方式是把真正服务的 hostname 加入白名单。完整示例来自 docs_src/deploy/tutorial001.pyfrom 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 fSaved: {text} security TransportSecuritySettings( allowed_hosts[mcp.example.com, mcp.example.com:*], allowed_origins[https://app.example.com], ) app mcp.streamable_http_app(transport_securitysecurity)配置要点如下allowed_hosts是精确字符串匹配mcp.example.com只匹配不带端口的裸Hostheadermcp.example.com:*匹配任意端口。两者通常需要同时列出。从 transport_security.py 的实现看_validate_host先做精确匹配再对以:*结尾的条目做base host 端口前缀匹配。allowed_origins只对浏览器有意义除浏览器外没有别的客户端会发送Origin。它是 asgi.md 中 CORS 配置的 server 端孪生配置。反向代理已控制Host时关闭该检查才是诚实的配置TransportSecuritySettings(enable_dns_rebinding_protectionFalse)。传非 localhost 的host如hostmcp.example.com并不会白名单该 hostname。它只会阻止 localhost 默认值启用防护结果反而变成每个 Host 和 Origin 都被接受。想表达什么就用transport_security明确表达。源码层面TransportSecuritySettings是 transport_security.py 中定义的 pydantic 模型三个字段分别为enable_dns_rebinding_protection默认True、allowed_hosts默认空列表、allowed_origins默认空列表。TransportSecurityMiddleware.validate_request依次校验 Content-Type、Host不通过返回421与 Origin不通过返回403校验发生在任何 MCP 逻辑之前。排查421 只在 server 日志里可见把transport_securitysecurity参数删掉再部署应用它能启动、/mcp能路由但包括普通curl在内的每个请求都会得到HTTP/1.1 421 Misdirected Request Invalid Host header在 client 端你找不到这些字样——421是纯文本 HTTP 响应而不是 JSON-RPC error因此 MCP client 只会抛出通用的 transport error那个不被接受的 hostname 仅以一条独立 warning 出现在server的日志中。一个刚部署却拒绝所有连接的服务在证明是其他原因之前都应首先怀疑 Host 白名单。相关排查也从 troubleshooting.md 开始。TLS 终结代理之后信任X-Forwarded-*如果 TLS 终结在代理上ingress、负载均衡器、Caddy、nginx而 uvicorn 在代理后面以纯 HTTP 提供服务需要告诉 uvicorn 信任代理的X-Forwarded-*headersuvicorn server:app --proxy-headers --forwarded-allow-ipsproxy address缺少该配置时应用会认为自己在http://上提供服务它发出的任何 redirect通常是/mcp→/mcp/都会指向http://…。Python client 拒绝从 HTTPS endpoint 降级到纯 HTTP并给出明确报错MCPError: Redirect to http://mcp.example.com/mcp/ not followed: it would downgrade this HTTPS endpoint to plain HTTP.client 侧临时解法直接配置 server 实际服务的完整 URLhttps://mcp.example.com/mcp/含末尾斜杠这样根本不会发生 redirect。根治方案就是上面的--proxy-headers --forwarded-allow-ips参数。环境变量形式FORWARDED_ALLOW_IPS是它的环境变量拼写传*表示信任每一跳这只有在除代理外无人能触达 uvicorn时才正确。Workers 与粘性sticky问题hostname 能响应之后就可以在它后面放多个 worker。SDK 没有提供 scaling 的旋钮Starlette app 按任何 ASGI app 的方式扩展——把对象交给懂 fork 的东西uvicorn server:app --workers 4四个进程共享一个 socket。现在每个部署都必须回答的问题出现了某个请求是否必须到达处理过上一个请求的那个 worker2026-07-28天然无状态对于讲2026-07-28协议的 client答案是不需要。modern 请求本身就是一次自包含的 POST没有前置的initialize握手响应上没有Mcp-Session-Id没有任何需要第二个请求回到的状态。把它路由到任意 worker 即可。这不是一个需要手动开启的模式。stateless_httpTrue看起来像是那个开关但实际上 transport 依据MCP-Protocol-Version请求 header 路由把 modern 请求交给 modern handler 后直接 return读取stateless_http的那行代码位于该 return之后。并不是说该 flag 在 2026-07-28 路径上被忽略了而是它根本不会被执行到。stateless_http只是legacy分支的旋钮modern 路径构造上就是 sessionless 的。三种 client 场景对照对于 spec 版本 2025-11-25 或更早的 legacy client答案取决于该 flagclient 的 protocol 版本Session负载均衡器需要做什么2026-07-28无。Mcp-Session-Id永远不会被设置。什么都不用做。任何 worker 服务任何请求。2025-11-25 及更早默认Mcp-Session-Id保存在某个 worker 的内存中。Sticky sessions。后续请求落到其他 worker 会得到404Session not found。2025-11-25 及更早配合stateless_httpTrue无。什么都不用做。代价是 server 到 client 的反向通道sampling、push elicitation、roots/list以及 resumability。关于 sticky sessions 与 legacy 分支的完整代价见 legacy-clients.md两个协议时代的划分见 protocol-versions.md。这里最重要的是结论的形状在 2026-07-28 上你已经是无状态的没有任何需要配置的东西。需要补充的是legacy session 是进程内的普通dict见 legacy-clients.md没有分布式 session store 可插拔因此多 worker 下 legacy client 必须 stickyevent_store解决的是 resumability同一 session 重连时重放 SSE 事件而非跨进程可达性别把它当成 session store 用。本文其余部分讲的是无状态并不能为你买到的那两样东西。跨 worker 的requestStatemulti-round-trip工具需要 client 去获取某些东西一个确认、一个选择、一个凭据因此它返回问题而不是答案在 retry 时完成工作。两轮之间 client 持有 server 铸造的一个不透明request_statetokenretry 时 server 必须重新打开该 token。用哪把 key 密封的默认情况下是 server 在构造时用os.urandom(32)生成的那把。在--workers 4下这意味着四次构造、四个进程四把不同的 key从未写入任何地方从未共享重启即消失。下面是一个行动前先询问的工具跑在一个什么都不配置的 server 上源自 docs_src/deploy/tutorial002.pyfrom mcp.server.mcpserver import Context, MCPServer from mcp.types import ElicitRequest, ElicitRequestFormParams, ElicitResult, InputRequiredResult CONFIRM ElicitRequest( paramsElicitRequestFormParams( messageIssue 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_statefrefund:{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 frefunded ${amount} return mcp第一轮到达 worker Aworker A 用自己的key 密封refund:120并返回 token。client 把问题摆到真人面前得到是后 retry——这个 retry 是一笔全新的 HTTP 请求。假设这次 retry 落到了 worker BB 试图 unseal 一个自己从未铸造的 token失败于是拒绝整轮。refund永远不会被调用client 收到 JSON-RPC error{ code: -32602, message: Invalid or expired requestState, data: {reason: invalid_request_state} }这条消息是冻结的。过期、被篡改、用不同参数 replay或真实部署中最常见的原因被兄弟 worker 密封——client 每次被告知的都是同一句话wire 上永远无法看出是哪项检查失败。真实原因只作为 server 日志中的一条WARNING出现requestState rejected on tools/call: unknown key一个在单 worker 时正常、两个 worker 时开始偶尔失败的 multi-round-trip 工具原因就是这个。两轮仍然必须到达同一个进程因此它失败的频率恰好等于负载均衡器把它们分开的频率。两轮是两个独立的 HTTP 请求许多常见情况都会把它们拆散按请求做均衡的 proxy、中途断掉的连接、一次 deploy 或 restart、保存了request_state后从完全不同的进程恢复的 client见 Driving the loop yourself。其中任何一种都相当于另一个 worker。修复一个参数的两个半部分修复方式是一个参数但它有两半完整示例来自 docs_src/deploy/tutorial003.pyfrom mcp.server.mcpserver import Context, MCPServer, RequestStateSecurity from mcp.types import ElicitRequest, ElicitRequestFormParams, ElicitResult, InputRequiredResult CONFIRM ElicitRequest( paramsElicitRequestFormParams( messageIssue 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_securityRequestStateSecurity(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_statefrefund:{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 frefunded ${amount} return mcpkeys[...]是大家都会找到的那一半。给每个实例同一个 secret至少 32 字节每个实例就能 unseal 任何兄弟铸造的 token。keys[0]负责密封列表中的每把 key 都能 unseal——这就是 rotation ring如何在零停机下轮换见 Rotating keys。server 的名字是几乎没人能找到的那一半也是共享 key 之后跨实例 retry 依然失败的真正原因。每个密封 token 都把 server 的name作为一个audience claim携带回程时被严格校验。由同一份代码构建的两个实例名字相同而且永远不会注意到这一点。给它们起不同的名字MCPServer(fbilling-{POD})看起来像良好的可观测性习惯那么每次跨实例 retry 都会如上被拒绝无论 key 是否共享。此时日志里写的是audience而不是unknown keyclient 察觉不到区别。secret 只铸造一次把同一个值交给每个实例。如果你传入的不足 32 字节SDK 自己的错误消息会提示你运行这条命令该提示也硬编码在 request_state.py 的ValueError中python -c import secrets; print(secrets.token_hex(32))[!warning] 相同的 keys而且相同的名字 多实例部署必须两者都共享。如果每个实例使用不同名字对你来说是必需的那就给整个 fleet 一个显式 audience 代替RequestStateSecurity(keys[...], audiencebilling)。这样每个实例无论叫什么名字都在billing之下铸造与接受。底层实现AES-256-GCM 密封从 request_state.py 的源码可以确认整套机制RequestStateSecurity的构造参数为keys与codec二选一ValueError: takes exactly one of keys or codec、ttl默认600.0秒、bind_principal默认绑定认证 principal、audience默认None由MCPServer传入其 server name 作为default_audience。内置AESGCMRequestStateCodec用 HKDF-SHA256 从操作者 secret 派生 AES-256 密钥token 是加密而非仅签名client 无法读取其中的 state每条 token 携带 4 字节非机密 key 指纹用于 O(1) 环形查找v1.前缀与指纹都绑定进 GCM associated data防止 token 被重放到其他格式版本或环槽位。RequestStateSecurity.ephemeral()内部即keys[os.urandom(32)]是MCPServer在未传request_state_security时安装的策略——这正是每进程一把随机 key、重启即失效的来源。RequestStateBoundary负责在 wire 边界密封/解封inbound 校验codec unseal expiry request binding audience principal失败统一走_reject——日志记录真实原因wire 上永远返回冻结的-32602Invalid or expired requestState。关于 seal 的其他一切——它绑定什么、每轮ttl默认 600 秒、自带 codec、为什么未配置的默认值在stdio上恰好正确——见 ProtectingrequestState。本文的全部贡献是一条两条目 checklist相同的 keys相同的名字。[!info] 你可能已经在走这条路了 即使你从未写过InputRequiredResult你也在这条路径上。参数使用Resolve(...)的工具见 Dependencies就是 multi-round-trip 工具SDK 会为它铸造并密封request_state。同样的默认 key、同样的跨 worker 失败、同样的修复。跨副本的变更通知Change Notificationsclient 的subscriptions/listenstream 是一个长生命周期响应因此它整个生命周期都钉在一个 replica 上。在另一个replica 上 publish 的ctx.notify_resource_updated(...)必须到达它。两者之间的接缝是SubscriptionBus。你给 server 的 bus 就是每个 publish 进入、每条打开的 stream 监听的同一个对象所以给每个 replica 传同一个 bus完整示例来自 docs_src/deploy/tutorial004.pyfrom 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, subscriptionsbus) 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 notes text. NOTES[name] text await ctx.notify_resource_updated(fnote://{name}) return saved return mcp扇出fan-out完全不关心 stream 挂在哪个 server object 上。两个持有同一个InMemorySubscriptionBus的 server 已经是这样行为了在一个上打开 listen stream在另一个上跑edit_notestream 就能听到。那个 in-memory bus 只覆盖单个进程内的 server objects所以它是模型model不是部署方案deployment跨真实进程时SDK 不附带任何能帮你的 bus。SubscriptionBus是一个只有两个方法的Protocolpublish和subscribe由你基于自己的 pub/sub 后端Redis、NATS或你已经在跑的任何东西实现然后以MCPServer(subscriptions...)传入。原型与契约见 Subscriptions。在 subscriptions.py 中可以看到publish是 async允许后端做网络 I/Osubscribe是同步本地注册并返回幂等退订函数。bus 只承载四个小型 typed events绝不承载 JSON-RPC。确认ack、过滤、stream 生命周期都留在 SDK 内因此你的 bus 不可能破坏协议它只能在进程之间搬运事件。stream 不可恢复not resumable事件不重放。丢失一个 replica 会同时丢掉它的 streamsclient 重新 listen、重新 fetch。没有可共享的 event store也没有其他可配置的东西。这是scale out 真的只是更多同样的副本的唯一位置。顺带一提ListenHandlersubscriptions.py的实现细节也印证了上述设计它先发送 ackstream 的第一帧为每条 stream 打上 listen 请求的 JSON-RPC id 作为_meta[io.modelcontextprotocol/subscriptionId]绝不投递 client 未请求的事件类型max_subscriptions默认 1024与max_buffered_events默认 1024分别限制并发 stream 数与单条 stream 的事件积压。legacy 时代的变更通知走的是另一根管道session 的 standalone stream两条管道互不互通详见 legacy-clients.md。SDK 不提供什么MCPServer是一个协议实现不是应用服务器。你接下来要找的那些部署旋钮是故意缺失的没有workers。mcp.run(streamable-http)恰好启动一个 uvicorn 进程而且永远只会启动这一个。多进程意味着把streamable_http_app()交给你已经用来部署 ASGI 的工具uvicorn --workers、gunicorn或你平台的进程管理器。本文刻意不做其中任何一个的教程——它们的官方文档比在这里复制一份更好。没有 health-check route。mcp.custom_route(/health, methods[GET])就是全部答案而且即使 server 其余部分都上了认证它也永远不会被认证。作为 liveness probe 这正合适放任何私有内容则不对。示例见 asgi.md。没有 production settings object。MCPServer上没有地方写下 timeout、TLS、graceful shutdown 或连接数限制因为这些都不是它的职责。它们属于你的 ASGI server你在那边配置。constructor 真正接收的那少数几项设置见 index.md。没有随附的EventStore而且在 2026-07-28 上也没有它的用武之地。Resumability 是 legacy 有状态分支的特性modern 交换是一次 POST、一个响应没有需要恢复的东西。总结部署上线 checklist开箱即用时app 只响应发给 localhost 的请求。transport_securityTransportSecuritySettings(allowed_hosts[...], allowed_origins[...])就是 go-live 闸门在传入它之前真实 hostname 后面的每个请求都是421原因只在 server 日志里。TLS 终结代理之后用--proxy-headers --forwarded-allow-ips...运行 uvicorn否则它的 redirects 指向http://而 client 会拒绝跟随。在 2026-07-28 上不存在 session负载均衡器没有需要 sticky 的东西。stateless_httpTrue是 legacy-only 的旋钮因为 modern 请求在 flag 被读取之前就已经路由并应答完毕。默认的requestStatekey 是os.urandom(32)每进程铸造。一个落到不同 worker 的 multi-round-trip retry 会以-32602Invalid or expired requestState失败。修复是RequestStateSecurity(keys[...])加上每个实例相同的 server name。name 是 token 的默认 audience claim。相同的 keys相同的名字。变更通知通过一个共享的SubscriptionBus跨越 replicas。SDK 唯一的实现是进程内的基于你自己的 pub/sub 实现那个两方法Protocol是你自己的功课。没有workers、没有 health route、没有 production settings object。自带你的 ASGI server。真实 hostname 前面还需要的另一样东西是 tokenAuthorization。赞分享人工智能MCP 服务MCP Clients【免费下载链接】python-sdkThe official Python SDK for Model Context Protocol servers and clients项目地址https://gitcode.com/gh_mirrors/pythonsd/python-sdk点击查看免费下载相关推荐Python MCP SDK 部署与水平扩展实战Host 白名单、TLS 代理、多 Worker 与跨副本状态共享Python MCP SDK 部署与水平扩展实战Host 白名单、TLS 代理、多 Worker 与跨副本状态共享 python sdk Model Con人工智能MCP 服务MCP Clientspython-sdk 服务部署与水平扩展实战Host 白名单、粘性会话、requestState 与 SubscriptionBuspython sdk 服务部署与水平扩展实战Host 白名单、粘性会话、requestState 与 SubscriptionBus 本篇指南围绕 MCPM人工智能MCP 服务MCP ClientsMCP Python SDK 部署与扩展Host 白名单、多 Worker 与跨副本状态同步实战MCP Python SDK 部署与扩展Host 白名单、多 Worker 与跨副本状态同步实战 本指南基于 MCPModel Context Protoc人工智能MCP 服务MCP Clients创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考