Model Context Protocol 的進階能力怎麼把模型呼叫、檔案權限、即時通知和遠端傳輸串在一起?這堂課從 Sampling、Roots 和雙向訊息開始,一路整理 Stdio、StreamableHTTP、SSE 與無狀態部署的取捨。
昨天的 Model Context Protocol 簡介 從零打造 MCP 客戶端與伺服器,今天接著進入 Model Context Protocol: Advanced Topics。
前一堂課把工具、資源和提示分開來看,這一堂開始處理更接近 production 的問題:伺服器需要模型幫忙時怎麼辦?長時間執行的工具要怎麼回報進度?檔案路徑要怎麼限制?客戶端和伺服器如果不在同一台機器上,又該選哪種 transport?
| 項目 | 內容 |
|---|---|
| 堂數 | 11 堂課 |
| 總時長 | 1.5 小時 |
| 測驗 | 1 個(10 題,約 8 分鐘) |
| 完成 | 有完成徽章 |
| 先決條件 | 具備 Python 開發與非同步程式設計模式的經驗;熟悉 JSON 訊息格式與 HTTP 協議;對伺服器發送事件(SSE)有基本了解 |
| 適合對象 | 從事 Model Context Protocol 實作的開發人員;建置 MCP 伺服器與客戶端的工程師(官方英文原文:Engineers building production MCP servers who need to understand the protocol's advanced capabilities) |
官方列出的學習內容,主要集中在這幾件事:
Sampling 讓伺服器透過已連接的 MCP 客戶端存取像 Claude 這樣的語言模型。伺服器不直接呼叫 Claude,而是請客戶端代為呼叫,把文本生成的責任和成本從伺服器轉移到客戶端。
這裡的「取樣」不是抽樣調查,也不是音訊取樣。LLM 生成文字時,技術上是在每一步從下一個 token 的機率分佈裡取樣;API 裡的 temperature 和 top_p 也屬於 sampling parameters。因此「請模型生成一段輸出」在 Machine Learning 的語境裡就叫 sampling,而 SDK 方法名 create_message(規格裡是 sampling/createMessage)指的是同一件事。
一般 MCP 流程裡,LLM 一直在客戶端那邊:
Sampling 的方向相反。它發生在伺服器執行工具的過程中,伺服器自己需要 LLM 才能把工作做完。以課程裡的研究工具為例:
兩種流程的差別,可以先看 prompt 和結果各自由誰處理:
| 一般 MCP 工具呼叫 | Sampling | |
|---|---|---|
| prompt 由誰組成 | 客戶端依照使用者問題組成 | 伺服器依照工具任務組成 |
| LLM 由誰呼叫 | 客戶端 | 客戶端代伺服器呼叫 |
| 生成結果先回到哪裡 | 客戶端,接著成為使用者答案 | 伺服器,伺服器可以繼續加工 |
| 伺服器是否需要自己的 API key | 視伺服器功能而定 | 不需要直接持有模型 API key |
所以,Sampling 解決的是「伺服器想用模型,但不想自己處理模型連線、憑證和每個使用者的 AI 費用」這個問題。公開 MCP 伺服器尤其適合這種設計:每個使用者透過自己的客戶端使用模型,伺服器不必替所有人負擔生成成本。
伺服器端在工具函式裡用 ctx.session.create_message() 送出 sampling request:
@mcp.tool()
async def summarize(text_to_summarize: str, ctx: Context):
prompt = f"""
Please summarize the following text:
{text_to_summarize}
"""
result = await ctx.session.create_message(
messages=[
SamplingMessage(
role="user",
content=TextContent(type="text", text=prompt)
)
],
max_tokens=4000,
system_prompt="You are a helpful research assistant",
)
if result.content.type == "text":
return result.content.text
else:
raise ValueError("Sampling failed")
客戶端則要寫一個 sampling callback,處理伺服器發過來的 request,再把 callback 傳給 ClientSession:
async def sampling_callback(context: RequestContext, params: CreateMessageRequestParams):
text = await chat(params.messages)
return CreateMessageResult(role="assistant", model=model, content=TextContent(type="text", text=text))
async with ClientSession(read, write, sampling_callback=sampling_callback) as session:
await session.initialize()
這裡還有一個實作細節:伺服器提供的訊息清單是 MCP 通訊格式,不保證能直接丟進你使用的 LLM SDK。假設客戶端使用 Anthropic SDK,就要額外把 MCP messages 轉成 Anthropic SDK 能接受的格式。
互動式演練把一次 sampling 拆成六個步驟:
create_message(),傳入想交給語言模型的訊息。CreateMessageResult。ClientSession 時傳入。這個流程讓 MCP 伺服器多了一個很有用的能力:它可以描述「我需要模型幫我完成哪一段工作」,但模型的選擇、登入狀態和費用管理仍由客戶端掌握。
長時間執行的工具如果完全沒有回饋,使用者很難判斷它是卡住、失敗,還是仍在處理。日誌和進度通知的實作不複雜,卻能讓操作感覺差很多。
Python MCP SDK 裡,工具函式會自動取得一個 Context 引數,使用它和客戶端溝通:
@mcp.tool(name="research", description="Research a given topic")
async def research(topic: str = Field(description="Topic to research"), *, context: Context):
await context.info("About to do research...")
await context.report_progress(20, 100)
sources = await do_research(topic)
await context.info("Writing report...")
await context.report_progress(70, 100)
results = await generate_report(sources)
return results
這段程式裡有兩個關鍵方法:
context.info():傳送日誌訊息給客戶端。context.report_progress():用目前值和總值更新進度。客戶端要自行決定怎麼呈現這些通知。CLI 可以直接印到終端機,網頁應用程式可以透過 WebSocket、SSE 或 polling 推送到瀏覽器,桌面應用程式則可以更新狀態文字和進度條。
async def logging_callback(params: LoggingMessageNotificationParams):
print(params.data)
async def print_progress_callback(progress: float, total: float | None, message: str | None):
if total is not None:
percentage = (progress / total) * 100
print(f"Progress: {progress}/{total} ({percentage:.1f}%)")
else:
print(f"Progress: {progress}")
async def run():
async with stdio_client(server_params) as (read, write):
async with ClientSession(read, write, logging_callback=logging_callback) as session:
await session.initialize()
await session.call_tool(
name="add",
arguments={"a": 1, "b": 3},
progress_callback=print_progress_callback,
)
日誌 callback 在建立 client session 時提供;進度 callback 則在個別工具呼叫時提供。兩者都是可選功能,客戶端可以完全忽略,也可以只顯示自己需要的通知類型。
通知功能的演練可以整理成四步:
Context 參數,取得記錄日誌和回報進度的方法。info()、warning()、debug()、error(),或用 report_progress() 回報工作進度。ClientSession,progress callback 給 call_tool()。這裡的通知比較接近 UX 層能力。它們不會改變工具最後回傳的資料,卻能讓使用者在等待期間知道系統正在做什麼。
Roots 是一種告訴 MCP 伺服器「可以存取哪些本機檔案和資料夾」的方式,可以把它想成檔案存取邊界。
假設有一個影片轉換工具,接受檔案路徑,把 MP4 轉成 MOV。使用者只說「把 biking.mp4 轉成 mov」,Claude 只知道檔名,不知道檔案實際位於哪個資料夾。要求使用者每次輸入完整路徑,體驗會很差;讓伺服器搜尋整個檔案系統,又會放大安全風險。
Roots 把這兩件事接起來:
list_roots,查看可存取的目錄。read_dir,找出檔案。使用者仍然只需要說「轉換 biking.mp4」。如果只授予 Desktop 資料夾存取權,MCP 伺服器就不應該碰 Documents、Downloads 等其他位置的檔案。
Roots 同時處理了幾種需求:
但安全邊界不會因為 SDK 有 Roots API 就自動生效。典型做法是自己寫 is_path_allowed():接收請求路徑,取得核准的 roots 清單,確認請求路徑落在其中一個 root 底下,再回傳 true 或 false。任何真正讀寫檔案的工具,都要在操作前執行這個檢查。
:::caution
MCP SDK 不會自動替檔案工具強制執行 root 限制。Roots 提供的是授權資訊,實際檔案存取仍要由伺服器在每個工具裡自行驗證。
:::
範例專案把 Roots 的使用拆成七步:
file:// 開頭的 URI,範例函式把路徑清單轉成 Root 物件。ListRootsResult,並把 callback 傳給 ClientSession。ctx.session.list_roots(),送訊息回客戶端並觸發 roots callback。is_path_allowed 之類的函式比對路徑。Roots 的價值不只在「能不能讀」。它把使用者授權、檔案探索和工具設計放到同一個協定流程裡,讓檔案型 MCP 工具不用把完整路徑和整台電腦的權限一起交出去。
MCP 使用 JSON 訊息處理客戶端與伺服器之間的通訊。Claude 要呼叫工具時,客戶端送出 Call Tool Request;伺服器處理完,再用 Call Tool Result 回應。
完整的訊息類型清單定義在官方 MCP 規範 repository,和 Python、TypeScript 等 SDK repository 分開。規範裡用 TypeScript 描述資料結構和型別,目的是讓協定更容易閱讀,不是要把那段 TypeScript 直接拿來執行。
訊息可以先分成兩類:
| 訊息類型 | 特性 | 例子 |
|---|---|---|
| 請求-結果訊息 | 一定成對,送出 request 後等待 result | Call Tool Request → Call Tool Result、List Prompts Request → List Prompts Result、Read Resource Request → Read Resource Result、Initialize Request → Initialize Result |
| 通知訊息 | 單向送出,不需要回應 | Progress Notification、Logging Message Notification、Tool List Changed Notification、Resource Updated Notification |
MCP 也依照發送者區分訊息:
這個雙向設計會直接影響 transport 的選擇。當伺服器只回應客戶端發來的 request 時,單向的 HTTP 模型看起來很自然;當伺服器需要主動發 sampling、roots、progress 或 logging 訊息時,傳輸層就要另外處理伺服器到客戶端的路徑。
MCP 客戶端和伺服器交換的是 JSON 訊息,實際 transport 可以是 HTTP、WebSocket 或其他方式。STDIO 是開發本機 MCP 伺服器時最常用的選項:客戶端把伺服器當成子程序啟動,透過標準輸入和標準輸出交換訊息。
STDIO 只適合客戶端和伺服器在同一台機器的情境:
開發時甚至可以直接從終端機測試 MCP 伺服器,不必先寫完整客戶端。用 uv run server.py 啟動伺服器後,把 JSON 訊息貼進 stdin,就能觀察 stdout 回傳的結果。
MCP 連線需要先完成三則訊息的初始化交握:
完成這段交握後,才能發送工具呼叫、提示列表查詢等其他請求。STDIO 的四種通訊方向可以整理成:
| 發起者 | 訊息 | 通道 |
|---|---|---|
| 客戶端 | 請求 | 寫入伺服器 stdin |
| 伺服器 | 回應 | 寫入客戶端可讀取的 stdout |
| 伺服器 | 請求 | 寫入客戶端可讀取的 stdout |
| 客戶端 | 回應 | 寫入伺服器 stdin |
STDIO 提供了雙向通訊的完整模型,很適合用來理解 MCP 的基礎行為。等到要把客戶端和伺服器放到不同機器,再面對 HTTP 的方向限制與 session 管理。
StreamableHTTP 讓 MCP 客戶端透過 HTTP 連到遠端託管的伺服器,突破 STDIO 必須在同一台機器的限制,也讓公開 MCP 伺服器成為可能。
這裡有兩個重要的配置旗標,預設都是 false:
stateless_http:控制是否採用無狀態 HTTP。json_response:控制回應是否使用單純 JSON。把它們設成 true 可能會犧牲進度通知、日誌、sampling 和伺服器發起的請求。原因在於標準 HTTP 很擅長處理「客戶端知道伺服器 URL,向伺服器發 request」,卻沒有自然提供「伺服器主動找到客戶端」的通道。
因此,以下 MCP 訊息在純 HTTP 裡需要額外設計:
Create Message。List Roots。StreamableHTTP 透過 SSE 等方式繞過這項限制,但當部署設定被迫使用 stateless_http=True 或 json_response=True 時,傳輸層就會退回較受限的 HTTP 模式。這不是單純換一個設定名稱,還會改變 MCP 伺服器能提供哪些功能。
StreamableHTTP 解決的根本問題是:某些 MCP 功能需要伺服器主動向客戶端發 request,但 HTTP 原本是客戶端向伺服器發 request。
它使用 Server-Sent Events(SSE)建立一條伺服器到客戶端的長期連線:
Initialize Request,伺服器回傳帶有 mcp-session-id header 的 Initialize Result,客戶端再送出帶著 session ID 的 Initialized Notification。所以,StreamableHTTP 的複雜度來自它要在 HTTP 的限制下保留 MCP 的雙向能力。初始化後的每個 request 都要帶 session ID,系統還要管理不同用途的 SSE 連線。理解這個模型,對除錯和判斷串流行為很重要。
stateless_http 和 json_response 會影響伺服器是否保留 session、是否能串流中間訊息,以及能不能使用伺服器發起的功能。這兩個旗標在 production 部署前需要分開理解。
當伺服器變熱門、單一實例撐不住流量時,常見做法是在 load balancer 後面跑多個伺服器實例。但 MCP 客戶端需要兩種獨立連線:
Load balancer 可能把兩種 request 路由到不同實例。如果工具執行期間要透過 sampling 呼叫 Claude,處理 POST 的伺服器就要和處理 GET SSE 的伺服器協調。這會引入 session 共享和跨實例通訊的複雜度。
把 stateless_http 設為 true 可以消除這類協調問題,但取捨也很明確:
換來的好處是:不需要客戶端初始化,請求可以直接處理,連線管理和水平擴展都比較單純。
json_response 做什麼?json_response=True 比較單純。它停用 POST request 的串流回應,工具執行完成後只回傳最後的 JSON 結果。中間的進度通知和執行期間的日誌不會透過這條 response 傳送。
可以用這張表快速判斷:
| 部署需求 | 適合選擇 |
|---|---|
| 需要在 load balancer 後水平擴展,不需要伺服器主動發訊息,也不需要 sampling | stateless_http=True |
| 不需要串流回應,只想取得工具執行完的 JSON 結果 | json_response=True |
| 需要 sampling、進度、日誌或 subscription | 保留有狀態的 StreamableHTTP |
| 客戶端和伺服器在同一台機器,正在開發和測試 | STDIO |
開發環境也要注意 transport 的差異。如果本地開發使用 STDIO,production 卻要部署 StreamableHTTP,最好在開發期間就使用接近 production 的 transport。否則有狀態與無狀態模式的差異,可能要到部署後才暴露。
有 10 題。以下保留題目與正確答案,中英對照的繁中是自譯。
答案:STDIO transport(STDIO 傳輸)。
答案:A system for informing the MCP server which files and directories it can access(一個告知 MCP 伺服器可以存取哪些檔案和資料夾的系統)。
答案:Initialize Request → Initialize Result → Initialized Notification。
答案:STDIO transport(STDIO 傳輸)。
Call Tool Request and expects a result. What type of message pattern is this?(MCP 工具發送 Call Tool Request 並期待取得結果,這是什麼類型的訊息模式?)答案:A request-result message(請求-結果訊息)。
答案:A way for a server to access a language model through a connected MCP client(讓伺服器透過已連接的 MCP 客戶端存取語言模型的方式)。
答案:json_response=True。
答案:It establishes a Server-Sent Events (SSE) connection(建立伺服器發送事件 SSE 連線)。
答案:Sampling(取樣)。
答案:Roots(根目錄)。
這堂進階課看完,我發現它的重心比想像中更靠近 App 客戶端。從 Sampling 到 STDIO、StreamableHTTP、SSE,表面上是不同功能和傳輸方式,實際上都在討論同一件事:MCP client 和 server 怎麼溝通、訊息怎麼雙向流動,以及資料要怎麼交換。
這也讓我重新理解 MCP 的進階難點。Server 能提供工具只是起點,真正要把能力做成可用的 App,還要處理 sampling callback、logging 和 progress callback、Roots、初始化交握、session 與 SSE 連線。下一步對照官方影片和測驗時,最值得再核對的就是 StreamableHTTP 的雙連線模型,因為它看起來很像「設定打開就好」,實際上每個旗標都在改變協定能做的事。
我是 Jasper,從事軟體開發,目前專注打造 AI 工作流程。
本文同步發佈於我的 Blog,和我一起探討更多 AI 議題 🚀。