
Day 2 說明了 MCP 的架構與核心元素,但那些都還停留在規範層面。今天要回答的是一個更實際的問題:這些東西,用 Python 怎麼寫?
好消息是不必從 JSON-RPC 開始。FastMCP 把協定細節完全封裝起來 —— 寫一個帶型別提示的 Python 函式、加上一個裝飾器,它就會自動生成模型看得懂的工具宣告。您不需要手寫任何 JSON Schema。
打造 MCP Server 目前有不少選擇,本系列用 FastMCP,因為它在「把一個 Python 函式變成 MCP 工具」這件事上最省事,期待在這篇文章中,能夠讓大家可以學習到如何透過 Python 架設一個 MCP Server。
以下是安裝方法:
python -m venv .venv-server
.venv-server/bin/pip install "fastmcp>=4,<5"
先看一個完整、能跑的最小範例:
# server.py
from fastmcp import FastMCP
mcp = FastMCP("demo")
@mcp.tool
def add(a: int, b: int) -> int:
"""把兩個數字相加。"""
return a + b
if __name__ == "__main__":
mcp.run()
這樣就完成了一個 MCP Server,並且可以啟動它:
python server.py
上面那段程式碼看起來平凡無奇,但 FastMCP 在背後完成了三件事。
第一,把型別提示轉成 JSON Schema。 a: int, b: int 被自動轉換成:
{
"type": "object",
"properties": {
"a": {"type": "integer"},
"b": {"type": "integer"}
},
"required": ["a", "b"]
}
這正是模型判斷「該傳什麼參數」的依據。如果沒有 FastMCP,這段 Schema 得手寫,而且每次改動函式簽名都要同步維護。
第二,把 Docstring 轉成工具描述。 那句「把兩個數字相加。」會成為模型看到的工具說明。
第三,處理 JSON-RPC 的訊息收發與生命週期。 包含 initialize 握手、tools/list、tools/call 這些協定層的往返,完全不需要自己實作。
這裡有一件事值得停下來想一想:
Docstring 不再只是給人看的註解,它是會實際進入模型上下文的 Prompt。
換句話說,寫 MCP 工具時,函式的說明文字同時服務兩個讀者 —— 維護程式碼的工程師,以及決定要不要呼叫它的模型。
這個觀念會貫穿整個系列。評測階段進行 Prompt 優化時,第一個要改的就是 Docstring;而它的品質,會直接反映在工具呼叫的準確率上。
一個實際的對照:
# 不好的寫法:模型不知道什麼時候該用、參數限制是什麼
@mcp.tool
def update_leave_status(leave_id: str, status: str) -> dict:
"""更新假單。"""
# 好的寫法:說明適用場景、參數限制與副作用
@mcp.tool
def update_leave_status(leave_id: str, status: str, note: str | None = None) -> dict:
"""更新假單狀態。
用於將假單推進到下一個處理階段。狀態必須依
draft → submitted → approved → taken 逐級推進,不可跳級。
Args:
leave_id: 假單 ID,須先透過 search_leaves 取得,不可自行推測
status: 目標狀態
note: 選填的變更說明,會記錄於假單歷程
"""
Day 2 介紹過 Server 端提供三種能力。以下逐一說明實作方式。
@mcp.tool 是最常用的裝飾器,完整簽名如下:
def tool(
self,
name_or_fn=None, # 直接 @mcp.tool 時就是被裝飾的函式
*,
name: str | None = None, # 預設用函式名稱
title: str | None = None, # 給人看的顯示名稱
description: str | None = None, # 預設用 docstring
tags: set[str] | None = None, # 分類用,可搭配 tool_filter
annotations: ToolAnnotations | dict | None = None, # 中繼資料,見第 V 節
output_schema: dict | None = None,
meta: dict[str, Any] | None = None,
timeout: float | None = None,
...
) -> Callable
多數情況下不需要傳任何參數,讓 FastMCP 從函式簽名與 Docstring 推導即可:
@mcp.tool
def search_leaves(
status: str | None = None,
employee_id: str | None = None,
) -> list[dict]:
"""搜尋假單。
Args:
status: 假單狀態,可選 open / in_progress / resolved / closed
employee_id: 承辦人 ID,格式為 u_ 開頭加四位數字
"""
...
選填參數用 X | None = None 表示,FastMCP 會據此把它從 required 陣列中排除。
若要使用更複雜的結構,可以搭配 Pydantic model:
from pydantic import BaseModel, Field
class TicketFilter(BaseModel):
status: str | None = Field(None, description="假單狀態")
limit: int = Field(20, ge=1, le=100, description="最多回傳幾筆")
@mcp.tool
def search_leaves_advanced(filter: TicketFilter) -> list[dict]:
"""使用進階條件搜尋假單。"""
...
Pydantic 的 Field 描述與驗證規則(如 ge、le)都會被轉進 JSON Schema,模型因此知道數值範圍的限制。
Resources 用於「把已存在的資料交出去」,語意上接近 HTTP 的 GET。它需要一個 URI:
@mcp.resource("leave://{leave_id}")
def get_leave_resource(leave_id: str) -> str:
"""依 ID 取得假單的完整內容。"""
leave = LEAVES.get(leave_id)
if leave is None:
raise ValueError(f"假單不存在:{leave_id}")
return json.dumps(leave, ensure_ascii=False, indent=2)
URI 中的 {leave_id} 是模板參數,會自動對應到函式的同名參數。
完整簽名還支援 mime_type 等選項:
@mcp.resource(
"config://runbook",
name="差勤規章",
mime_type="text/markdown",
)
def runbook() -> str:
"""回傳團隊的請假規章。"""
return open("docs/runbook.md", encoding="utf-8").read()
Prompts 是給使用者觸發的模板,而非給模型自動呼叫:
@mcp.prompt
def daily_check(scope: str = "all") -> str:
"""產生每日簽核待辦的指令模板。"""
return f"""請整理今日待簽核的假單,範圍:{scope}
1. 列出所有 open 狀態的假單
2. 檢查各員工的資源使用率
3. 彙整需要優先處理的項目
"""
在 Claude Desktop、Google Antigravity 這類 Host 中,Prompts 通常會顯示成可點選的指令選單或斜線指令。
實務上最常混淆的是 Resources 與 Tools。一個簡單的判斷法:
這個操作會改變世界的狀態嗎? 會的話是 Tool;只是把已存在的資料交出去,那是 Resource。
有一個常見的偷懶做法是把所有東西都做成 Tool,因為模型對 Tool 的支援最一致。這樣能運作,但會丟失語意,而且會讓工具清單膨脹、增加模型選錯的機率。
到目前為止的工具都是「純函式」—— 收參數、回傳結果。但實務上工具經常需要更多能力:回報進度、寫日誌、讀取其他資源,或是向使用者索取確認。
這些能力透過 Context 提供。用法是在函式簽名加上一個型別為 Context 的參數:
from fastmcp import Context, FastMCP
@mcp.tool
async def my_tool(x: int, ctx: Context) -> str:
"""FastMCP 會自動注入 Context,不需要呼叫端傳入。"""
await ctx.info(f"開始處理 x={x}")
return "done"
參數名稱不限定(叫 ctx、context 都可以),FastMCP 是依型別註解來判斷是否注入。而且這個參數不會出現在給模型的 Schema 裡 —— 模型不知道它的存在。

長時間執行的工具可以回報進度,Host 端能據此顯示進度條:
@mcp.tool
async def bulk_update(leave_ids: list[str], ctx: Context) -> dict:
"""批次更新多張假單。"""
total = len(leave_ids)
for i, tid in enumerate(leave_ids, 1):
_do_update(tid)
await ctx.report_progress(i, total, f"已處理 {i}/{total}")
return {"updated": total}
這是 Day 2 介紹過的機制,也是本系列處理「破壞性操作」的核心。
ctx.elicit() 的簽名是:
async def elicit(
self,
message: str,
response_type: type[T],
) -> AcceptedElicitation[T] | DeclinedElicitation | CancelledElicitation: ...
response_type 通常是 Pydantic model,且依規範只能使用原始型別(string / number / boolean / enum),不支援巢狀結構。
實際用法:
class PurgeConfirmation(BaseModel):
"""清理作業的確認表單。"""
confirmed: bool = Field(description="確認執行清理,此操作無法復原")
reason: str = Field(description="執行原因,將記錄於稽核日誌", min_length=5)
@mcp.tool
async def cancel_approved_leave(table: str, leave_id: int, ctx: Context) -> dict:
"""清除指定資料表中超過保留期限的記錄。
這是破壞性操作,執行前會要求使用者確認。
若使用者拒絕,本操作即終止,不得改用其他工具達成相同目的。
"""
affected = _count_affected(table, leave_id)
result = await ctx.elicit(
message=(
f"即將從 {table} 刪除 {affected} 筆超過 {leave_id} 天的記錄。"
f"此操作無法復原,請確認。"
),
response_type=PurgeConfirmation,
)
if result.action == "accept":
if not result.data.confirmed:
return {"status": "aborted", "reason": "使用者未勾選確認"}
deleted = _do_purge(table, leave_id)
return {"status": "completed", "deleted": deleted, "reason": result.data.reason}
if result.action == "decline":
return {"status": "declined", "message": "使用者拒絕執行,操作已終止"}
# action == "cancel"
return {"status": "cancelled", "message": "使用者未做選擇,請重新詢問使用者意向"}
回傳型別是三個 Pydantic model 的 union:
AcceptedElicitation[T] # action="accept", data: T
DeclinedElicitation # action="decline"
CancelledElicitation # action="cancel"
只有 accept 時 result.data 才有值。 存取 result.data 之前一定要先檢查 result.action,否則 decline 與 cancel 那兩條路會直接炸掉。
規範在這裡有一條明確的禁令:表單模式不可以用來索取密碼、金鑰或付款資訊。理由不難理解 —— 使用者填進表單的內容會經過 MCP Client,很可能就這樣落進對話歷史與日誌裡了。
實務上這不太會遇到,因為需要這類憑證的情境,本來就該走正規的授權流程,而不是在對話框裡跟使用者要。真正要記得的是:Elicitation 是拿來確認意圖、補齊參數用的,不是拿來收憑證的。
@mcp.tool 的 annotations 參數可以附加描述工具「性質」的中繼資料,其中最實用的是 readOnlyHint:
@mcp.tool(annotations={"readOnlyHint": True})
def search_leaves(status: str | None = None) -> list[dict]:
"""搜尋假單(純查詢,不會改變任何狀態)。"""
...
這個標註告訴 Client 端:這個工具不會產生副作用。Host 可以據此決定是否需要使用者確認 —— 唯讀工具直接執行,寫入工具則跳出確認對話框。
除了上述的通用價值之外,readOnlyHint 在這個系列裡還有一個特定用途:
評測階段用的工具會讀取這個標註,用來計算唯讀合規性(Read-only Compliance)—— 也就是「Agent 有沒有在唯讀任務中動用寫入工具」。
若是漏標,該項指標會安靜地變成空值。過程中不會出現任何錯誤訊息,直到後續診斷時才會發現手上少了一把重要的尺。
原則很簡單:純查詢的工具標 True,會改變狀態的工具不標。
if __name__ == "__main__":
mcp.run(transport="http", host="127.0.0.1", port=8090)
transport 有三個選項,預設是 "stdio":

網路設定寫在 run(),不在建構子。 這個分工是刻意的:FastMCP("leave-copilot") 宣告的是「這台伺服器是什麼」,而 host、port、路徑屬於「這次要怎麼跑」—— 同一個 Server 在本機跑跟部署到 Cloud Run,前者不變、後者才變。
mcp.run(
transport="http",
host="127.0.0.1", # 部署時改成 0.0.0.0
port=8090,
path="/mcp", # 預設就是 /mcp
)
完整位址即為 http://127.0.0.1:8090/mcp。
對外提供服務時要限制來源。
run()另外接受host_origin_protection、allowed_hosts、allowed_origins三個參數,用來擋 DNS rebinding 攻擊。本機開發不必設,但是,明天文章寫到有關部署到 Cloud Run 時,其實是會用到的,也請注意。
FastMCP 可以叫起 MCP Inspector,在接上任何 Agent 之前先用圖形介面驗證 Server:
.venv-server/bin/fastmcp dev inspector server.py
第一次執行會透過 npx 下載 Inspector,稍等一下。跑起來之後終端機會印出網址與一組驗證用的金鑰:
MCP Inspector Web is up and running at:
http://127.0.0.1:6274?MCP_INSPECTOR_API_TOKEN=<token>
瀏覽器會自動開啟,讓您逐一檢視與呼叫工具、瀏覽 Resources、觸發 Prompts。

這一頁正好把前面講的三件事一次攤開來看:
employee_id 底下的「員工編號,格式為 E 開頭加四位數字(非姓名)」—— 那是 Docstring 裡 Args: 區塊的內容annotations={"readOnlyHint": True}
您在程式碼裡寫的每一個字,模型都看得到。 Inspector 讓這件事變得具體。
Resources 與 Prompts 也各有一頁:

Resource 的 URI 模板 leave://{leave_id} 被解析成一個需要填 leave_id 的表單 —— 這正是模板參數自動對應到函式同名參數的效果。
其中最重要的是驗證 Elicitation 流程。 Inspector 會在 Server 要求確認時顯示表單,可以分別測試 accept、decline、cancel 三種回應。
建議在這個步驟多花十分鐘,把三條路徑都手動走過一次,確認 Server 回傳的 status 各自正確。理由在於除錯範圍的收斂:之後要把 Elicitation 接上 Google ADK,屆時若出現問題,可能的原因有三個 —— Server 端邏輯錯誤、Google ADK 的 callback 未正確掛載、或是協定層的相容性問題。今天先把 Server 端排除,後續的除錯範圍就縮小了三分之一。
有了以上的基礎,接下來說明本系列實際使用的工具集。

mcp_server/
├── server.py # FastMCP 實例與九個工具
├── store.py # 模擬的資料層(實務上會換成真實 DB)
└── fixtures.py # 初始測試資料與重置函式

這裡要說明一件事:這組工具是刻意設計得不好用的。
一般設計 API 時我們追求直觀易用,但本系列為了要證明 Fine-Tuning Model 能夠幫助在使用工具上的能力提升,如果工具太過直觀,基座模型本來就能正確呼叫,準確率一開始就接近滿分 —— 那麼微調自然展示不出提升,因此才有這樣的設計。

這四個難點有一個共同特徵:它們全部都是 JSON Schema 表達不了的規則。
Schema 可以限制 status 必須是四個字串之一(用 enum),但管不了「從哪裡來」;可以要求 start_after 是字串,但無法強制它必須先呼叫 search_leaves 取得 ID。
這正是 Day 2 埋下的伏筆 —— 而這條界線,會在評測階段用數據被清楚地畫出來。

以狀態機為例,實作的關鍵不只是「拒絕非法轉移」,還在於錯誤訊息怎麼寫:
STATUS_ORDER = ["open", "in_progress", "resolved", "closed"]
@mcp.tool
def update_leave_status(leave_id: str, status: str, note: str | None = None) -> dict:
"""更新假單狀態。
狀態必須依 draft → submitted → approved → taken 逐級推進,
不可跳級,也不可回退。
Args:
leave_id: 假單 ID,須先透過 search_leaves 取得
status: 目標狀態
note: 選填的變更說明
"""
leave = LEAVES.get(leave_id)
if leave is None:
raise ValueError(f"假單不存在:{leave_id}")
current_idx = STATUS_ORDER.index(leave["status"])
try:
target_idx = STATUS_ORDER.index(status)
except ValueError:
raise ValueError(f"未知狀態:{status}")
if target_idx != current_idx + 1:
raise ValueError(
f"狀態不可從 {leave['status']} 跳至 {status},"
f"下一個合法狀態為 {STATUS_ORDER[current_idx + 1]}"
)
leave["status"] = status
return leave
請注意那段錯誤訊息 —— 它不只說「錯了」,還說明了「下一個合法狀態是什麼」。
錯誤訊息會原封不動回到模型手上,因此寫得含糊它只能亂猜,寫得明確它就有機會一次修正:

這是用工具設計補償模型能力的典型手法,成本只是多寫幾個字。
最後一個實作細節:update_leave_status 會真的改變狀態。跑完一輪測試之後,假單已經從 open 變成 submitted,第二輪的前置條件就不同了。評測階段的基準線與系列後期的對比都要求「每個模型面對相同的初始狀態」,所以重置機制現在就要準備:
# mcp_server/fixtures.py
import copy
INITIAL_LEAVES = {
"LV-7f3a91": {
"id": "LV-7f3a91",
"title": "資料庫連線池耗盡",
"status": "open",
"employee_id": "u_2841",
"created_at": "2026-08-14T09:23:00Z",
},
# ⋯ 其餘假單
}
def reset(store: dict) -> None:
store.clear()
store.update(copy.deepcopy(INITIAL_LEAVES))
再開一個管理端點方便測試腳本呼叫:
@mcp.tool
def _reset_fixtures() -> dict:
"""重置測試資料。僅供評測腳本使用,正式環境應移除。"""
reset(LEAVES)
return {"status": "reset", "leaves": len(LEAVES)}
這個工具記得在之後接上 Agent 時,用
tool_filter排除掉,否則模型會看到它 —— 而一個叫「重置」的工具對 LLM 有莫名的吸引力。
FastMCP 把 MCP 的協定細節封裝得相當完整,讓「把既有系統包裝成 AI 可用的工具」這件事,從協定實作降級成了一般的 Python 開發工作。
總結來說,今天提到三個重點:
ctx: Context,就能取得日誌、進度回報、資源讀取與 Elicitation 的能力。而且這個參數不會出現在給模型的 Schema 裡。至於 Leave Copilot 那四個刻意植入的難點,它們的價值會在後續逐步顯現:之後讓 Agent 實際去踩、評測階段量出踩得多嚴重、訓練資料階段轉成訓練資料、系列後期驗證改善幅度。
明天,我將會提到,如何建置成 Docker Service,並且部署到 Cloud Run,接著就要思考 MCP 的安全性議題,再請大家回來閱讀後續章節囉!

@mcp.tool / @mcp.resource / @mcp.prompt 用法、Context 方法、run() 的傳輸與網路參數ctx.elicit(message, response_type=...) 與 accept/decline/cancel 三態server/fastmcp.py——官方 SDK 2.x 只留下一段遷移提示,該路徑已改名為 mcp.server.mcpserver.MCPServer
查證日期:2026-09-01。本篇程式碼以 fastmcp 4.0.0 實際跑過。
大家好,我是 Simon 劉育維,是一位 AI 領域解決方案專家,目前也擔任 Google Cloud AI 領域開發者專家 (GDE),期待能夠幫助企業導入人工智慧相關技術解決問題。如果這篇文章對您有幫助,歡迎在我的 Linkedin 上留言提供意見,並與我一起討論有關人工智慧的主題,期待能夠對大家有所幫助!
我的個人部落格資訊:https://medium.com/@simon3458