跳到主要內容

AG Day 25 MCP 概念:Model Context Protocol 架構

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 版規格的討論也在此追蹤。

留言

這個網誌中的熱門文章

Day 2 變數與資料型別

Day 2 變數與資料型別 引言 寫程式的過程中,變數與資料型別是處理資料的基礎。變數是存放資料的容器,資料型別則決定這筆資料有哪些特性、可以進行哪些操作。學會定義變數、認識各種資料型別,是學好 Python 的關鍵一步。 這篇文章會帶你了解 Python 中變數的觀念、如何定義變數,以及常見的資料型別,包括整數、浮點數、字串、布林值,還有串列、元組、字典與集合等容器型別。我們也會介紹變數的命名規則與撰寫風格建議,以及如何用 type() 檢查資料型別。 什麼是變數?如何在 Python 中定義變數 變數是在程式執行時用來存放資料的名稱。透過定義變數,我們可以給一筆資料一個名字,並在程式的其他地方用這個名字取用該筆資料。在 Python 中,變數不需要事先宣告型別,因為 Python 是動態型別語言,變數的型別由指定給它的值決定。 定義變數的基本語法 在 Python 中定義變數非常簡單,只要用賦值符號 = 把值指定給變數即可。例如: x = 5 # 定義變數 x,並把整數 5 賦值給它 name = "Alice" # 定義變數 name,並把字串 "Alice" 賦值給它 在這裡,x 是一個變數,被賦予整數 5;name 是另一個變數,被賦予字串 "Alice"。 變數的更新與覆寫 變數的值可以修改,也就是說,我們可以在程式的不同地方給同一個變數新的值。例如: x = 10 # x 最初被賦予 10 x = 15 # x 的值現在被更新為 15 這樣就能依照需求,在程式執行過程中靈活調整變數的值。 Python 的動態型別系統 Python 和某些靜態型別語言不同,定義變數時不需要宣告型別。賦值時,Python 會根據值自動判斷變數的型別。例如: x = 5 # x 是整數 x = 3.14 # x 變成浮點數 x = "Hi" # x 變成字串 同一個變數在程式執行過程中可以存放不同型別的值,這是 Python 的彈性之一。 常見資料型別 在 Python 中,資料型別決定我們可以對變數進行哪些操作...

Day 1 Python 簡介與環境設定

Day 1 Python 簡介與環境設定 引言 在現在的科技環境裡,程式設計已經是一項重要技能。無論你是對資料科學有興趣、想成為開發者,或是想踏入人工智慧(AI)領域,學會寫程式都能明顯提升你的競爭力。在眾多程式語言中,Python 因為語法簡單、功能強大、應用範圍廣泛,成為許多人進入程式世界的第一選擇。這篇文章會帶你認識 Python 的背景與優勢,並一步步教你在不同系統上安裝與設定 Python 開發環境,最後寫出第一支 Python 程式。 為什麼選擇 Python? Python 是一種高階程式語言,由 Guido van Rossum 在 1991 年發布。Python 的設計哲學強調程式碼的可讀性,並用縮排來定義程式區塊,這點和許多使用大括號的語言不同。簡潔的語法讓它成為初學者的理想選擇;就算是經驗豐富的開發者,也能用它完成複雜的專案。 Python 的優勢如下: 簡單易學 :Python 的語法清楚、結構簡潔,初學者很快就能上手。和其他語言相比,學習曲線相對平緩,不需要先弄懂一堆複雜觀念,就能開始寫程式。 應用範圍廣泛 :從資料科學、網頁開發、人工智慧、機器學習、自動化測試到網路爬蟲,Python 都有大量開源函式庫與工具支援,而且在這些領域都扮演關鍵角色。 豐富的函式庫與框架 :Python 的函式庫生態系非常龐大。做資料分析有 NumPy、Pandas;開發網站有 Django、Flask;做深度學習有 TensorFlow、PyTorch。各種需求幾乎都能找到對應的套件,讓開發更有效率。 跨平台支援 :Python 支援 Windows、macOS、Linux 等作業系統,程式通常不需要太多修改就能跨平台執行,讓開發與部署更有彈性。 活躍的社群 :Python 擁有龐大的開發者社群。學習或開發上遇到問題,幾乎都能在社群與論壇(例如 Stack Overflow)找到答案,對初學者來說是很強的後盾,也能減少卡關時的挫折感。 Python 的應用領域 Python 的流行與強大功能,讓許多領域都開始大量使用它。以下是幾個常見的應用方向: 資料科學 :隨著大數據與人工智慧興起,資料科學大量使用 Python。NumPy、Pandas 與 Matplotlib 等工具能處理和分析龐...

Python 從入門到 PyTorch 深度學習:開啟 AI 世界的大門

Python 從入門到 PyTorch 深度學習:開啟 AI 世界的大門 隨著人工智慧(AI)與深度學習(Deep Learning)快速發展,越來越多人對這些技術產生興趣。不論你是想踏入 AI 領域的初學者,還是已經有程式基礎的開發者,學好 Python 與深度學習框架(例如 PyTorch),都能為你打開更多可能。 為什麼選擇 Python? Python 已經是資料科學與人工智慧領域的首選語言。它的語法簡潔、容易上手,而且擁有龐大的生態系與大量開源函式庫。無論是資料處理、資料視覺化,還是建立機器學習與深度學習模型,Python 都能勝任。對想進入 AI 或資料科學領域的人來說,它幾乎是必備工具。 PyTorch 是什麼? PyTorch 是由 Meta(原 Facebook)AI 研究團隊開發的開源深度學習框架,以易用、靈活和動態計算圖著稱,是許多 AI 研究人員與開發者的首選。相較於其他框架,PyTorch 的寫法更貼近原生 Python,對初學者相對友善。無論是簡單的實驗,還是複雜的深度學習模型,PyTorch 都能提供強大的支援。 這個系列能帶給你什麼? 這個系列會從 Python 的基礎開始,帶你一步一步學習,最後能自己用 PyTorch 建立深度學習模型。即使你完全沒有寫過程式,也能跟著文章的節奏累積技能,理解 AI 與深度學習的核心觀念。 本系列涵蓋的主題 Python 基礎:從變數、條件判斷到函式與模組。 資料處理工具:用 NumPy 與 Pandas 有效率地操作資料。 資料視覺化:用 Matplotlib 與 Seaborn 把資料畫成圖表。 深度學習的數學基礎:線性代數、微積分與機率。 PyTorch 入門:理解張量、模型建構與 GPU 加速。 基礎深度學習模型:CNN 與 RNN 的實作應用。 深度學習專案實戰:從資料前處理到模型部署的端到端流程。 誰適合這個系列? 程式初學者 :如果你對 AI 充滿好奇,卻還沒寫過程式,系列的第一部分會帶你快速上手 Python,並幫助你理解深度學習的基本觀念。 資料科學愛好者 :如果你已經熟悉一些資料處理方法,進階部分會教你如何用 PyTorch 建構深度學習模型。 開發者與研究人員 :想更深入了...