你的服務裡已經有一條開好的 DB 連線、一份讀完的設定檔、一個還沒過期的存取權杖。現在你要讓 Claude 去查一筆訂單狀態,於是開始寫 MCP server:第二支程式、第二份設定、第二個要煩惱它有沒有活著的東西。

這一步不一定要走。而且判準比想像中窄,照官方文件看,窄到只剩幾種情況真的非走不可。

它省掉的那一步

Agent SDK 裡有 create_sdk_mcp_server(Python)跟 createSdkMcpServer(TypeScript)這組東西,官方對它的描述只有一句,而那句是整件事的地基:

“The server runs in-process inside your application, not as a separate process.”

跑在你的應用程式裡面。所以開頭那條連線你直接拿來用,設定不用再讀一次,權杖也不用想辦法遞過去,因為它們本來就在同一塊記憶體裡。站上 2026-05-20 那篇〈Claude Agent SDK 完整教學〉收尾時提過「可以把 MCP server 當成 Agent SDK 的工具來源」,講的是外部那條路。這篇走的是它旁邊那條。

一個工具就四個零件:名字、描述、輸入 schema、handler。描述那一格最容易被當成註解隨手寫掉,但它是 Claude 決定要不要叫這個工具的依據,寫壞了工具就靜靜躺在那裡永遠不被呼叫。schema 在 TypeScript 一律是 Zod,handler 收到的 args 型別直接從它推出來;Python 是一個 dict,像 {"latitude": float} 這樣,SDK 幫你轉成 JSON Schema。要用 enum、範圍或巢狀物件的時候,Python 這邊就得改傳完整的 JSON Schema dict。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
from claude_agent_sdk import tool, create_sdk_mcp_server

@tool(
"get_temperature",
"Get the current temperature at a location",
{"latitude": float, "longitude": float},
)
async def get_temperature(args):
# 這裡原本是一段 httpx 抓 open-meteo 的程式碼,節錄時掐掉了
return {
"content": [{"type": "text", "text": "Temperature: 62.1°F"}]
}

weather_server = create_sdk_mcp_server(
name="weather",
version="1.0.0",
tools=[get_temperature],
)

這一頁最容易看漏的地方是:工具的全名不是你在 @tool 裡取的那個。

1
2
3
4
options = ClaudeAgentOptions(
mcp_servers={"weather": weather_server},
allowed_tools=["mcp__weather__get_temperature"],
)

mcp_servers 那個 dict 的 key 才是全名裡的 {server_name},格式是 mcp__{server_name}__{tool_name}。把 key 從 weather 改成別的而 allowed_tools 那行沒跟著改,工具還在、Claude 也還看得到,但每次呼叫都會停下來等你批准。同一台 server 上工具多了,可以用 mcp__weather__* 一次蓋掉。

選填參數那一格,直覺會寫錯

假設這支工具要多吃一個「往後抓幾小時」的參數,而它可以不填。Python 這邊的直覺寫法是在 dict schema 裡加一格 {"hours": int},然後在描述裡註明它是選填。

這條路是死的。dict schema 把每一個 key 都當成必填,寫在描述裡不會讓它變成選填。官方教的做法剛好反過來:把那個參數留在 schema 外面,在描述字串裡講清楚它可以傳、範圍多少,handler 裡用 args.get() 讀。

1
2
3
4
5
6
7
8
9
@tool(
"get_precipitation_chance",
"Get the hourly precipitation probability for a location. "
"Optionally pass 'hours' (1-24) to control how many hours to return.",
{"latitude": float, "longitude": float},
)
async def get_precipitation_chance(args):
# 'hours' 不在 schema 裡,用 .get() 讀,它才是選填的
hours = args.get("hours", 12)

TypeScript 沒這個問題,Zod 欄位加個 .default(12) 就選填了。兩邊同一件事做法不一樣的地方,這一頁上不只這一處,後面還有更難繞的。

例外沒被接住時,Claude 讀到的是誰寫的句子

官方這一段的第一句就把最要緊的事講完了:

“A handler error doesn’t stop the agent loop.”

handler 丟出未捕捉的例外,in-process MCP server 會接住、轉成一個 error result,把原始例外訊息交給 Claude,然後迴圈繼續跑。你自己 catch 起來、回一個帶 isError: true(Python 寫 "is_error": True)的結果,迴圈同樣繼續跑。兩種情況下這次查詢都不會失敗。

所以這個選擇跟「會不會炸」無關,它決定的是 Claude 讀到哪一句話。

差別像餐廳出餐出了問題。一種是廚房把烤箱的錯誤代碼直接端到你桌上;另一種是服務生走過來說「這道今天缺一味料,要不要換隔壁那道」。前者你只知道事情不對,後者你知道下一步怎麼走。Claude 拿到一串原始例外字串的時候,它能做的判斷跟你盯著那串烤箱錯誤碼差不多。所以官方的建議是,原始訊息不足以讓 Claude 採取行動時就自己接起來,補上是哪個端點失敗、建議改試什麼。

什麼時候 in-process 反而是錯的選擇

前面那些好處全部來自同一件事:它跟你的程式跑在同一個 process 裡。限制也全部來自那件事,而文件對其中一條的態度直接得不留餘地,它叫你別用 in-process。

那條是 structuredContent。工具結果除了 content 陣列,還可以帶一個 structuredContent,讓 Claude 讀到精確的欄位,不必從一段文字裡撈值。

在講 Python 缺什麼之前,這個欄位有一個行為得先講,它是整頁最該記住的一條。

structuredContent 一旦有值,content 裡的 text block 就不會被轉發給 Claude。官方的理由是那些文字被假定跟結構化資料重複。image 跟 resource block 不受影響,照樣過去。

所以被吃掉的,剛好是你寫給 Claude 看的那段人話。

這條跟這一頁其他的坑差在它不出聲。後面那兩條(音訊 block、二進位 resource),Python SDK 丟掉東西的時候至少會印一行警告,log 裡 grep 得到。text block 被略過沒有警告、沒有錯誤、迴圈照跑。你的測試多半也抓不到,因為 handler 的回傳值本來就是對的,兩樣東西都好好地放在那個 dict 裡;掉的是 Claude 那一端收到的版本。等你發現,多半是因為 Claude 的回答漏了某個你以為已經交代過的前提,而不是因為誰報了錯。

你以為兩邊都送了,其實只送了一邊。

至於 Python,@tool 裝飾器只把 contentis_error 兩樣東西從 handler 的回傳 dict 轉出去。官方 Note 寫得毫不含糊:

“To return structuredContent from Python, run a standalone MCP server instead of an in-process SDK server.”

另外兩條也是回傳型別上的洞,同樣只在 Python 那邊。音訊 block 在 TypeScript 會被 SDK 存成檔案、Claude 收到一個帶檔案路徑的 text block;在 Python,SDK 直接把音訊 block 從結果裡丟掉,印一行警告。二進位的 resource 一樣,resource.blob 是 TypeScript only,Python SDK 會丟掉它並印警告。

resource link 是第四件事,不過它不算進判準裡。TypeScript 的應用程式收得到 resourceLinks,Python 的 SDK 在 CLI 看到結果之前就把它們攤平成文字,所以 Python 那個 resourceLinks 欄位對 in-process 工具永遠不會產生。差別在前面三條是東西送不出去,這一條是東西換了型態送出去:內容還在,只是那個欄位不會出現。

三條合起來,判準其實很窄。你在 Python,而且這支工具的回傳值是機器可讀的結構化資料、音訊或二進位內容,這時候 in-process 這條路上是缺東西的,文件自己叫你去開獨立的 server。其他情況下,「另外開一支程式」的成本你付了也買不到什麼。

要注意這三條全部是 Python 跟 TypeScript 之間的落差,不是 in-process 跟外部 server 之間的落差。跨語言、要能獨立重啟、要給別的 client 共用同一台 server,這些聽起來都很像該開獨立 server 的理由,但這一頁沒有替它們背書,我也不替它編一條出來。真要拿它們當理由,你得自己去驗。

標了唯讀,它還是能寫磁碟

annotations 是四個布林值的中繼資料,TypeScript 傳在 tool() 的第五個參數裡({ annotations: { readOnlyHint: true } }),Python 用 annotations=ToolAnnotations(...) 這個關鍵字參數。四個裡面只有一個會真的改變行為。

欄位 預設 它做什麼
readOnlyHint false 宣告工具不改動環境。決定它能不能跟其他唯讀工具平行呼叫
destructiveHint true 工具可能做破壞性更新。純資訊
idempotentHint false 同樣參數重複呼叫沒有額外效果。純資訊
openWorldHint true 工具會碰到你的 process 以外的系統。純資訊

官方把這件事寫在表格底下一行:「Annotations are metadata, not enforcement.」標了 readOnlyHint: true 的工具,handler 如果就是去寫磁碟,它照樣寫得下去。這欄位的意思是請你把它填對,不是填了就守得住。

同一種「以為在管 A 其實在管 B」的坑,權限設定那邊也有一個,代價是白花一輪。tools 陣列跟裸名的 disallowedTools 動的是可用性,也就是工具在不在 Claude 的 context 裡;allowedTools 跟帶範圍的 disallowedTools 規則動的是權限,也就是呼叫時要不要批准。所以 "Bash(rm *)" 這種帶範圍的 deny,工具還留在 Claude 眼前,它會去試、然後被擋掉,官方的說法是 Claude 可能因此浪費一輪(”so Claude may waste a turn trying it”)。要讓一個內建工具徹底消失,把它從 tools 裡拿掉,或者在 disallowedTools 裡只寫裸名。

還有一件預設就開著的事值得知道。tool search 預設開啟,它會把 SDK MCP 工具延後載入,Claude 先看到一份精簡的名字清單,要用了才去載完整 schema。反過來說,tool search 關掉的話,tools 陣列裡每一個工具每一輪都在吃 context window。TypeScript 想讓某個工具的 schema 常駐在初始 prompt,可以在 tool()extras 參數或 createSdkMcpServer() 的 options 裡設 alwaysLoad: true

這篇的 API 名稱、欄位、預設值與引號裡的英文原句都抄自官方的 custom tools 文件(2026 年 9 月 9 日讀到的版本),我沒有實際跑過這裡任何一段程式碼,所以不會告訴你 npx tsx weather.ts 跑起來長什麼樣、open-meteo 回什麼,也不會給你工具延後載入省下多少 token 的數字。上面的程式碼是照官方範例節錄的,抓資料那幾行掐掉了,要跑請回原頁抄完整版。

兩條線

預設寫 in-process,不要先開第二支程式。你的工具要用的東西已經躺在你的 process 裡了,把它搬到另一支程式去,付出去的是兩份設定、兩個生命週期,換回來的是這一頁文件沒有承諾給你的東西。

要判斷例外,背後就兩條線。第一條:這個函式會不會用到你 process 裡已經有的連線、設定或狀態。會,in-process 幾乎是免費的。第二條:它的回傳值會不會落在 Python 的 in-process 工具送不出去的那幾種型別上,結構化資料、音訊、二進位內容。會,那就去開獨立的 server,這是文件自己講的,不是你的取捨空間。

兩條線都是「不會」的時候,一個 @tool 加一個 create_sdk_mcp_server 就是那支工具的全部。先把這個寫出來,真的撐不住再談第二支程式。