部署與擴展
伺服器可以運作了。現在它需要一個真正的主機名稱,後面還要有不只一個 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
四個處理程序,一個 socket。接著是每次部署都得回答的問題:請求是否必須送到看過上一個請求的那個 worker?
對使用 2026-07-28 協定的用戶端來說,不用。現代請求是一個自成一體的 POST:前面沒有 initialize 交握,回應上沒有 Mcp-Session-Id,第二個請求沒有什麼可以「回去找」的對象。送到任何一個 worker 都行。
這不是一個要你開啟的模式。stateless_http=True 看起來像是,但傳輸層依 MCP-Protocol-Version 請求標頭分流,把現代請求交給現代處理函式,然後就回傳了。讀取 stateless_http 的那一行在那個 return 之後。並不是這個旗標在 2026-07-28 路徑上被忽略,而是根本執行不到。stateless_http 只是舊版那一支的開關,現代路徑在設計上就沒有工作階段(session)。
對規格版本 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;用戶端分不出差別。
祕密只鑄造一次,把同一個值交給每個執行個體。如果傳入少於 32 個位元組,SDK 自己的錯誤訊息就會叫你執行這條指令:
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。給伺服器什麼 bus,每次發布就進到那個 bus,每個開著的串流也都在上面聽,所以把同一個 bus 交給每個副本:
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 本來就是這樣運作:在其中一個上開啟 listen 串流,在另一個上 edit_note,串流就會聽到。那個記憶體內的 bus 只能跨越同一個處理程序裡的伺服器物件,所以它是模型,不是部署方案:
- 跨真正的處理程序時,SDK 沒有附任何幫得上忙的 bus。
SubscriptionBus是一個只有兩個方法的Protocol(publish和subscribe),由你在自己的 pub/sub 後端(Redis、NATS,或你已經在跑的任何東西)上實作,再以MCPServer(subscriptions=...)傳入。草稿與契約請見 訂閱。 - bus 載的是四種小型的有型別事件,從來不是 JSON-RPC。確認、過濾和串流生命週期都留在 SDK 裡,所以你的 bus 不可能破壞協定;它只能在處理程序之間搬運事件。
- 串流不能續傳,事件也不會重播。失去一個副本就丟掉它的串流;用戶端會重新 listen、重新抓取。沒有要共用的事件儲存區,也沒有別的要設定。這是唯一一個向外擴展真的只是「多幾台一樣的」的地方。
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 伺服器。
真正的主機名稱前面需要的另一樣東西是權杖:授權。