AG Day 25 MCP 概念:Model Context Protocol 架構
執行需求:CPU 可跑。昨天的 AG Day 24 搜尋工具:讓 Agent 查網路(原文連結)把我們的工具箱擴張到「可以自己找資料」,但這些工具(search_web、fetch_page)目前都是專案內部的 Python 函式,只有 research-agent 自己能用。如果明天另一個代理、另一個產品線也想用同一組搜尋與擷取能力,就只能把程式碼複製一份,或者各自維護各自的版本。這顯然不是好的工程做法。今天要處理的正是這個問題:把工具介面變成標準協定,讓任何遵循協定的客戶端都能呼叫同一個伺服器,不必重新實作。我們會從頭拆解這個被稱為 MCP(Model Context Protocol)的協定的角色分工、訊息格式與傳輸方式,先不依賴任何官方 SDK,親手用 Python 標準函式庫走一遍握手與工具呼叫的流程,把底層機制看清楚;之後用框架才不會覺得是黑盒子。
引言
先談一個情境。假設你已經把 research-agent 寫得很完整,並且把搜尋、抓取、檢索、寫報告的工具都模組化好了。有一天同事拿著他寫的「自動寫文案代理」來找你:「你那個 fetch_page 看起來很讚,能不能借我用?」你的第一個反應大概是「好,我把那段程式碼給你」。但他複製過去之後發現幾件事:第一,函式簽名跟你專案內的狀態物件綁在一起,要改;第二,他自己用的 httpx 版本跟你不同,重試設定也跟著要改;第三,他想用同一個工具在兩個不同的代理流程裡各別記錄事件,需要你把 logging 換成可注入的設計。三天後他的工具呼叫出了 bug,你們倆各自除錯,最後各自維護一份看起來很像又不完全一樣的程式。這就是「工具當函式賣」的典型副作用。
MCP(Model Context Protocol)的出現就是為了解決這件事。它把「工具提供者」與「工具消費者」之間的通訊方式固定成一份開放規格:伺服器負責宣告自己提供哪些工具、資源、提示範本,客戶端透過標準化的訊息呼叫它們;只要雙方都遵守同一份規格,跨程式語言、跨產品線的工具共用就變得可行。把它想成「代理世界裡的 HTTP」也不為過——HTTP 之前,每個系統各自實作 RPC;HTTP 之後,瀏覽器、伺服器、API 客戶端都能互聯互通。MCP 想在代理工具這層做類似的事。
今天的目標很單純:把這份協定的骨架讀懂,並且不裝任何第三方套件就能跑出一輪「宣告工具 → 客戶端發現 → 客戶端呼叫」的完整互動。我們會用 Python 標準函式庫的 subprocess 與 json,搭配 stdio 這個最簡單的傳輸通道,從最底層走一次流程。明天(AG Day 26 第一個 MCP server:用 FastMCP)才會用 FastMCP 把今天手刻的訊息交換包成正式的 MCP 伺服器。
原理/觀念
MCP 想解決的核心問題
在進入規格細節之前,先把問題想清楚。代理工程目前最大的痛點之一是「工具的供應與使用割裂」。每一個代理框架(LangGraph、Autogen、CrewAI 等)都發明了自己的工具描述格式;每一個模型供應商(OpenAI、Anthropic、Google 等)的 function calling schema 也不盡相同;每一個想提供工具的開發者都得為每個框架各寫一份。當工具數量、代理數量、使用情境都成長起來,這份對齊工作會迅速膨脹到無法維護。
MCP 的設計目標是把這層對齊標準化。它定義了一套「模型與工具之間的中介語言」,使得任何代理(host)只要內建一個 MCP 客戶端,就能呼叫任何遵循同一份規格的伺服器(server)。伺服器不必知道背後跑的是哪個模型,客戶端也不必知道工具的實作細節。這樣的設計讓工具有了「可移植性」與「可組合性」,也讓生態系有機會以「提供 MCP server」作為單一的發布形式。
三方架構:host、client、server
規格把參與者分成三種角色。Host 是「使用工具的代理本體」,通常是整合了語言模型的應用,例如 Claude Desktop、編輯器外掛、自建的 research-agent。它負責理解使用者需求、決定要呼叫哪個工具、組合結果。Client 是 host 內部的協定層,它幫 host 把「我要呼叫某某工具」翻譯成符合 MCP 規格的訊息,並透過某種傳輸通道送給對應的 server。一個 host 通常會有多個 client,每個 client 對應一個獨立連線的 server。Server 是工具提供者,它對外宣告自己有哪些工具、資源、提示範本,並回應 client 的請求。
這個分工的價值是「責任分離」。host 不需要懂工具的底層實作,只需要懂 MCP;client 不需要懂模型,只需要懂訊息格式;server 不需要知道是誰在呼叫自己,只需要懂 MCP。三者可以由不同團隊、不同語言、不同部署形式組合而成,這也是 MCP 能形成生態系的結構基礎。
JSON-RPC 2.0 作為訊息骨架
MCP 選擇了 JSON-RPC 2.0 作為訊息格式。這是一個歷史悠久的開放規格(早於 Web3 與許多現代 RPC 框架),特色是「輕量、無版本包袱、以 JSON 表示」。一個 JSON-RPC 訊息分為三種:request(帶 id、等待回應)、notification(不帶 id、不期待回應)、response(對 request 的回應,可能是成功結果或錯誤)。這個分類讓非同步事件與同步請求得以在同一條連線上和平共存。
具體來看,一個 request 看起來像這樣:
{"jsonrpc": "2.0", "id": 1, "method": "tools/list", "params": {}}
成功回應會帶同樣的 id:
{"jsonrpc": "2.0", "id": 1, "result": {"tools": [{"name": "echo", "description": "回傳收到的字串", "inputSchema": {"type": "object"}}]}}
錯誤回應則帶 error 欄位,包含錯誤碼與訊息:
{"jsonrpc": "2.0", "id": 1, "error": {"code": -32601, "message": "Method not found"}}
這三種訊息形式足以表達「我準備好了」(initialize)、「你能做什麼」(tools/list)、「請幫我做這件事」(tools/call)等所有互動。規格在 JSON-RPC 之上多包了一層「方法名與參數語意」,但訊息的外殼仍然是這個簡單三件式結構。
三種能力:tools、resources、prompts
MCP 把伺服器能提供的東西分成三類,分別對應不同的使用模式。Tools 是「可以被模型主動呼叫的函式」,通常代表一個有副作用、需要執行的工作,例如「搜尋網路」「送出訊息」「讀取檔案」。tools 是規格最早定義、也是目前生態最豐富的能力。Resources 是「可以被客戶端存取的資料」,例如一份設定檔、一段書面內容、一張資料表快照;它們通常不由模型主動呼叫,而是由客戶端根據上下文(例如「使用者正在編輯某檔案」)主動推送給模型。Prompts 是「預先設計好的提示範本」,可以帶參數、可以選擇是否要交給使用者確認;它們讓伺服器能貢獻「對話策略」而不只是資料與函式。
三者的差別可以用一個比喻:tools 是「員工可以做的事」、resources 是「員工可以讀的資料」、prompts 是「公司寫好的 SOP」。一個完整的 MCP server 可能同時是員工、圖書館與顧問公司,端看它的設計意圖。今天先把這個分類記起來,明天與後天會在程式裡把它們一一實作出來。
傳輸層:stdio、SSE、streamable HTTP
MCP 訊息本身是協定層的事;訊息要走哪條實體通道,是傳輸層的問題。規格在 2025-06-18 版支援三種主要傳輸。stdio 是最單純的:client 啟動 server 為子行程,雙方透過標準輸入輸出交換訊息,一行一個 JSON。它的好處是零網路設定、適合本機與單機開發;缺點是 server 必須是「可以當命令列工具啟動」的行程。SSE(Server-Sent Events) 是基於 HTTP 的單向推送,server 主動送訊息給 client,client 則用一般 HTTP POST 回送。適合 server 與 client 分屬不同機器的情境。streamable HTTP 是 2025-06-18 版新增的單一 HTTP 端點傳輸,雙向訊息都在同一條 HTTP 連線上交換,設計上更單純,也是目前官方推薦用於遠端伺服器的形式。
規格本身不限於哪一種傳輸;只要雙方能在訊息層正確解讀 JSON-RPC,就符合。今天我們為了把焦點放在訊息格式上,會全部走 stdio;這也是 AG Day 26 用 FastMCP 時的預設傳輸,學習成本最低、除錯最容易。
初始化握手:規格的第一步
在開始任何工作之前,client 與 server 必須先進行一輪「握手」。握手的目的有四:交換協定版本、確認彼此能力(capabilities)、驗證身分或金鑰(可選)、決定 session 識別。簡化版本只包含前三項。一個典型的握手流程是:client 送出 initialize request,server 回應版本與能力;client 收到後送出 notifications/initialized(注意這是 notification,不是 request,所以不期待回應);之後才進入正常的工作階段(tools/list、tools/call 等)。
這層握手看似多餘,但它解決了兩個重要的相容性問題。第一,版本差異:不同時期的 MCP 規格可能會有不相容的變更,透過交換版本號,雙方能在握手階段就發現無法對齊並提早結束,而不是跑到一半才發現某個方法根本不存在。第二,能力協商:不是所有 server 都提供 resources 或 prompts;client 透過握手知道對方能提供什麼,就不會去呼叫它不支援的方法。
完整實作
接下來我們用 Python 標準函式庫,從最底層實作一個「符合 MCP 訊息格式」的最簡伺服器與客戶端。我們不裝 mcp 套件,只用 json、subprocess、sys,這樣你可以直接讀完程式就懂訊息怎麼流動。完整檔案放在 research-agent/scripts/mcp_min/。
第一步,先定義共用的訊息輔助函式。把它放在 mcp_min/rpc.py,讓伺服器與客戶端共用同一份序列化與解析邏輯,避免兩邊各自實作而對不上:
"""research-agent/scripts/mcp_min/rpc.py:MCP 訊息的序列化與解析。
注意:這是教學用的精簡版本,不處理邊緣情況,僅示範訊息格式。
"""
import json
import sys
from typing import Any
def send_message(message: dict[str, Any], stream=sys.stdout) -> None:
"""以 JSON-RPC 慣例寫入一行 JSON,附上換行作為訊息邊界。"""
stream.write(json.dumps(message, ensure_ascii=False))
stream.write("\n")
stream.flush()
def read_message(stream=sys.stdin) -> dict[str, Any] | None:
"""讀取一行 JSON;遇到 EOF 回傳 None。"""
line = stream.readline()
if not line:
return None
return json.loads(line)
def make_request(method: str, params: dict[str, Any] | None = None,
message_id: int | str = 1) -> dict[str, Any]:
"""建立一個 JSON-RPC request 訊息。"""
message: dict[str, Any] = {"jsonrpc": "2.0", "id": message_id, "method": method}
if params is not None:
message["params"] = params
return message
def make_response(result: Any, message_id: int | str) -> dict[str, Any]:
"""建立一個 JSON-RPC 成功回應。"""
return {"jsonrpc": "2.0", "id": message_id, "result": result}
def make_error(code: int, message: str, message_id: int | str | None = None) -> dict[str, Any]:
"""建立一個 JSON-RPC 錯誤回應。"""
payload: dict[str, Any] = {"code": code, "message": message}
return {"jsonrpc": "2.0", "id": message_id, "error": payload}
def make_notification(method: str, params: dict[str, Any] | None = None) -> dict[str, Any]:
"""建立一個 notification(沒有 id,不期待回應)。"""
message: dict[str, Any] = {"jsonrpc": "2.0", "method": method}
if params is not None:
message["params"] = params
return message
段落說明:send_message() 在最後加上換行,這是「stdio 上一行一個訊息」慣例的關鍵;如果忘了 flush(),另一端的 readline() 會在緩衝區裡空等。錯誤碼採用 JSON-RPC 標準的負數區間(-32768 到 -32000 為預留;-32600 為 Parse error;-32601 為 Method not found;-32602 為 Invalid params;-32603 為 Internal error)。為了教學簡化,這個版本不做嚴格的錯誤碼檢查,但實務上伺服器應該回傳正確的錯誤碼給客戶端做差異化處理。
第二步,實作一個最小可運作的伺服器。它只提供兩個工具:「echo」回傳收到的字串、「add」做兩數相加。把它放在 mcp_min/server.py:
"""research-agent/scripts/mcp_min/server.py:最小 MCP-like 伺服器。
提供兩個工具 echo 與 add,並遵守 MCP 握手順序(initialize → initialized → 工作)。
"""
import sys
from rpc import (
read_message,
send_message,
make_response,
make_error,
make_notification,
)
SERVER_INFO = {
"name": "mcp-min-server",
"version": "0.1.0",
}
CAPABILITIES = {
"tools": {"listChanged": False},
}
TOOLS = [
{
"name": "echo",
"description": "回傳收到的字串,方便驗證通訊是否正確",
"inputSchema": {
"type": "object",
"properties": {"text": {"type": "string"}},
"required": ["text"],
},
},
{
"name": "add",
"description": "把兩個整數相加並回傳結果",
"inputSchema": {
"type": "object",
"properties": {
"a": {"type": "integer"},
"b": {"type": "integer"},
},
"required": ["a", "b"],
},
},
]
def handle_initialize(params: dict, message_id) -> dict:
return make_response(
{"protocolVersion": "2025-06-18", "serverInfo": SERVER_INFO, "capabilities": CAPABILITIES},
message_id,
)
def handle_tools_list(params: dict, message_id) -> dict:
return make_response({"tools": TOOLS}, message_id)
def handle_tools_call(params: dict, message_id) -> dict:
name = params.get("name")
arguments = params.get("arguments") or {}
if name == "echo":
return make_response({"content": arguments.get("text", "")}, message_id)
if name == "add":
return make_response(
{"content": int(arguments["a"]) + int(arguments["b"])},
message_id,
)
return make_error(-32602, f"未知工具:{name}", message_id)
METHODS = {
"initialize": handle_initialize,
"tools/list": handle_tools_list,
"tools/call": handle_tools_call,
}
def main() -> None:
initialized = False
while True:
message = read_message(sys.stdin)
if message is None:
break
method = message.get("method")
message_id = message.get("id")
params = message.get("params") or {}
if not initialized and method != "initialize":
send_message(make_error(-32000, "請先呼叫 initialize", message_id))
continue
handler = METHODS.get(method)
if handler is None:
send_message(make_error(-32601, f"Method not found: {method}", message_id))
continue
reply = handler(params, message_id)
send_message(reply)
if method == "initialize":
initialized = True
if __name__ == "__main__":
main()
段落說明:initialized 是伺服器內部的狀態旗標,用來確保 client 先做過握手才允許呼叫工具。這個檢查是規格的「最低限度」實作:真正的 MCP 規格還會要求 client 在收到 initialize 回應後,主動送一個 notifications/initialized,但因為是 notification(沒有 id),伺服器不必回應;為了讓教學版最短,我們在 client 端的握手完成後直接送這個 notification,而不是由伺服器檢查它。回傳結果的 content 欄位是個簡化的示範——MCP 規格在 tools/call 的回傳上定義了文字與圖片等多種內容型別,完整的 SDK 會把工具的回傳包成 Content 物件,細節以官方規格與 SDK 指南為準。
第三步,實作對應的客戶端。它啟動伺服器作為子行程、送出握手訊息、列出工具、實際呼叫一次。把它放在 mcp_min/client.py:
"""research-agent/scripts/mcp_min/client.py:最小 MCP-like 客戶端。
啟動 server.py 作為子行程,跑一輪握手、列出工具、實際呼叫 echo 與 add。
"""
import json
import subprocess
import sys
from rpc import (
read_message,
send_message,
make_request,
make_notification,
)
def call(server: subprocess.Popen, method: str, params: dict | None = None) -> dict:
"""送出 request 並等待對應 id 的回應。"""
request_id = 1 # 教學版使用固定 id,實務上會遞增
send_message(make_request(method, params, request_id), stream=server.stdin)
while True:
message = read_message(server.stdout)
if message is None:
raise RuntimeError("伺服器已關閉")
if message.get("id") == request_id:
return message
def main() -> None:
server = subprocess.Popen(
[sys.executable, "mcp_min/server.py"],
stdin=subprocess.PIPE,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
text=True,
encoding="utf-8",
)
try:
# 1) 握手:client 送出 initialize,server 回應版本與能力
hello = call(server, "initialize", {
"protocolVersion": "2025-06-18",
"clientInfo": {"name": "mcp-min-client", "version": "0.1.0"},
})
print("initialize 回應:", json.dumps(hello.get("result"), ensure_ascii=False))
# 2) 送出 initialized notification(不期待回應)
send_message(make_notification("notifications/initialized"), stream=server.stdin)
# 3) 列出可用工具
listed = call(server, "tools/list")
tools = listed["result"]["tools"]
print("可用工具:", [tool["name"] for tool in tools])
# 4) 實際呼叫 echo
echo_reply = call(server, "tools/call", {
"name": "echo",
"arguments": {"text": "你好,MCP"},
})
print("echo 結果:", echo_reply["result"])
# 5) 實際呼叫 add
add_reply = call(server, "tools/call", {
"name": "add",
"arguments": {"a": 7, "b": 35},
})
print("add 結果:", add_reply["result"])
finally:
server.stdin.close()
server.wait(timeout=5)
if __name__ == "__main__":
main()
段落說明:call() 用一個 while True 迴圈持續讀取伺服器輸出,直到拿到對應 id 的回應為止——這個寫法能正確忽略伺服器主動推播的 notification(沒有 id),只回應我們發出去的 request。實務的 MCP 客戶端會維護一個「等待中的請求」的字典,把 notification 與 response 路由到不同的處理函式;這裡為了教學簡化,用迴圈略過即可。text=True 與 encoding="utf-8" 是必要的,否則 subprocess 預設走 bytes,跟我們的 readline() 介面對不上。
第四步,用 shell 把這套最小骨架跑起來,確認訊息真的能往返:
cd research-agent
python scripts/mcp_min/client.py
預期輸出(節錄):
initialize 回應: {'protocolVersion': '2025-06-18', 'serverInfo': {'name': 'mcp-min-server', 'version': '0.1.0'}, 'capabilities': {'tools': {'listChanged': False}}}
可用工具: ['echo', 'add']
echo 結果: {'content': '你好,MCP'}
add 結果: {'content': 42}
段落說明:這份輸出由本機最小伺服器示範產生,不代表任何官方 MCP server 的真實格式。請把它當成「我們對訊息格式的理解是否正確」的最小驗證;如果哪一個欄位跟你手上的官方 SDK 對不上,代表我們的簡化版省略了某些欄位,請回到規格本身核對。
第五步,把訊息交換的可觀測性補上。真實的除錯場景中,把每一行訊息印出來是最有效的診斷手段。我們為 rpc.py 加上一個可開關的記錄函式:
"""research-agent/scripts/mcp_min/log.py:訊息層級的可觀測輔助。"""
import json
import os
import sys
ENABLED = os.environ.get("MCP_MIN_LOG") == "1"
def log(direction: str, message: dict) -> None:
if not ENABLED:
return
sys.stderr.write(f"[{direction}] " + json.dumps(message, ensure_ascii=False) + "\n")
sys.stderr.flush()
段落說明:為什麼用 stderr 而不是 stdout?因為 MCP 的訊息通道本身就走 stdout,任何除錯訊息都不能擠進同一條流,否則另一端的 readline() 會把它當成 JSON 解析失敗。這是 stdio 傳輸最常見的踩雷點之一,把日誌導向 stderr 才能在不破壞訊息流的前提下觀察雙方行為。把這個 log() 函式導入 rpc.send_message 與 rpc.read_message 就能看到完整對話紀錄;為了簡化範例,這裡只展示函式本身,呼叫端的改動留給讀者練習。
第六步,把今天這套「最小訊息骨架」與 MCP 規格做一次正式對照。把我們用過的方法、訊息欄位與概念整理成下表,方便之後查閱:
"""research-agent/scripts/mcp_min/concepts.py:把今日用過的概念做成常數表。"""
PROTOCOL_VERSION = "2025-06-18"
CONCEPTS = {
"host": "整合語言模型的代理本體,例如 research-agent",
"client": "host 內部的協定層,把工具呼叫翻譯成 MCP 訊息",
"server": "工具提供者,對外宣告工具、資源、提示範本",
"tools": "可被模型主動呼叫的函式,是 server 最常見的能力",
"resources": "可被客戶端存取的資料,例如檔案、書面快照",
"prompts": "預先設計好的提示範本,可帶參數",
"initialize": "握手第一個 request,交換版本與能力",
"notifications/initialized": "握手完成後 client 送出的 notification",
"tools/list": "列出伺服器所有可用工具",
"tools/call": "呼叫指定工具並回傳結果",
"stdio": "以子行程 stdin/stdout 作為傳輸層,最簡單的本機選項",
"streamable HTTP": "2025-06-18 版新增的 HTTP 雙向傳輸",
"SSE": "以 Server-Sent Events 為基礎的單向推送傳輸",
}
def render() -> str:
lines = ["MCP 概念對照表(教學版)"]
for key, value in CONCEPTS.items():
lines.append(f"- {key}: {value}")
lines.append(f"協定版本基準:{PROTOCOL_VERSION}")
return "\n".join(lines)
if __name__ == "__main__":
print(render())
段落說明:這個檔案本身不是 MCP 規格的一部分,它是我們今天學到的術語整理。把它放在專案裡有兩個好處:一是當未來你讀官方說明卡住時,可以回來看這份對照;二是當你換到另一個 SDK(例如 FastMCP 或官方 mcp 套件)時,這份表能幫你快速確認術語有沒有被翻譯成不同名稱。
第七步,示範錯誤處理。我們的最小伺服器只回應固定的方法,若收到未知方法會回 JSON-RPC 標準錯誤。對應到規格,這個錯誤碼是 -32601(Method not found),客戶端可以依錯誤碼決定要不要重試或顯示提示。為了讓讀者更熟悉這個流程,這裡示範一段客戶端程式主動測試「未知方法」並讀取錯誤碼:
"""research-agent/scripts/mcp_min/test_error.py:驗證伺服器的錯誤回應。
呼叫一個不存在的方法,並讀取伺服器回傳的 error.code。
"""
import json
import subprocess
import sys
from rpc import make_request, read_message, send_message
def main() -> None:
server = subprocess.Popen(
[sys.executable, "mcp_min/server.py"],
stdin=subprocess.PIPE,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
text=True,
encoding="utf-8",
)
try:
# 先完成握手,伺服器才會接受後續呼叫
send_message(make_request("initialize", {"protocolVersion": "2025-06-18"}, 1),
stream=server.stdin)
read_message(server.stdout) # 吃掉 initialize 回應
send_message({"jsonrpc": "2.0", "method": "notifications/initialized"},
stream=server.stdin)
# 故意呼叫一個不存在的工具
send_message(make_request("tools/call", {"name": "no_such_tool"}, 2),
stream=server.stdin)
while True:
reply = read_message(server.stdout)
if reply is None:
break
if reply.get("id") == 2:
print("伺服器回應:", json.dumps(reply, ensure_ascii=False))
assert reply["error"]["code"] == -32602, "應該回 Invalid params"
break
finally:
server.stdin.close()
server.wait(timeout=5)
if __name__ == "__main__":
main()
段落說明:這段驗證腳本的目的是把「錯誤也是回傳值」這條紀律落到實處。在實際的代理系統裡,模型必須能讀懂 error.code 與 error.message,才能決定下一步該重試、該改參數還是該換工具。把這個機制在我們的最小骨架裡示範一次,比讀十遍規格說明更有用。
常見錯誤與踩雷
第一個錯誤是忘了換行。json.dumps() 之後一定要加 \n,否則另一端的 readline() 永遠讀不到完整訊息,整個握手會卡在原地。一旦看到「initialize 回應永遠不出現」,第一個檢查點就是訊息結尾的換行。
第二個錯誤是忘了 flush()。print() 預設會 flush,但 stream.write() 不會。如果你的程式只看到「client 送出去、伺服器沒反應」,那很可能是客戶端的訊息還在緩衝區裡,伺服器端的 readline() 還在空等。請在每一次 write() 之後加 flush(),或者乾脆把輸出物件包成自動 flush 的寫法。
第三個錯誤是忘了區分 stdout 與 stderr。stdio 傳輸把訊息通道定義在 stdout;如果你在 server 端用 print() 印除錯訊息,它會跟 MCP 訊息擠在同一條流,客戶端的 JSON 解析就會壞掉。除錯輸出一律走 stderr,這是 stdio 通道的鐵則。
第四個錯誤是 id 不一致或重複。JSON-RPC 用 id 把 request 與 response 配對;如果客戶端用了兩個相同的 id,第二個回應就會被誤認到第一個請求上。實務上請用計數器或 uuid 產生唯一 id,並且把等待中的請求存在字典裡,等回應進來再取出。
第五個錯誤是沒做握手就直接呼叫工具。規格規定 client 必須先送 initialize、再送 notifications/initialized,之後才能呼叫 tools/list、tools/call 等工作方法。我們的伺服器加了 initialized 旗標來擋下沒握手的呼叫,這是規格要求的最低實作。忘記握手的後果可能是伺服器直接回錯誤,或更糟的是某些伺服器完全不檢查就接受呼叫,造成除錯時找不到原因。
第六個錯誤是忽略版本字串。我們刻意寫死 2025-06-18,是因為這是規格在 2026 年 7 月時點的最新公開版本之一;若你在未來讀到這篇文章,請依當時的官方說明為準,把版本字串換成正確的值。版本對不上時,伺服器會回 protocolVersion 不相符的錯誤,這也是握手存在的目的之一。
第七個錯誤是把「通知」當成「請求」。notifications/initialized 沒有 id,不能期待伺服器回應;如果你在客戶端送完之後還在等回應,就會一直等到逾時。客戶端程式對 notification 應該「送出就忘」,不應該進入等待狀態。
第八個錯誤是直接把整個 stdio 通道交給 MCP 而忘了留除錯管道。我們用 stderr 印訊息是其中一種做法,更工程級的做法是把訊息流包到專屬 logger(例如寫到檔案或推到追蹤平台)。這件事之後在 AG Day 35 談 Langfuse 時會再次出現,請把它跟「日誌不等於訊息通道」這條紀律記在一起。
效能與實務提醒
先講傳輸選擇的取捨。stdio 的延遲最低、零網路設定、訊息格式最單純,是本機與單機開發的預設選擇。但它有一個結構性限制:client 必須能啟動 server 作為子行程,這對「遠端託管的服務」與「行動裝置」不友善。當你需要跨機器呼叫、或者要對工具供應商隱藏內部實作時,請改用 streamable HTTP 或 SSE,並且記得它們都需要正確的網路設定與身分驗證。
再講訊息大小。JSON-RPC 沒有對單一訊息大小設硬限制,但實務上不應在一個 params 裡塞進整本小說。請把傳輸的內容視為「協定的承載」,把真正的資料視為「可由客戶端另外取得的資源」。例如與其用 tools/call 回傳整份書面,不如回傳書面的 URI 或 id,讓客戶端用 resources/read 自行取得;這個模式既省 token,也讓權限控管更細緻。
第三個是連線管理。一個 host 可能同時對接多個 server;建議在 host 內部為每個 server 維護獨立的 client 物件,各自負責自己的連線、心跳與重連。一旦其中一個 server 故障,你不會拖累其他 server;反過來說,每個 client 都需要做好「連線中斷 → 重試握手 → 重新列出工具」這條復原路徑。
第四是觀測與日誌。MCP 訊息本身就是極好的遙測資料:每一個 method 與 id 都可以被收集起來畫時序圖、計算錯誤率、量測 token 消耗。在我們把官方 SDK 接進研究助理之前,可以先用今天的最小骨架當作遙測來源——把每一行訊息寫到檔案,就能看到完整的對話紀錄。這個能力會在後面 AG Day 35 的 Langfuse 章節中重新登場,屆時你會看到更完整的觀測設計。
最後是安全邊界。MCP 把工具的執行權從模型手中交給了伺服器,這是巨大的能力,也是巨大的風險。任何被代理呼叫的工具都應該視為「外部輸入」:請對工具的輸入做驗證、對輸出做大小限制、對敏感操作加上二次確認。我們在 AG Day 37 談 prompt injection 與工具防護時會回頭強化這一塊。今天先把「工具呼叫需要被視為不可信任的動作」這個觀念種下,之後的章節會反覆用上。
小結
今天我們從「為什麼需要 MCP」開始,拆解了這個協定的角色分工、訊息格式、能力分類與傳輸選擇。重點整理如下:
- 三方架構:host(代理)、client(協定層)、server(工具提供者)。三者責任分離,跨語言、跨框架、跨部署形式都能組合。
- JSON-RPC 2.0 訊息:request、notification、response 三種型別,
id配對、錯誤碼標準化;stdio 上以換行作為訊息邊界。 - 三種能力:tools(可呼叫函式)、resources(可存取資料)、prompts(提示範本);今天只看 tools,明天會把另外兩種補上。
- 傳輸層:stdio(本機)、SSE、streamable HTTP(遠端);選哪一個取決於部署與安全需求。
- 握手順序:
initialize→notifications/initialized→ 開始工作。任何省略握手的呼叫都是錯誤實作。 - 訊息層的可觀測性:stderr 印訊息、除錯訊息不擠進 stdout、id 唯一、版本字串對齊。
這份最小骨架不是拿來取代任何官方 SDK,而是拿來驗證你「對訊息格式的理解是否正確」。如果未來你用 FastMCP 或官方 mcp 套件時遇到問題,先回頭跑一次這個最小骨架,確認訊息層本身沒問題,再把焦點轉到 SDK 設定上。今天鋪好的地基,會在接下來三篇裡逐步長成完整的 MCP server 與 client。
結語
今天我們用最少數的程式碼,把 MCP 的訊息骨架親手走了一遍。這個過程最重要的收穫不是「我們寫了一個能跑的東西」,而是「我們知道那些訊息是怎麼流動的」。有了這個底層觀念,明天開始使用 FastMCP 時,你會很清楚裝飾器底下發生什麼事、除錯時該往哪裡看、效能瓶頸可能出在哪一段。
明天,我們會進入「AG Day 26 第一個 MCP server:用 FastMCP」,把今天手刻的訊息交換骨架換成 FastMCP 的裝飾器風格。我們會建立第一個正式的 MCP server、宣告工具、用 MCP inspector 驗證握手與工具列表,把今天的最小骨架正式升級成可被任何遵循 MCP 的客戶端呼叫的伺服器。程式碼會比今天短,但能做的事情會多很多。
延伸資源
- Model Context Protocol 官方網站:
https://modelcontextprotocol.io/。規格首頁,方法清單與傳輸選項都以此為準。 - JSON-RPC 2.0 規格:
https://www.jsonrpc.org/specification。MCP 採用的訊息格式,id、error、params的語意在此定義。 - Python
subprocess官方說明:https://docs.python.org/3/library/subprocess.html。Popen、stdin/stdout/stderr的設定與注意事項。 - Python
json官方說明:https://docs.python.org/3/library/json.html。json.dumps()的ensure_ascii與效能特性。 - MCP 規格 GitHub 倉庫:
https://github.com/modelcontextprotocol。規格原始碼、修訂歷史與實作參考;2025-06-18 版規格的討論也在此追蹤。
留言
張貼留言