给 Claude Agent SDK 写自定义工具:入参 schema、返回值与报错怎么设计
你已经有一套内部接口——查订单的、算库存的、读工单系统的。现在想让 agent 直接调它们,而不是把结果粘贴进 prompt 里。Claude Agent SDK 给的路子是:把函数包成 MCP 工具,跑在你自己进程里。
真正卡人的不是”怎么把函数注册进去”,而是返回值该长什么样。工具跑失败了,Claude 到底看到什么?返回一张图和一份结构化数据,哪个会被丢掉?这些在官方文档 code.claude.com/docs/en/agent-sdk/custom-tools 那一页里写得挺细,但散在几个小节里,第一次读容易漏。这篇就沿着字段结构和错误约定走一遍。
一、工具定义就四个部分
官方文档写明,一个工具由四部分构成,作为参数传给 TypeScript 的 tool() 辅助函数或 Python 的 @tool 装饰器:
- Name:唯一标识符,Claude 用它来调用;
- Description:这工具干什么,Claude 读这段来决定什么时候调;
- Input schema:Claude 必须提供的参数。TypeScript 侧永远是 Zod schema,handler 的
args会自动从中推导类型;Python 侧是一个名字到类型的 dict,比如{"latitude": float},SDK 帮你转成 JSON Schema; - Handler:Claude 调用时跑的 async 函数,拿到已校验的参数,必须返回一个对象。
第四条里那个”必须返回的对象”才是重点。文档列了三个字段:
| 字段 | 是否必填 | 语义 |
|---|---|---|
content | 必填 | 结果块数组,每个块的 type 取 "text"、"image"、"audio"、"resource"、"resource_link" 之一 |
structuredContent | 可选 | 一个 JSON 对象,把结果以机器可读形式和 content 一起返回 |
isError | 可选 | 置 true 表示这次工具调用失败,好让 Claude 据此反应 |
TypeScript 侧这套形状就是 MCP 的 CallToolResult,SDK 参考页给出的类型是:
type CallToolResult = {
content: Array<{
type: "text" | "image" | "audio" | "resource" | "resource_link";
// Additional fields vary by type
}>;
structuredContent?: Record<string, unknown>;
isError?: boolean;
};
工具定义好之后,用 createSdkMcpServer(TypeScript)或 create_sdk_mcp_server(Python)包成一个 server。文档明确说:这个 server 跑在你的应用进程内,不是单独起一个进程。
二、前置条件(这段别跳)
官方 quickstart 页写的前置条件是 Node.js 18+ 或 Python 3.10+,外加一个 Anthropic 账号。安装命令:
npm install @anthropic-ai/claude-agent-sdk
npm install --save-dev tsx
Python 侧是 pip install claude-agent-sdk(或用 uv 的 uv add claude-agent-sdk)。
Windows 侧不能照抄 macOS 那套。 文档给的 Windows 建虚拟环境写法是:
py -m venv .venv
.venv\Scripts\Activate.ps1
pip install claude-agent-sdk
文档还补了一句:如果 PowerShell 因执行策略报错拦住 Activate.ps1,先跑 Set-ExecutionPolicy -Scope Process RemoteSigned。macOS / Linux 侧则是 python3 -m venv .venv 加 source .venv/bin/activate。
还有两个容易踩的坑,文档都写了。两个 SDK 都自带一份 Claude Code 原生二进制,多数情况不用另外装。但:pip 如果装的是 Python SDK 的源码分发而非平台 wheel(文档举的例子就是 ARM64 Windows),就没有捆绑的二进制,需要单独装 Claude Code,Python SDK 会从 PATH 上找它;TypeScript SDK 的二进制是走 npm optional dependencies 装的,像 npm ci --omit=optional 这类跳过可选依赖的安装方式拿不到二进制,要么重装时别跳过,要么装原生 Claude Code 并把 pathToClaudeCodeExecutable 指到它的路径。
密钥用环境变量给,写法上一律用占位符,别把真值提交进仓库:ANTHROPIC_API_KEY=<YOUR_API_KEY>。
三、schema 怎么写,可选参数是个坑
TypeScript 侧 Zod 直给,.describe() 加的字段描述 Claude 是能看到的:
const getTemperature = tool(
"get_temperature",
"Get the current temperature at a location",
{
latitude: z.number().describe("Latitude coordinate"),
longitude: z.number().describe("Longitude coordinate")
},
async (args) => {
// ...
return {
content: [{ type: "text", text: `Temperature: ...` }]
};
}
);
Python 那个简写 dict 有个明确限制,文档写得很直白:dict schema 把每个键都当成必填。所以想做可选参数,TypeScript 是给 Zod 字段加 .default(),Python 则只能绕:把这个参数从 schema 里拿掉,在 description 字符串里说明它,然后在 handler 里用 args.get() 读。文档里 get_precipitation_chance 那个例子同时展示了这两种写法,注释就是 # 'hours' isn't in the schema - read it with .get() to make it optional。
另一处 Python 缺口:dict 简写不支持 enum。要约束取值范围、要 ranges、要可选字段或嵌套对象,Python 装饰器接受完整的 JSON Schema dict:
@tool(
"convert_units",
"Convert a value from one unit to another",
{
"type": "object",
"properties": {
"unit_type": {
"type": "string",
"enum": ["length", "temperature", "weight"],
"description": "Category of unit",
},
"value": {"type": "number", "description": "Value to convert"},
},
"required": ["unit_type", "value"],
},
)
对应的 TypeScript 写法是 z.enum(["length", "temperature", "weight"])。SDK 参考页写明 tool() 的 inputSchema 同时支持 Zod 3 和 Zod 4。
注册这一步有个命名规则要记牢:mcpServers 里的 key 会成为工具全限定名 mcp__{server_name}__{tool_name} 中的 {server_name} 段。把这个全名列进 allowedTools,工具就不走权限提示直接跑。一台 server 上挂多个工具时,可以逐个列,也可以用通配写法 mcp__weather__* 覆盖这台 server 暴露的全部工具。
四、报错:抛出去,还是自己组装
这是本篇最该记住的一条约定。文档开门见山:handler 报错不会停掉 agent loop。 SDK 的 in-process MCP server 会捕获未处理异常并转成 error result,所以你怎么报错决定的是 Claude 读到什么,而不是这次 query 会不会失败。
| 发生了什么 | 结果 |
|---|---|
| handler 抛出未捕获异常 | MCP server 把它转成 error result,携带原始异常消息,Claude 看到那条消息,agent loop 继续 |
handler 自己捕获并返回 isError: true(TS)/ "is_error": True(Python) | Claude 看到你组织的那条消息,你可以补上原始异常没有的上下文,比如哪个请求失败了、该改试什么 |
两种情况下 Claude 都能重试、换工具或解释失败。文档给的取舍标准是:当原始异常消息不足以让 Claude 据此行动时,自己捕获。
注意 TypeScript 和 Python 的字段名不一样——TS 是驼峰 isError,Python 返回 dict 里是 "is_error"。跨语言迁移时这个是高频翻车点。
文档的示例把两类失败都收进了 handler:非 200 的 HTTP 状态从 response 判出来,返回带 isError 的结果;网络错误或 JSON 解析失败由外层 try/catch(Python 的 try/except)兜住,同样返回 error result。单位转换那个例子还展示了第三类用法:输入本身不被支持时也返回 isError: true,比如找不到对应的转换对,这样 Claude 会当成失败去告诉用户,而不是把它当正常结果处理。
五、返回值的几处硬边界
content 数组能混装 text、image、audio、resource、resource_link 五类块,但各类的支持程度不一致,文档写明的差异有这么几处:
- image 块只能内联 base64,没有 URL 字段。
data要求是裸 base64,不带data:image/...;base64,前缀;mimeType必填。想返回一张网上的图,得在 handler 里 fetch 下来、读字节、自己编码。 - resource 块的
uri只是个标签。 内容本身走resource.text或resource.blob,两者只给其一。文档特意说明:像file:///tmp/report.md这样的 URI 是给 Claude 后续引用用的名字,SDK 不会去读那个路径。而resource.blob标注了 TypeScript only——Python SDK 会把二进制 resource 从工具结果里丢弃并记一条 warning。 - audio 块两边行为不同。 TypeScript 侧 SDK 把音频存到磁盘,Claude 收到的是一个含存盘路径的 text 块;Python 侧直接从结果里丢掉并记 warning。
structuredContent会顶掉文本块。 文档写明:设了structuredContent之后,Claude 收到的是这份 JSON 加上content里的 image / resource 块,content里的 text 块不再转发,理由是它们被假定与结构化数据重复。- Python 拿不到
structuredContent。 文档的 Note 说得很死:Python 的@tool装饰器只转发 handler 返回 dict 里的content和is_error。要在 Python 里返回structuredContent,得改跑独立的 MCP server,不能用 in-process 的 SDK server。
还有 annotations。它是可选元数据,TypeScript 走 tool() 的第五个参数,Python 走 @tool 的 annotations 关键字参数。文档列了四个布尔 hint 字段及其默认值:readOnlyHint(默认 false)、destructiveHint(默认 true)、idempotentHint(默认 false)、openWorldHint(默认 true)。只有 readOnlyHint 有实际效果——它控制这个工具能不能和其它只读工具并行调用;另外三个文档标的都是 informational only。
这里有条反直觉的限制值得单独拎出来:annotations 是元数据,不是强制约束。文档原话的意思是,一个标了 readOnlyHint: true 的工具,handler 里要真去写盘照样能写。SDK 参考页也提醒,这些字段都是 hint,客户端不该拿它们做安全判断。所以别指望靠 annotation 来兜权限,权限那层要用 tools / allowedTools / disallowedTools 去配。
最后一处和上下文有关:文档说 tool search 默认开启,会把 SDK MCP 工具延后加载——Claude 先在一份紧凑列表里看到工具名,需要时才拉完整 schema。关掉 tool search 的话,tools 数组里每个工具每一轮都占上下文。TypeScript 侧如果想让某个工具的完整 schema 始终留在初始 prompt 里,可以在 tool() 的 extras 参数或 createSdkMcpServer() 的 options 里传 alwaysLoad: true。参考页里 extras 还有个 searchHint,是 tool search 生效时显示在延后工具列表里的一行能力描述。
六、怎么确认配对了
文档给的验证路径是把工具定义、server 定义和调用代码放进同一个文件,然后 Python 跑 python weather.py,TypeScript 跑 npx tsx weather.ts。
想看清楚 Claude 到底调没调你的工具,文档示例的做法是遍历消息流里的 AssistantMessage,把其中的 ToolUseBlock 打出来,再打最终的 ResultMessage 文本——这样能区分它是在调工具还是在凭自己的知识作答。TypeScript 侧对应的判断是 message.type === "assistant" 里 block.type === "tool_use",最终结果看 message.type === "result" && message.subtype === "success"。
有个输出上的提醒文档也写了:因为 tool search 默认开着,输出里可能还会出现一次 ToolSearch 调用,那是 Claude 在加载延后的工具 schema,不是异常。
另外文档提到,单发式的 query() 在产出 error result 之后会抛出(Python 侧是 raise),示例里只打印 success 结果,所以失败得在外层 try 里接住再继续下一个 prompt。
以上代码片段均取自官方文档示例;若你把多个参数组合起来用,属于按官方文档中的参数语义组合的示例,未经实测,请以官方文档与 SDK 参考页的实际说明为准。
一句话收尾:content 是唯一必填项,isError 决定 Claude 读到的是你的话还是异常字符串,structuredContent 在 Python 的 in-process server 上根本发不出去。这三条记住,剩下的翻文档就够了。
本文依据 Claude Code 官方文档(code.claude.com/docs)于 2026-08-17 的公开内容整理。
该产品闭源,本文只复述官方文档写明的机制,不推断其内部实现;
我们没有对文中涉及的功能做过实测,因此不涉及界面外观、操作手感与运行速度的任何描述。
该产品迭代频繁,文中涉及的命令、配置项与默认值随版本变动,请以官方文档最新内容为准。
本文不涉及价格、额度与限流的具体数值,相关信息请以官方定价与用量说明页为准。
安全与合规相关做法请结合自身环境评估,本文不构成安全方案建议。