NLP Day 34 MCP 與工具生態
執行需求:需 API Key(Ollama 本地替代可行)。昨天(Day 33)我們寫了第一個 Agent:模型呼叫本地的 `get_contract_info()` 函式。這種「自寫工具函式」的做法在原型階段很方便,但一旦工具數量上升到十幾個、把 Agent 部署到不同應用、或者想讓其他團隊的工具也能被自己的 Agent 使用,就會碰到整合問題:每家 LLM 客戶端的工具描述格式不同、執行環境不同、權限控管也不同。MCP(Model Context Protocol)正是為了解決這個問題而生的標準化協定。今天這一篇會從 MCP 的設計目標談起,介紹三個角色(host、client、server)的分工,再示範怎麼寫一個最小的 MCP server,以及怎麼讓支援 MCP 的客戶端(Claude Desktop、Cursor 等)呼叫它。
引言
MCP 由 Anthropic 在 2024 年 11 月 25 日開源(官方公告:https://www.anthropic.com/news/model-context-protocol),目的是「讓 LLM 應用能像 USB-C 一樣、用同一個介面串接各種資料源與工具」。在 MCP 出現之前,每家 IDE、聊天客戶端、AI 編輯器都需要各自整合外部工具——Cursor 寫一套、Claude Desktop 寫一套、自家內部工具又寫一套。MCP 把這個整合成本標準化:工具提供者只要寫一次 MCP server、任何支援 MCP 的客戶端就能使用,模型從中挑選工具時也不再被各家格式差異干擾。
今天的內容會刻意聚焦在「能跑通的最小範例」與「觀念」,不深入探討 MCP 的每一個 JSON-RPC 細節。原因有二:第一,MCP 的規格仍在演進,2025 年 3 月之前的版本穩定,但之後的版本會繼續新增欄位;第二,讀者若想把 MCP 落地到企業環境,更需要的是「了解 host/client/server 三者關係」與「能把一個工具改成 MCP server」這兩個能力,而不是死背某一版的規格。今天的範例用一個查詢合約的小工具,示範從 Python 函式改寫成 MCP server、再用支援 MCP 的客戶端呼叫它的完整流程。
MCP 的三個角色:Host、Client、Server
MCP 把整合切成三個角色:Host 是裝 LLM 應用的程式(Claude Desktop、Cursor、自家聊天機器人),負責與使用者互動、把對話傳給 LLM、呼叫 LLM;Client 是 Host 內部、用來跟 Server 溝通的小程式,知道如何跟特定 Server 建立連線、列出可用工具、把工具呼叫結果回傳給 Host;Server 是真正提供工具與資料的程式,每個 Server 通常負責一個領域(合約系統、Slack、檔案系統、PostgreSQL)。一個 Host 可以同時跟多個 Server 連線、一個 Server 也可以被多個 Host 共用,這樣就能建構出「工具市集」的生態。
舉一個實際情境:我們團隊的 Claude Desktop 同時連線到「公司內部合約系統 MCP server」、「Slack MCP server」、「PostgreSQL MCP server」。當使用者問「幫我把 Q1 合約資料庫裡的客戶清單丟到 Slack 的 #sales 頻道」時,Claude Desktop(Host)會自動協調兩個 Server——先用 PostgreSQL 工具查客戶清單、再用 Slack 工具發訊息——這就是 MCP 想做到的「跨工具協作」。沒有 MCP 之前,這個跨工具情境需要 Claude Desktop 自己寫兩套整合,現在只要各裝一個 MCP server 就行。
傳輸層:stdio 與 HTTP
MCP 規範在 2025 年 3 月前公開支援兩種傳輸方式:stdio(標準輸入輸出)與 HTTP。stdio 是預設方式,Server 以子行程方式啟動、Host 透過 stdin/stdout 與 Server 溝通,優點是設定簡單、適合本機工具;HTTP 則適合跨機器、跨網段的 Server,用 HTTP+SSE(Server-Sent Events)做雙向通訊。對企業內部場景,stdio 適合單機開發、HTTP 適合多機部署。
stdio 模式的設定最簡單,只要在客戶端(Claude Desktop 或 Cursor)寫一個 JSON 設定檔,告訴它「啟動這個命令、它就是一個 MCP server」。Claude Desktop 在 macOS 的設定檔位於 ~/Library/Application Support/Claude/claude_desktop_config.json,加上類似以下的區塊就能新增一個 server。
{
"mcpServers": {
"contracts": {
"command": "python",
"args": ["-m", "contracts_mcp.server"]
}
}
}
這段 JSON 設定告訴 Claude Desktop:「啟動 `python -m contracts_mcp.server` 當作一個 MCP server、命名為 contracts」。Claude Desktop 啟動時會把這個指令跑起來,把 stdin/stdout 串到 MCP 通訊。對企業使用者來說,這樣就能在不修改 Claude Desktop 程式碼的情況下加入自家工具。
由於我們無法預測每家 IDE 在 2025 年 3 月之後的設定檔路徑與格式變動,本文不深入到具體客戶端的設定畫面,只示範如何寫一個 MCP server。讀者若要把範例整合進 Claude Desktop 或 Cursor,請參考該客戶端的官方文件。
寫一個最小的 MCP server
MCP 的官方 Python SDK 套件名稱是 `mcp`(由 Anthropic 與社群共同維護),2025 年 3 月已經進入活躍開發。我們用這個 SDK 把昨天的 `get_contract_info()` 函式改寫成 MCP server,提供 `list_contracts`、`get_contract_info` 兩個工具。
# contracts_mcp/server.py
from mcp.server import Server
from mcp.types import Tool, TextContent
CONTRACT_DB = {
"A123": {"customer": "碩網資訊", "expire": "2025-12-31"},
"B456": {"customer": "大河媒體", "expire": "2025-08-15"},
"C789": {"customer": "北辰文創", "expire": "2025-06-30"},
}
app = Server("contracts-mcp")
@app.list_tools()
async def list_tools() -> list[Tool]:
return [
Tool(
name="get_contract_info",
description="查詢合約基本資料。",
inputSchema={
"type": "object",
"properties": {"contract_id": {"type": "string"}},
"required": ["contract_id"],
},
),
Tool(
name="list_contracts",
description="列出所有合約編號。",
inputSchema={"type": "object", "properties": {}},
),
]
@app.call_tool()
async def call_tool(name: str, arguments: dict) -> list[TextContent]:
if name == "get_contract_info":
info = CONTRACT_DB.get(arguments["contract_id"], {"error": "查無此合約"})
return [TextContent(type="text", text=str(info))]
if name == "list_contracts":
return [TextContent(type="text", text=str(list(CONTRACT_DB.keys())))]
raise ValueError(f"未知工具:{name}")
這段程式用 MCP Python SDK 的 `Server` 物件註冊兩個工具:`get_contract_info` 與 `list_contracts`。每個工具都用 `Tool` 物件描述,包含 name、description 與 inputSchema(MCP 用 JSON schema 描述輸入參數)。`call_tool()` 是 Server 接收到客戶端呼叫時的處理函式,回傳 `TextContent` 串列。這個 server 啟動後就會透過 stdin/stdout 接受 MCP 客戶端的呼叫。
注意我們刻意不在 server 內部呼叫 LLM——MCP server 的職責是「提供工具與執行工具」,不負責 LLM 推理。LLM 在 Host 那一端跑,把工具呼叫的意圖送到 MCP server,server 執行後回傳結果。這個分工讓 MCP server 可以獨立測試、也可以被多個不同的 Host 共用。
啟動 server 並用 Python 客戶端測試
寫好 server 之後,先寫一個最小的 Python 客戶端測試它能不能溝通。MCP 的客戶端 SDK 與 server SDK 在同一個套件,可以用 `stdio_client` 建立連線、用 `ClientSession` 呼叫工具。這個測試客戶端可以用在 CI 上,確保 server 改版時不會壞掉。
import asyncio
from mcp import ClientSession, StdioServerParameters
from mcp.client.stdio import stdio_client
async def main():
params = StdioServerParameters(command="python", args=["-m", "contracts_mcp.server"])
async with stdio_client(params) as (read, write):
async with ClientSession(read, write) as session:
await session.initialize()
tools = await session.list_tools()
print(f"可用工具:{[t.name for t in tools.tools]}")
result = await session.call_tool("get_contract_info", {"contract_id": "A123"})
print(f"查詢結果:{result.content[0].text}")
asyncio.run(main())
# 輸出(實際結果會略有不同):
# 可用工具:['get_contract_info', 'list_contracts']
# 查詢結果:{'customer': '碩網資訊', 'expire': '2025-12-31'}
這段程式用 `stdio_client` 啟動 server 子行程、用 `ClientSession` 與 server 溝通。先呼叫 `list_tools()` 列出可用工具,再呼叫 `get_contract_info` 查詢特定合約。輸出會顯示工具清單與查詢結果。如果看到「查無此合約」或連線錯誤,代表 server 啟動失敗或 MCP SDK 版本不相容,請先確認兩個套件版本都是 2025 年 3 月前後的版本。
MCP 與昨天自寫函式的差別
把昨天的 `get_contract_info()` 與今天的 MCP server 比較,最大的差別是「可被呼叫的範圍」。自寫函式只能被同一個 Python 程式內的 LLM 呼叫;MCP server 可以被任何支援 MCP 的 LLM 應用呼叫,包括 Claude Desktop、Cursor、各家 IDE 插件、甚至自家寫的聊天機器人。一旦把工具打包成 MCP server,它就從「個人工具」升級成「團隊工具」、甚至「社群工具」。
第二個差別是「設定檔驅動」。MCP server 的註冊是設定檔式的(昨天的範例是 JSON),使用者要新增工具時不需要修改 LLM 客戶端的程式碼,只需要編輯設定檔加一行。這種「外掛模型」是 MCP 生態能快速長大的關鍵,跟瀏覽器擴充套件是同樣的設計理念。
第三個差別是「資源(resources)與提示(prompts)」兩種新的工具類型。除了工具(tools)之外,MCP 還支援 resources(被動資料源,例如檔案、資料表)與 prompts(預先寫好的提示模板)。今天的範例只用 tools,但讀者寫企業內部 MCP server 時常會同時用到這三種。
完整實作:套件化的 MCP server
為了讓 MCP server 能用 `python -m contracts_mcp.server` 啟動,我們把它包成一個小套件:建立 `contracts_mcp/__init__.py` 與 `contracts_mcp/__main__.py`,讓 `python -m contracts_mcp` 能執行主程式。這個套件化設計讓設定檔的指令更乾淨,也方便用 pip 安裝到不同主機。
# contracts_mcp/__main__.py
import asyncio
from mcp.server import stdio_server
from contracts_mcp.server import app
async def main():
async with stdio_server() as (read, write):
await app.run(read, write, app.create_initialization_options())
if __name__ == "__main__":
asyncio.run(main())
這段程式是 server 的進入點,呼叫 `stdio_server()` 建立標準輸入輸出連線,再把讀寫串流交給 `app.run()`。配合昨天的 `server.py`,整個套件就可以用 `python -m contracts_mcp` 啟動。把這個套件用 `pip install -e .` 安裝到開發機後,就能在 Claude Desktop 的設定檔引用。
{
"mcpServers": {
"contracts": {
"command": "python",
"args": ["-m", "contracts_mcp"]
}
}
}
這段 JSON 設定告訴支援 MCP 的客戶端啟動這個 server。一旦啟動成功,使用者在 Claude Desktop 或 Cursor 裡問「合約 A123 還剩幾天」,客戶端就會呼叫 server 的 `get_contract_info` 工具、把結果交給 LLM、生成回答。
JSON schema 驗證:守住工具輸入的第一道關卡
MCP 規範用 JSON schema 描述工具輸入,但 schema 只在客戶端讀取時生效,server 端仍可能收到不符合的參數(攻擊者或程式 bug 都有可能)。建議在 server 內部加上 schema 驗證,把不合法的請求擋在工具執行之前。我們用 Python 內建的 `jsonschema` 套件示範這個機制。
from jsonschema import validate, ValidationError
CONTRACT_SCHEMA = {
"type": "object",
"properties": {"contract_id": {"type": "string"}},
"required": ["contract_id"],
}
def validate_input(arguments: dict) -> tuple[bool, str]:
"""驗證工具輸入是否符合 schema。"""
try:
validate(instance=arguments, schema=CONTRACT_SCHEMA)
return True, ""
except ValidationError as e:
return False, str(e.message)
print(validate_input({"contract_id": "A123"}))
# 輸出:(True, '')
print(validate_input({"id": "A123"}))
# 輸出:(False, "'contract_id' is a required property")
這段程式定義了 `CONTRACT_SCHEMA` 並用 `validate()` 檢查工具輸入。第一個範例符合 schema,回傳 `(True, '')`;第二個範例缺少 `contract_id` 欄位,回傳錯誤訊息。在 production MCP server 中,這個驗證應該在 `call_tool()` 函式的開頭就做,把不合法的請求擋下、避免進入實際業務邏輯。
錯誤處理:工具失敗時的回應
MCP server 的工具可能因為各種原因失敗:合約編號不存在、資料庫連線逾時、權限不足。良好的 server 應該把錯誤包裝成結構化回應,而不是直接拋出例外給客戶端。我們擴充昨天的 `call_tool()` 加入錯誤處理:
def safe_call_tool(name: str, arguments: dict) -> list[TextContent]:
"""呼叫工具,失敗時回傳結構化錯誤。"""
ok, err = validate_input(arguments)
if not ok:
return [TextContent(type="text", text=json.dumps({"error": err}))]
if name == "get_contract_info":
info = CONTRACT_DB.get(arguments["contract_id"])
if info is None:
return [TextContent(type="text", text=json.dumps({"error": "查無此合約"}))]
return [TextContent(type="text", text=json.dumps(info))]
return [TextContent(type="text", text=json.dumps({"error": "未知工具"}))]
import json
print(safe_call_tool("get_contract_info", {"contract_id": "Z999"}))
# 輸出:[TextContent(type='text', text='{"error": "查無此合約"}')]
這段程式把昨天的 `call_tool()` 改成 `safe_call_tool()`,先做 schema 驗證再做實際查詢。當合約不存在時,回傳結構化的錯誤 JSON 而不是拋出例外;客戶端可以把這個錯誤訊息交給 LLM,讓模型決定下一步怎麼處理。實務上常見的延伸是把錯誤碼分級(輸入錯誤、資源缺失、伺服器錯誤),讓客戶端可以根據錯誤碼採取不同動作。
逾時控制:避免 server 卡住拖垮客戶端
MCP server 啟動後會在背景執行,如果工具內部卡住(例如資料庫查詢逾時),整個客戶端都會跟著卡住。建議在 server 端加上逾時控制,把長時間未回應的工具呼叫主動中斷。我們用 Python 的 `asyncio.wait_for()` 示範非同步逾時機制。
import asyncio
async def slow_lookup(contract_id: str) -> dict:
"""模擬一個可能逾時的查詢。"""
await asyncio.sleep(2.0)
return CONTRACT_DB.get(contract_id, {"error": "查無"})
async def safe_lookup(contract_id: str, timeout: float = 1.5) -> dict:
"""查詢合約,逾時則回傳錯誤。"""
try:
return await asyncio.wait_for(slow_lookup(contract_id), timeout=timeout)
except asyncio.TimeoutError:
return {"error": "查詢逾時"}
result = asyncio.run(safe_lookup("A123"))
print(result)
# 輸出(實際會略有不同):{'customer': '碩網資訊', 'expire': '2025-12-31'}
這段程式用 `asyncio.wait_for()` 把查詢包在 1.5 秒逾時內,正常情況下 `slow_lookup` 會在 2 秒內回傳結果(這裡為了展示逾時把延遲設為 2 秒)。如果實際環境查詢超過逾時門檻,回傳錯誤訊息而不是無限等待。實務上逾時門檻要依工具特性調整:對 LLM 推論設 30 秒、對資料庫查詢設 5 秒、對檔案讀取設 2 秒,過短會誤殺、過長會拖累客戶端。
常見錯誤與踩雷
第一個常見踩雷是「stdio 設定檔的指令找不到」。`command: python` 在某些環境會找不到 Python,請改用絕對路徑(例如 `/usr/bin/python3` 或 `C:\Python312\python.exe`)。Mac 使用者可以加 "command": "python3";Windows 使用者建議用 "command": "py" 並確認 Python Launcher 已安裝。
第二是「Server 啟動後沒有任何 log」。stdio 模式的 stdout 被 MCP 通訊佔用,server 不能用 `print()` 輸出 log,否則會污染通訊協定。請改用 logging 模組輸出到 stderr,這樣 log 才不會跟 MCP 通訊混在一起。
第三是「輸入參數型別不符」。MCP 用 JSON schema 描述參數,但 Python 端拿到的 `arguments` 可能是字串而非數字。`get_contract_info({"contract_id": 123})` 會拿到整數而不是字串,請在 server 內部強制轉型為字串,避免資料庫鍵對應錯誤。
效能與實務提醒
MCP server 的啟動延遲大約 200 至 500 毫秒(Python 子行程的常見延遲),對互動式對話可接受;對高頻自動化的批次任務,建議改用 HTTP 模式並保持連線復用,避免反覆啟動子行程。
對企業部署,多個團隊共用 MCP server 是常見情境。此時建議用 HTTP 模式、把 server 部署在內部網路、固定一個網址與通訊埠,並用反向代理(nginx、Caddy)處理認證與限流。今天的範例為了簡潔用 stdio,企業環境請評估改用 HTTP。
另外,MCP SDK 在 2025 年 3 月仍在活躍開發,介面可能持續演進。建議把 MCP 套件版本鎖定在某個 release(例如 `mcp==0.3.x` 或 `mcp==1.0.x`),並在 CI 上跑回歸測試,避免升級時被 API 變動打到。
另一個延伸考量是 server 的資源限制。MCP 規範允許 server 暴露 resources(被動資料源)與 prompts(預先寫好的提示模板)兩種能力,這在企業內部特別有用:把常見的客服回應範本做成 prompts、把內部文件存成 server resources,讓任何支援 MCP 的客戶端都能直接取用。今天的範例只用 tools,但企業部署請把這兩種能力也規劃進去。
最後,企業級 MCP server 通常會串接認證系統:透過 OAuth token 或 API key 驗證呼叫者身份、記錄呼叫紀錄到 audit log、根據角色限制可呼叫的工具。MCP 規範在 2025 年 3 月前已對認證有基本支援,但實務實作仍依賴各 server 自行設計。
小結
今天從 MCP 的設計目標出發,介紹 host/client/server 三個角色的分工、stdio 與 HTTP 兩種傳輸方式,並實際寫出一個最小的 MCP server。我們把昨天的 `get_contract_info()` 改寫成可被外部 LLM 應用呼叫的工具,跨過了「個人工具」與「團隊工具」之間的門檻。MCP 的概念在 2025 年仍在演進,但「把工具當作獨立 server」的設計理念已經穩定,今天的程式碼會在 Day 35 與 Day 37 繼續被重用。
觀察能力:用 MCP Inspector 除錯
MCP 官方提供了 Inspector 工具,這是一個圖形介面的除錯器,可以列出 server 的所有工具、資源與提示,並手動呼叫工具看回傳結果。在開發階段用 Inspector 比寫測試客戶端更直觀,可以即時看到工具描述、輸入 schema 與回傳內容是否符合預期。Inspector 的啟動指令在 2025 年 3 月前已經穩定,可以用 `npx @modelcontextprotocol/inspector` 啟動一個本地網頁介面,連線到正在執行的 MCP server 做互動式測試。對企業開發團隊來說,這個工具是 debug 的標配。
結語
MCP 解決了「工具怎麼被多個 LLM 應用共用」的問題,但企業環境更常見的場景是「多個 LLM 應用一起合作完成一件任務」。明天(Day 35)我們會進入多 Agent 協作的世界:兩個以上的 LLM 互相傳遞訊息、各自負責一段任務、彼此檢查結果。今天的 MCP server 會以「合約 Agent」的身分被多 Agent 框架呼叫,敬請期待。
延伸資源
- Anthropic MCP 官方公告(2024-11-25):
https://www.anthropic.com/news/model-context-protocol - MCP 官方網站與規格(2024–2025):
https://modelcontextprotocol.io/ - MCP Python SDK(GitHub,2024–2025):
https://github.com/modelcontextprotocol/python-sdk - Claude Desktop MCP 整合文件(2025):
https://docs.anthropic.com/en/docs/build-with-claude/mcp - 社群 MCP server 清單(2025):
https://github.com/modelcontextprotocol/servers
留言
張貼留言