先別開第二支程式,Agent SDK 的自訂工具跑在你的 process 裡
你的服務裡已經有一條開好的 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 | from claude_agent_sdk import tool, create_sdk_mcp_server |
這一頁最容易看漏的地方是:工具的全名不是你在 @tool 裡取的那個。
1 | options = ClaudeAgentOptions( |
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 |
|
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 裝飾器只把 content 跟 is_error 兩樣東西從 handler 的回傳 dict 轉出去。官方 Note 寫得毫不含糊:
“To return
structuredContentfrom 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 就是那支工具的全部。先把這個寫出來,真的撐不住再談第二支程式。





































































































































































































