昨天把 MCP 的兩種傳輸方式拆開來看。
stdio 適合跟著本機應用一起跑,Streamable HTTP 則適合獨立部署成服務。
今天先不再拆協定了。
前九天學過的東西全部拿回來,做出第一個真的能用的 MCP Server:devbench。
它是一個本機開發者工作台,提供:
Tools
├── read_file
├── list_files
├── run_tests
└── git_log
Resources
├── devbench://project/summary
└── devbench://file/{path}
Prompt
└── code_review
最後再把它掛進 Claude Code,真的用起來。

這次實作用的是 MCP Python SDK 2.x。
如果照一些舊教學寫:
from mcp.server.fastmcp import FastMCP
會直接遇到 ModuleNotFoundError。
現在這份實作使用:
from mcp.server.mcpserver import MCPServer
另外 SDK 裡 Python 物件的欄位名稱也改成 snake_case。
例如:
protocolVersion → protocol_version
inputSchema → input_schema
isError → is_error
但這只是 Python API 的命名方式改了。
MCP 在線上傳送的 JSON 仍然維持協定定義的 camelCase。
這個差異如果不知道,很容易出現一種情況:
JSON 看起來完全正常,但 Python 一直噴
AttributeError。
devbench 第一版先提供四個工具:
| Tool | 用途 |
|---|---|
read_file |
讀取專案檔案 |
list_files |
搜尋專案內的檔案 |
run_tests |
執行 pytest |
git_log |
查看 Git commit |
例如 read_file:
@mcp.tool(
annotations=ToolAnnotations(
read_only_hint=True,
destructive_hint=False,
idempotent_hint=True,
)
)
def read_file(
path: Annotated[
str,
Field(description="相對於專案根目錄的檔案路徑")
],
max_lines: Annotated[
int,
Field(default=200, ge=1, le=5000)
] = 200,
) -> str:
"""讀取專案內的一個檔案並回傳內容。"""
target = safe_path(path)
if not target.is_file():
return f"找不到檔案:{path}"
lines = target.read_text(
encoding="utf-8",
errors="replace",
).splitlines()
return "\n".join(lines[:max_lines])
這裡其實把前面幾天的東西全部串起來了。
Annotated 和 Field 會被 SDK 轉成 Tool schema,模型因此知道參數名稱、型別和用途。
而 ToolAnnotations 則是在描述這個 Tool 的行為特性:
read_only_hint
destructive_hint
idempotent_hint
例如 read_file 是唯讀、不具破壞性,而且重複執行通常會得到相同效果。
這些是提供給 Host 的提示。
Host 可以依照這些資訊調整 UI、權限流程或確認機制,但它本身不是安全保證。
只要 Tool 接受檔案路徑,就會立刻遇到一個問題:
../../../etc/passwd
如果直接:
PROJECT_ROOT / path
模型或使用者就有機會一路往專案目錄外面走。
所以先做一個 safe_path():
def safe_path(rel: str) -> Path:
if rel.startswith("/") or "\x00" in rel:
raise ValueError("只接受相對路徑")
target = (PROJECT_ROOT / rel).resolve()
if not target.is_relative_to(PROJECT_ROOT):
raise ValueError("路徑逃出專案根目錄")
return target
這裡做了三件事:
1. 不接受絕對路徑
2. 不接受 null byte
3. resolve 後再次確認還在專案根目錄內
第三個最重要。
因為只檢查:
..
其實不夠。
假設專案內有一個 symbolic link 指向外部目錄,路徑字串本身可能完全沒有 ..,最後還是能讀到專案外的檔案。
所以要先:
.resolve()
把真正位置展開,再檢查它是不是還位於 PROJECT_ROOT 裡。
這也是 Tool 開始具備實際能力後,第一個真正需要自己負責的安全邊界。
接著故意攻擊一次:
await session.call_tool(
"read_file",
{"path": "../../../etc/passwd"},
)
實測結果:
isError = True
回傳內容 → Error executing tool read_file
路徑穿越成功被擋下來,而且 Server 沒有把完整例外內容直接送給 Client。
這個行為滿重要。
因為例外訊息有時候會包含:
絕對路徑
使用者名稱
內部目錄結構
套件資訊
不一定適合直接暴露給模型。
所以我在這裡把錯誤分成兩種。
像:
找不到檔案
是正常操作可能遇到的情況,直接 return:
return f"找不到檔案:{path}"
模型拿到訊息後,可以自己修正路徑再試一次。
但像:
路徑穿越
代表輸入已經越過安全邊界,就直接:
raise ValueError(...)
讓 SDK 當成 Tool error 處理。
簡單來說:
預期中的失敗 → 回傳給模型處理
違反安全邊界 → 丟例外
Day 7 講過,MCP Server 不只有 Tools。
所以這次也一起放兩個 Resources。
第一個提供專案摘要:
@mcp.resource(
"devbench://project/summary",
name="專案摘要",
mime_type="text/plain",
)
def project_summary() -> str:
...
Client 可以讀:
devbench://project/summary
取得專案的 Python 檔案數量、程式碼行數和目錄概況。
第二個則是 Resource Template:
@mcp.resource(
"devbench://file/{path}",
name="專案檔案",
mime_type="text/plain",
)
def file_resource(path: str) -> str:
return safe_path(path).read_text(
encoding="utf-8",
errors="replace",
)
所以同一個 Server 裡,現在同時有:
模型主動決定 → Tools
Host 主動載入 → Resources
使用者主動選擇 → Prompts
Prompt 則做成一個固定的 Code Review 流程:
@mcp.prompt(
name="code_review",
title="程式碼審查",
)
def code_review(
path: str,
focus: Literal["安全性", "效能", "可讀性", "全部"] = "全部",
) -> str:
return (
f"請審查 `{path}`,重點放在「{focus}」。\n"
"請檢查例外處理、外部輸入、效能與可讀性。"
)
這樣 Client 不需要每次重新拼一整段 Code Review Prompt。
Server 寫好之後,我沒有直接假設它能動。
另外寫一個 Client,真的照 MCP 流程跑一次:
initialize
↓
tools/list
↓
tools/call
↓
resources/list
↓
resources/read
↓
prompts/list
↓
prompts/get
Client 連上之後可以看到:
協定版本 2025-11-25
Server devbench v0.1.0
宣告的能力 tools=True resources=True prompts=True
接著也能讀到 Tool 的 schema 和 annotations:
read_file
參數 ['path', 'max_lines']
必填 ['path']
唯讀=True
破壞性=False
冪等=True
這裡其實就是前幾天內容的總驗收。
Day 7 的三大原語、Day 8 的 JSON-RPC、Day 9 的 stdio,現在全部串在同一個程式裡。
因為 devbench 需要讀本機專案,所以這次選 Day 9 講過的 stdio。
在專案根目錄放:
{
"mcpServers": {
"devbench": {
"command": "uv",
"args": [
"run",
"python",
"days/day10_mcp_server/server.py"
]
}
}
}
Claude Code 啟動時,就會:
啟動 devbench 行程
↓
建立 MCP Client
↓
initialize
↓
取得 Tools / Resources / Prompts
到這裡,它不再只是我們自己寫的 Demo Client 才能使用。
Claude Code 也能直接看到:
read_file
list_files
run_tests
git_log
以及 code_review Prompt。
前面九天一直在拆 MCP。
今天終於第一次把它裝回一個真的可以使用的東西。
第一個是測試。
我沒有 mock MCP Server,而是真的在測試裡啟動一個 stdio Server,再讓官方 Client 連進去。
原本把 Session 做成 pytest async fixture,結果遇到:
Attempted to exit cancel scope in a different task
最後改成:
@asynccontextmanager
async def devbench():
...
讓建立與關閉 Client 留在同一個 task 裡,問題就消失了。
第二個是 run_tests。
執行 pytest 時我沒有組成一整條 shell command:
subprocess.run(
["uv", "run", "pytest", target, "-q"],
...
)
而是直接傳 argument list。
這樣不會經過 shell 字串展開,也避開了一類常見的 shell injection 問題。
但 target 本身還是要做路徑驗證。
不經過 shell,不代表輸入就不用驗證。
Day 10 做完後,手上終於有一個能真正使用的 MCP Server:
devbench
├── Tools
├── Resources
├── Prompt
├── 路徑安全
├── 端到端測試
└── Claude Code
不過現在還有一件事沒有發生。
目前都是「人」在決定要呼叫哪個 Tool。
如果把 devbench 直接交給模型,讓模型自己觀察任務、挑工具、取得結果,再決定下一步呢?
這就開始碰到 Agent 了。
明天進入 Day 11:
從 MCP 到 Agent:模型、工具與 Agent 框架各自負責什麼?