AG Day 26 第一個 MCP server:用 FastMCP
執行需求:CPU 可跑。昨天的 AG Day 25 MCP 概念:Model Context Protocol 架構(原文連結)我們用 Python 標準函式庫寫了一個 MCP-like 的最小骨架,把 JSON-RPC 訊息怎麼走、握手怎麼做、工具怎麼呼叫全部攤開來看。今天要把它升級成「正式的 MCP server」:用 FastMCP(官方推薦的高階 Python SDK)把昨天的樣板程式碼濃縮成幾個裝飾器,同時自動處理握手、能力宣告、輸入驗證這些瑣事。我們會把昨天示範的 echo 與 add 兩個工具改寫成 FastMCP 風格,接著用社群提供的 mcp 命令列工具啟動 server、把對話記錄印出來驗證。本篇完全在本機 CPU 上執行,不需要任何 API 金鑰,也不依賴 uv 之外的工具鏈。
引言
昨天的骨架能跑,但它有兩個明顯的限制。第一,它只接受最簡單的工具描述(用我們自己寫的 TOOLS 串列),沒有標準的 schema 驗證——若客戶端送來不符合 schema 的 arguments,伺服器端是到 handle_tools_call() 才在 arguments["a"] 處拋出 KeyError,沒有提早攔截。第二,每加一個工具都要手動擴充 METHODS 字典、寫 handle_* 函式、並記得把它放進 TOOLS——這些步驟極度重複,工具數量一多就會變成錯誤的溫床。
FastMCP(截至 2026 年 7 月已是 2.x 世代)正是為了解決這兩個問題而設計。它提供「裝飾器風格」的 API:寫一個 Python 函式,加上 @mcp.tool(),伺服器就自動處理「宣告、輸入驗證、呼叫、錯誤回傳」這一整條鏈。對比昨天的 handle_tools_call 裡手寫的 if name == "echo": ... 那一大段,FastMCP 版本只需要寫正常的函式即可。我們今天的目標,就是把昨天的 server.py「翻譯」成 FastMCP 版本,看看框架到底幫我們做了什麼、又藏了什麼。
先講一個紀律:用框架之前,先用手刻跑過一次。MCP 訊息的「request/notification/response」三型別、「id 配對」、「握手必須先做」這些概念,都是昨天親手走過才真正進到腦袋。今天用 FastMCP 時,框架會自動處理它們,但我們仍然要在腦中保持「這個訊息是怎麼流動的」這個圖像,否則未來除錯時會找不到方向。今天的程式碼會比昨天短很多,但能做的事情會多很多——這正是框架的價值所在。
原理/觀念
FastMCP 是什麼
FastMCP 是官方 mcp Python 套件提供的高階伺服端介面。它的設計理念是「裝飾器優先、慣例取代設定」:開發者只需要宣告工具、資源、提示範本的 Python 函式,框架會自動把它們轉成符合 MCP 規格的 JSON-RPC 訊息、輸入 schema、能力宣告等等。這樣的設計讓 MCP server 的「宣告式」與「命令式」之間的距離大幅縮短。
相對於昨天的「純 stdlib 訊息骨架」,FastMCP 多做了至少六件事:自動建立 initialize 的回應內容;自動把函式簽名轉成 JSON Schema;自動驗證客戶端送來的 arguments 是否符合 schema;自動把函式的回傳值包成 MCP 規格的 content 陣列;自動管理 stdio 的讀寫迴圈與錯誤處理;自動支援非同步函式與同步函式混搭。把這些瑣事從你的程式裡拿掉之後,留下的就是「工具本身的邏輯」與「伺服器的啟動方式」兩件事。
裝飾器風格的 API
FastMCP 的核心 API 由三個裝飾器構成:@mcp.tool() 把一個函式註冊成 tool;@mcp.resource() 把一個函式註冊成 resource;@mcp.prompt() 把一個函式註冊成 prompt template。今天先專注在 tool(),後兩者留到 AG Day 27。
使用方式非常直覺:建立一個 FastMCP 實例,把函式加上裝飾器,最後呼叫 mcp.run() 啟動。型別標註不是裝飾器風格下可有可無的裝飾,而是schema 的來源——FastMCP 會讀取函式的參數與回傳型別,把它們轉成 JSON Schema 與 MCP 規格要求的 inputSchema 欄位。這也意味著當你的函式簽名有錯(例如忘記加 type hint),客戶端看到的 schema 會不完整,工具呼叫就會在驗證階段失敗。
同步與非同步的混搭
FastMCP 同時支援 def 與 async def。同步函式適合 CPU 密集或快速的工具(例如算術、字串處理);非同步函式適合 I/O 密集的工具(例如呼叫 API、抓取網路)。同一個 server 可以同時註冊兩種函式,FastMCP 會在呼叫時根據函式本身的性質決定怎麼執行,不需要額外設定。
這對未來的我們很重要:AG Day 24 寫的 search_web 與 fetch_page 都是 I/O 密集,把它們移植成 MCP server 時會用 async def;而像 add 這種算術工具,保持 def 即可。今天的範例兩個都會示範一次,確保你能一眼看出差異。
stdio 與其他傳輸
FastMCP 預設走 stdio:呼叫 mcp.run() 時,框架會把伺服器的 stdin/stdout 當成 MCP 訊息通道,stderr 留給日誌。這跟我們昨天手刻的設計完全一致,差別只在「不用自己寫讀寫迴圈」。若要走 HTTP 或 SSE,mcp.run() 可以接受 transport="http" 或 transport="sse" 參數,但這需要先安裝額外的依賴(具體名稱以官方說明為準)。本系列為求單純,全部走 stdio;遠端部署情境留到 AG Day 38 談 FastAPI 時再展開。
與手刻版本的差異對照
昨天手刻 server.py 約有 110 行,其中大約 60 行是「框架會自動處理」的程式碼(讀寫迴圈、初始化檢查、方法字典、錯誤回傳)。今天用 FastMCP 重寫會剩下大約 30 行,而且「業務邏輯」的比例大幅提高——讀者一眼就能看出「這個 server 提供 echo 與 add 兩個工具」,不必先理解 JSON-RPC 的訊息流。這就是框架的價值:把抽象層次往使用者想關心的方向移動。
但要強調一件事:框架並沒有取代抽象,只是把抽象藏起來了。當除錯時看到「客戶端收到的 schema 跟你想的不一樣」,你仍然需要回到昨天的訊息層級,看 tools/list 的回傳到底長什麼樣子;當效能出問題時,你也要能從 stdio 訊息的頻率與大小回推瓶頸在哪一段。把今天當作「昨天的延伸」而不是「昨天的取代」,是讀懂這個領域的關鍵心態。
完整實作
接下來在 research-agent/ 底下建立一個新的 MCP server 子套件,把昨天的最小骨架正式升級成 FastMCP 版本。整個過程只需要安裝一個套件、建立兩個檔案、執行一段驗證指令。
第一步,安裝 FastMCP。我們沿用 AG Day 2 建立的 uv 環境管理方式,把 MCP 相關依賴統一收進專案鎖定檔:
cd research-agent
uv add fastmcp
uv lock
段落說明:uv add 會自動更新 pyproject.toml 並寫入 uv.lock,讓團隊其他人能復現一模一樣的版本。FastMCP 的具體版本號會依安裝當下而異,請以 uv.lock 與官方說明為準。指令序列刻意把 uv lock 寫進去,因為鎖定檔的更新是「應該在每次安裝後都做」的事,把它放進一行的命令可以減少遺漏。
第二步,建立 MCP server 的目錄與空檔。今天的 server 主檔叫 basic_server.py,為了讓 FastMCP 範例獨立於研究助理的核心邏輯,我們把它放在 research-agent/src/research_agent/mcp_servers/:
mkdir -p research-agent/src/research_agent/mcp_servers
touch research-agent/src/research_agent/mcp_servers/__init__.py
touch research-agent/src/research_agent/mcp_servers/basic_server.py
段落說明:__init__.py 是 Python 套件的必要檔案,沒它就只是資料夾;把它建出來,未來就可以用 from research_agent.mcp_servers.basic_server import mcp 把這個 server 整合進研究助理。把 MCP server 放在獨立的子套件而不是寫在 tools.py 裡,是因為 MCP server 通常會被當成「獨立的行程」啟動,跟核心程式碼的耦合應該盡量低;這個邊界在 AG Day 28 把客戶端接進來時會變得明顯。
第三步,寫第一個 FastMCP server。把昨天的 echo 與 add 兩個工具移植過來,並刻意保留函式簽名的型別標註:
"""research-agent/src/research_agent/mcp_servers/basic_server.py
第一個 FastMCP server。提供 echo 與 add 兩個工具,作為後續章節的起點。
"""
from fastmcp import FastMCP
mcp = FastMCP(name="research-agent-basic")
@mcp.tool()
def echo(text: str) -> str:
"""回傳收到的字串;用於驗證通訊是否正確。"""
return text
@mcp.tool()
def add(a: int, b: int) -> int:
"""把兩個整數相加並回傳結果。"""
return a + b
if __name__ == "__main__":
mcp.run() # 預設走 stdio 傳輸
段落說明:整個檔案只有 17 行程式碼(扣掉 import 與空行),相對於昨天的 110 行縮短了 80%。把 name="research-agent-basic" 當作伺服器的識別字串傳進去,是 FastMCP 的慣例;這個名稱會在 initialize 回應的 serverInfo.name 欄位出現,客戶端會用它來區分不同的 server。函式本體的 docstring 不是裝飾,而是被 FastMCP 拿來當 description 欄位的內容——這條慣例記下來,未來寫工具時就會自然地把 docstring 寫好。
第四步,把函式簽名驗證一下。我們呼叫內建的 schema 工具(FastMCP 會在執行時建立,但開發期可以用 Python 直接呼叫函式驗證型別正確):
"""research-agent/src/research_agent/mcp_servers/_inspect.py
開發期輔助:列出 FastMCP 已註冊的工具名稱與其輸入 schema。
"""
from basic_server import mcp
def list_tools() -> None:
registry = getattr(mcp, "_tool_manager", None)
if registry is None:
# FastMCP 的內部介面可能依版本而異,這裡僅示範。
print("無法直接存取內部 registry;請改用官方提供的 CLI")
return
for name in registry._tools: # type: ignore[attr-defined]
tool = registry._tools[name] # type: ignore[attr-defined]
print(f"{name} -> {tool.parameters}")
if __name__ == "__main__":
list_tools()
段落說明:這段程式刻意標出「內部介面可能依版本而異」,目的是提醒讀者 FastMCP 的內部屬性並非公開 API,正式開發請用官方提供的 CLI(例如 mcp inspect)或讀取 FastMCP 對外暴露的方法。我們把它放在「_inspect.py」這個下劃線開頭的檔名裡,意思是「這是開發者輔助腳本,不要在正式流程中使用」。
第五步,用官方提供的命令列工具啟動 server 並驗證對話流程。社群提供的 mcp 套件內含 mcp run 與 mcp dev 兩個常用指令,前者把 server 跑起來、後者會啟動一個開發用的 inspector 介面:
cd research-agent
uv run mcp run src/research_agent/mcp_servers/basic_server.py
段落說明:這個指令啟動後,server 會進入 stdio 訊息迴圈,不會有任何主動輸出到終端機(因為訊息走 stdout,日誌走 stderr)。這對第一次使用 FastMCP 的讀者會有點不適應——「跑起來了嗎?跑成功了嗎?」答案是:只要程式沒有拋例外,就是跑成功了。互動測試請改用 mcp dev 或下方的 Python 客戶端腳本。另一個常見踩雷是「我怎麼看不到結果」——那是因為訊息在 stdout,但你看到的「沒輸出」其實是它在等客戶端送訊息進來。
第六步,用一個獨立的客戶端腳本測試剛剛啟動的 server。這裡的客戶端不是昨天的最小骨架,而是用 mcp 官方套件提供的客戶端介面:
"""research-agent/scripts/mcp_min/stdio_client.py
用官方 mcp 客戶端測試 basic_server 的兩個工具。
"""
import asyncio
from mcp import ClientSession, StdioServerParameters
from mcp.client.stdio import stdio_client
async def main() -> None:
params = StdioServerParameters(
command="python",
args=["src/research_agent/mcp_servers/basic_server.py"],
)
async with stdio_client(params) as (read, write):
async with ClientSession(read, write) as session:
await session.initialize()
tools = await session.list_tools()
print("工具清單:", [tool.name for tool in tools.tools])
echo_reply = await session.call_tool("echo", {"text": "你好,MCP"})
print("echo 回應:", echo_reply.content)
add_reply = await session.call_tool("add", {"a": 12, "b": 30})
print("add 回應:", add_reply.content)
if __name__ == "__main__":
asyncio.run(main())
段落說明:stdio_client() 會啟動 server 作為子行程、並把它的 stdin/stdout 包成 async 的讀寫介面;ClientSession 則把這個介面包成符合 MCP 規格的客戶端,包含握手、訊息配對、錯誤回傳。執行後你會看到類似以下的輸出:
uv run python scripts/mcp_min/stdio_client.py
工具清單: ['echo', 'add']
echo 回應: [TextContent(type='text', text='你好,MCP', annotations=None)]
add 回應: [TextContent(type='text', text='42', annotations=None)]
段落說明:這份輸出是官方 mcp 客戶端的真實回應,跟昨天最小骨架裡用我們自己寫的 read_message() 解析出來的格式不同。TextContent 是 MCP 規格定義的內容物件,包含文字、圖片等多種型別,回傳的整數 42 會被 FastMCP 序列化成字串,這是規格要求的「內容一律以字串承載」。如果你需要在程式裡把它當整數用,記得在客戶端自己轉型。
第七步,把工具組織成模組。今天只有兩個工具,但真實情境可能有十幾個。我們示範一個「把工具放在獨立檔案、用 import 組合」的結構:
"""research-agent/src/research_agent/mcp_servers/tools_text.py
把所有文字相關的工具集中在這裡;未來新增工具只要寫函式並掛上裝飾器。
"""
from fastmcp import FastMCP
# 注意:這裡的 mcp 是從 basic_server 引入、共用同一個 registry
from basic_server import mcp
@mcp.tool()
def reverse(text: str) -> str:
"""回傳字串的反轉版本。"""
return text[::-1]
@mcp.tool()
def count_words(text: str) -> int:
"""回傳字串裡被空白切開的詞數。"""
return len(text.split())
段落說明:FastMCP 的關鍵慣例是「所有裝飾器必須作用在同一個 FastMCP 實例上」。這代表當你從 basic_server 引入 mcp、然後在另一個檔案裡加 @mcp.tool(),所有工具都會被收集到同一個 server 裡。副作用:basic_server 被執行時要記得 import 那個檔案(即使只是 import basic_server.tools_text 也行),否則工具不會被註冊。我們的 basic_server.py 範例因為在同一個檔案裡,沒有這個問題,但模組化之後請記得這一點。
為了讓模組化後的 server 啟動時自動載入所有工具,我們在 basic_server.py 的尾端加上一行 import,確保 tools_text 的工具被收集:
"""research-agent/src/research_agent/mcp_servers/basic_server.py
第一個 FastMCP server;同時註冊 echo、add 與文字相關的進階工具。
"""
from fastmcp import FastMCP
mcp = FastMCP(name="research-agent-basic")
@mcp.tool()
def echo(text: str) -> str:
"""回傳收到的字串;用於驗證通訊是否正確。"""
return text
@mcp.tool()
def add(a: int, b: int) -> int:
"""把兩個整數相加並回傳結果。"""
return a + b
# 引入其他模組的工具;匯入的副作用會把所有 @mcp.tool() 註冊進來
from . import tools_text # noqa: E402, F401
if __name__ == "__main__":
mcp.run()
段落說明:noqa 註解是給 lint 工具看的,這裡刻意忽略兩個常見警告:E402(module level import not at top of file)與 F401(imported but unused)。這個寫法在 FastMCP 模組化專案裡非常常見,請把它視為慣例而不是錯誤。若你的 lint 規則較嚴,可以用 __all__ 顯式匯出或把 import 放在 if __name__ == "__main__" 區塊之前,但要付出「無法在 import 時就完成工具註冊」的代價——這通常會讓工具列表的行為變得難以預測,不建議採用。
第八步,示範如何在客戶端觀察 stdio 上的訊息流。把 stderr 重新導向到檔案,可以事後檢視完整的 JSON-RPC 對話:
"""research-agent/scripts/mcp_min/capture_session.py
把 stdio 上的訊息導向檔案,方便事後檢視。
"""
import asyncio
import sys
from mcp import ClientSession, StdioServerParameters
from mcp.client.stdio import stdio_client
async def main() -> None:
params = StdioServerParameters(
command="python",
args=["src/research_agent/mcp_servers/basic_server.py"],
)
async with stdio_client(
params,
errlog=open("logs/mcp_session.log", "ab", buffering=0),
) as (read, write):
async with ClientSession(read, write) as session:
await session.initialize()
await session.call_tool("echo", {"text": "recorded"})
if __name__ == "__main__":
sys.path.insert(0, "src")
asyncio.run(main())
段落說明:errlog 是 stdio_client 的可選參數,負責接收伺服器的 stderr 輸出,但不會干擾 stdout 上的 MCP 訊息。把伺服器的 stderr 寫到檔案是觀察訊息流最簡單的方法;當未來你想換成更完整的觀測(接到 Langfuse 或自寫的 logger),可以從這個檔案改起。這也是 AG Day 35 追蹤平台章節的伏筆。
常見錯誤與踩雷
第一個錯誤是「server 跑起來了,但我看不到任何輸出」。這是 stdio 傳輸的特性:訊息走 stdout,日誌走 stderr,而 uv run 預設不會把子行程的 stderr 接到父行程。如果你想看到啟動訊息,請加上 uv run --verbose 或在啟動指令後接 2> server.log(Windows PowerShell 對應 2> server.log)。
第二個錯誤是函式忘了寫型別標註。FastMCP 會根據型別產生 JSON Schema;若 def add(a, b) 沒有 int 標註,schema 就會變成 {},客戶端送來任何參數都會通過驗證,但在函式本體卻可能拋出 TypeError。請把型別標註視為必要紀律,而不是可選風格。
第三個錯誤是 docstring 寫得太長或太模糊。docstring 會被當成 description 欄位回傳給客戶端與模型;太長會浪費 token,太模糊會讓模型判斷錯工具用途。一句話寫清楚「做什麼、什麼時候該用、回傳什麼」就夠了。今天的範例刻意用最簡的 docstring,就是為了讓大家看到這個對應關係。
第四個錯誤是忘記 await。FastMCP 的客戶端介面大多是 async,呼叫時忘了 await 不會立刻拋例外(coroutine 物件會被默默忽略),結果是「什麼都沒發生」或「輸出是空的」。如果你看到這種現象,請先檢查所有客戶端呼叫是否都有 await。
第五個錯誤是「server 在 IDE 裡看起來正常,但用 CLI 啟動就壞」。這通常是 __main__ 的相對路徑問題——IDE 跑的是專案根目錄,但 CLI 跑的是 research-agent/ 內部,兩者的「當前目錄」不同。請一律用絕對路徑或基於 __file__ 的相對路徑,不要依賴「執行時的當前目錄」。
第六個錯誤是忘記 stdio 的 newline 訊息邊界。FastMCP 內部會處理,但你若在自己的 stdio 客戶端(例如昨天的最小骨架)忘了加 \n,伺服器端的解析會卡住。當你從 FastMCP 換回自己寫的客戶端時,這個紀律要重新守住。
第七個錯誤是把 FastMCP 與舊版 SDK 混用。MCP 在 2025 年經歷了幾次規格演進,不同世代的介面不完全相容;請確認你安裝的 fastmcp 版本與官方說明一致,再依該版本的 API 撰寫。今天的範例以 2.x 世代為主;如果你從教學文章複製了 1.x 範例卻裝到 2.x,會看到 API 變動造成的錯誤。
效能與實務提醒
先談工具啟動成本。FastMCP 的啟動時間主要花在 import 套件上,第一次 mcp.run() 之前若有大量同步函式 import,可能會延遲幾百毫秒到幾秒。實務上的兩個做法:第一,把每個工具放在獨立的子模組,只在需要時 import;第二,把耗時的初始化(例如讀大型設定檔)放到工具函式內部,而不是模組層級。
再談客戶端的並行。當你需要一次呼叫多個工具時,asyncio.gather() 是最直接的做法,但要注意 stdio 是一條序列化的訊息通道,同一個 session 裡的訊息本來就會被序列化處理。若你想真正並行(例如同時對多個獨立 server 發請求),請開多個 session,而不是單一 session 內 gather()。
第三是 schema 的快取。客戶端通常會在握手後 快取 tools/list 的結果,避免每次對話都重新查詢;但工具的 schema 可能會在 server 升級後改變。請在 server 升級時同步讓客戶端失效快取,例如 bump 一個版本號或在 server name 後加日期,這樣客戶端能依版本決定要不要重抓清單。
第四是觀測與錯誤處理。FastMCP 內建的錯誤回應會附上錯誤碼與訊息,但對「為什麼這個工具呼叫失敗」這類問題,你需要更詳細的日誌。建議在工具函式內部加上結構化的紀錄(例如呼叫次數、輸入摘要、回應時間),並把這些紀錄串接到未來的追蹤平台(AG Day 35)。在沒有金鑰的開發環境裡,把日誌寫到 stderr 即可;正式環境則應走結構化輸出。
第五是與既有系統的整合。今天的 server 完全是新的程式,但實務上你會想「把 AG Day 24 的 fetch_page 包成 MCP 工具」。這個移植在本機可以馬上做,但要注意幾件事:fetch_page 內部用了 httpx 與 tenacity,這些依賴會被 server 一起 import 進來,啟動時間會變長;若要在多個 server 共用 httpx,建議把它做成共享模組而不是各 server 自己 import。AG Day 28 會把客戶端與 server 整合起來,屆時你會看到完整的端到端流程。
小結
今天我們把昨天的最小骨架升級成 FastMCP 形式。重點整理如下:
- 框架價值:裝飾器風格讓工具宣告縮短到三行;型別標註自動轉成 schema;握手與錯誤處理由框架負責。
- 基本 API:
FastMCP(name="...")建立實例;@mcp.tool()註冊工具;mcp.run()啟動 server。 - 同步與非同步混搭:CPU 密集工具用
def;I/O 密集工具用async def。同一個 server 可以同時支援兩種。 - stdio 預設:訊息走 stdout,日誌走 stderr;客戶端可以用官方
mcp.client.stdio串接。 - 模組化:所有裝飾器必須作用在同一個
FastMCP實例上;新檔案裡加@mcp.tool()會自動被收集。 - 除錯紀律:型別標註必要、docstring 精簡、stderr 留給日誌、版本變動要同步讓客戶端快取失效。
今天的程式碼比昨天短了 80%,但功能反而更多——這就是抽象的力量。請記得一件事:框架是抽象,不是魔法。當你看到奇怪行為時,回到昨天的訊息層級,把訊息流印出來看一遍,幾乎所有問題都會有解。
結語
今天我們用 FastMCP 把昨天的最小骨架正式升級成可被任何遵循 MCP 規格的客戶端呼叫的 server。雖然只有兩個工具,但底層的握手、訊息、驗證、錯誤回傳都已就位。接下來兩天,我們會把 MCP 伺服器的另外兩種能力補齊:resources 與 prompts,讓 server 從「員工」變成「員工加圖書館加顧問公司」。之後再把客戶端接進研究助理,讓 MCP 生態真正進入 research-agent 的工作流。
明天,我們會進入「AG Day 27 MCP 進階:resources、prompts 與多工具 server」,把 resources 與 prompts 的宣告、呼叫、權限設計一個一個走過。我們會示範如何用 @mcp.resource() 暴露資料快照、如何用 @mcp.prompt() 提供對話策略,並把昨天與今天的範例組合成一個含三種能力的完整 server。
延伸資源
- FastMCP 官方說明:
https://github.com/modelcontextprotocol/python-sdk。安裝指令、裝飾器用法、版本相容性皆以此為準。 - Model Context Protocol 官方網站:
https://modelcontextprotocol.io/。伺服器與客戶端的整體架構、方法清單與版本演進。 - MCP Inspector 工具:
https://github.com/modelcontextprotocol/inspector。開發期圖形化除錯介面,可以用它對 server 發送測試訊息。 - Python
asyncio官方說明:https://docs.python.org/3/library/asyncio.html。async 客戶端介面的背景知識。 - JSON Schema 規格:
https://json-schema.org/。FastMCP 自動生成的inputSchema與 JSON Schema 的對應關係。
留言
張貼留言