iT邦幫忙

2026 iThome 鐵人賽

DAY 3
1

Day 3 今日地圖:今天在整條閉環的位置、承接與產出

I. 前言:從協定規範到能跑的程式碼

Day 2 說明了 MCP 的架構與核心元素,但那些都還停留在規範層面。今天要回答的是一個更實際的問題:這些東西,用 Python 怎麼寫?

好消息是不必從 JSON-RPC 開始。FastMCP 把協定細節完全封裝起來 —— 寫一個帶型別提示的 Python 函式、加上一個裝飾器,它就會自動生成模型看得懂的工具宣告。您不需要手寫任何 JSON Schema。

打造 MCP Server 目前有不少選擇,本系列用 FastMCP,因為它在「把一個 Python 函式變成 MCP 工具」這件事上最省事,期待在這篇文章中,能夠讓大家可以學習到如何透過 Python 架設一個 MCP Server。

II. 你的第一個 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 幫你做了什麼

上面那段程式碼看起來平凡無奇,但 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/listtools/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: 選填的變更說明,會記錄於假單歷程
    """

III. 實作三大元素

Day 2 介紹過 Server 端提供三種能力。以下逐一說明實作方式。

1. Tools:模型執行的函式

@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 描述與驗證規則(如 gele)都會被轉進 JSON Schema,模型因此知道數值範圍的限制。

2. Resources:供讀取的上下文

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()

3. Prompts:預先寫好的工作流模板

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 的支援最一致。這樣能運作,但會丟失語意,而且會讓工具清單膨脹、增加模型選錯的機率。

IV. Context:讓工具能與外界互動

到目前為止的工具都是「純函式」—— 收參數、回傳結果。但實務上工具經常需要更多能力:回報進度、寫日誌、讀取其他資源,或是向使用者索取確認

這些能力透過 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"

參數名稱不限定(叫 ctxcontext 都可以),FastMCP 是依型別註解來判斷是否注入。而且這個參數不會出現在給模型的 Schema 裡 —— 模型不知道它的存在。

Context 提供的能力

表格:方法、用途

進度回報

長時間執行的工具可以回報進度,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}

Elicitation:向使用者索取確認

這是 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 是拿來確認意圖、補齊參數用的,不是拿來收憑證的。

V. Tool Annotations:給工具加上中繼資料

@mcp.toolannotations 參數可以附加描述工具「性質」的中繼資料,其中最實用的是 readOnlyHint

@mcp.tool(annotations={"readOnlyHint": True})
def search_leaves(status: str | None = None) -> list[dict]:
    """搜尋假單(純查詢,不會改變任何狀態)。"""
    ...

這個標註告訴 Client 端:這個工具不會產生副作用。Host 可以據此決定是否需要使用者確認 —— 唯讀工具直接執行,寫入工具則跳出確認對話框。

為什麼本系列特別重視它

除了上述的通用價值之外,readOnlyHint 在這個系列裡還有一個特定用途:

評測階段用的工具會讀取這個標註,用來計算唯讀合規性(Read-only Compliance)—— 也就是「Agent 有沒有在唯讀任務中動用寫入工具」。

若是漏標,該項指標會安靜地變成空值。過程中不會出現任何錯誤訊息,直到後續診斷時才會發現手上少了一把重要的尺。

原則很簡單:純查詢的工具標 True,會改變狀態的工具不標。

VI. 啟動:三種 Transport 的選擇

if __name__ == "__main__":
    mcp.run(transport="http", host="127.0.0.1", port=8090)

transport 有三個選項,預設是 "stdio"

表格:Transport、運作方式、適用場景

網路設定寫在 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_protectionallowed_hostsallowed_origins 三個參數,用來擋 DNS rebinding 攻擊。本機開發不必設,但是,明天文章寫到有關部署到 Cloud Run 時,其實是會用到的,也請注意。

VII. 用 MCP Inspector 測試

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。

MCP Inspector 的 Tools 分頁:工具清單、READ-ONLY 標籤、Docstring 轉成的說明與參數描述

這一頁正好把前面講的三件事一次攤開來看:

  • 「搜尋假單。這是取得 leave_id 的唯一方式,請勿自行推測編號。」—— 那是 Docstring 的第一行,原封不動變成工具說明
  • employee_id 底下的「員工編號,格式為 E 開頭加四位數字(非姓名)」—— 那是 Docstring 裡 Args: 區塊的內容
  • 左上角綠色的 READ-ONLY 標籤 —— 那是 annotations={"readOnlyHint": True}

您在程式碼裡寫的每一個字,模型都看得到。 Inspector 讓這件事變得具體。

Resources 與 Prompts 也各有一頁:

MCP Inspector 的 Resources 分頁:URI 模板 leave://{leave_id} 與參數輸入

Resource 的 URI 模板 leave://{leave_id} 被解析成一個需要填 leave_id 的表單 —— 這正是模板參數自動對應到函式同名參數的效果。

其中最重要的是驗證 Elicitation 流程。 Inspector 會在 Server 要求確認時顯示表單,可以分別測試 accept、decline、cancel 三種回應。

建議在這個步驟多花十分鐘,把三條路徑都手動走過一次,確認 Server 回傳的 status 各自正確。理由在於除錯範圍的收斂:之後要把 Elicitation 接上 Google ADK,屆時若出現問題,可能的原因有三個 —— Server 端邏輯錯誤、Google ADK 的 callback 未正確掛載、或是協定層的相容性問題。今天先把 Server 端排除,後續的除錯範圍就縮小了三分之一。

VIII. 本系列的實作:Leave Copilot

有了以上的基礎,接下來說明本系列實際使用的工具集。

Leave Copilot MCP Server 的九個工具與四個刻意難點的分布

專案結構與工具清單

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

表格:類別、工具、readOnlyHint

一個違反直覺的設計目標

這裡要說明一件事:這組工具是刻意設計得不好用的。

一般設計 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 有莫名的吸引力。

IX. 結語

FastMCP 把 MCP 的協定細節封裝得相當完整,讓「把既有系統包裝成 AI 可用的工具」這件事,從協定實作降級成了一般的 Python 開發工作。

總結來說,今天提到三個重點:

  • Docstring 是給模型看的說明書,不只是註解: FastMCP 會把型別提示轉成 JSON Schema、把 Docstring 轉成工具描述。這意味著函式的說明文字同時服務兩個讀者 —— 工程師與模型,而它的品質會直接反映在工具呼叫的準確率上。
  • Context 讓工具從「純函式」變成能與外界互動: 只要在簽名加上 ctx: Context,就能取得日誌、進度回報、資源讀取與 Elicitation 的能力。而且這個參數不會出現在給模型的 Schema 裡。
  • Transport 的選擇會影響整個工具鏈: stdio 最省事,但沒有 URL 可供外部工具連接。本系列因為評測工具需要讀取 Server 的工具定義,全程採用 HTTP —— 這類決策最好在第一天就定案,因為之後更動的成本相當高。

至於 Leave Copilot 那四個刻意植入的難點,它們的價值會在後續逐步顯現:之後讓 Agent 實際去踩、評測階段量出踩得多嚴重、訓練資料階段轉成訓練資料、系列後期驗證改善幅度。

明天,我將會提到,如何建置成 Docker Service,並且部署到 Cloud Run,接著就要思考 MCP 的安全性議題,再請大家回來閱讀後續章節囉!

Day 3 Cheat Sheet:指令、參數與容易踩的地方


參考來源

查證日期:2026-09-01。本篇程式碼以 fastmcp 4.0.0 實際跑過。


I am Simon

大家好,我是 Simon 劉育維,是一位 AI 領域解決方案專家,目前也擔任 Google Cloud AI 領域開發者專家 (GDE),期待能夠幫助企業導入人工智慧相關技術解決問題。如果這篇文章對您有幫助,歡迎在我的 Linkedin 上留言提供意見,並與我一起討論有關人工智慧的主題,期待能夠對大家有所幫助!

我的個人部落格資訊:https://medium.com/@simon3458


上一篇
[ AI Agent ] Day 2 — AI Agent 核心概念與 MCP 架構解構:兩個必須先建立的心智模型
系列文
從 MCP 到專屬 Agentic 模型:30 天走完一條可評測、可微調、可自架的 AI Agent 模型與服務製作流程3
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言