给 Claude Agent SDK 写自定义工具:入参 schema、返回值与报错怎么设计

2026-08-18

你已经有一套内部接口——查订单的、算库存的、读工单系统的。现在想让 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 .venvsource .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 数组能混装 textimageaudioresourceresource_link 五类块,但各类的支持程度不一致,文档写明的差异有这么几处:

  • image 块只能内联 base64,没有 URL 字段。 data 要求是裸 base64,不带 data:image/...;base64, 前缀mimeType 必填。想返回一张网上的图,得在 handler 里 fetch 下来、读字节、自己编码。
  • resource 块的 uri 只是个标签。 内容本身走 resource.textresource.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 里的 contentis_error。要在 Python 里返回 structuredContent,得改跑独立的 MCP server,不能用 in-process 的 SDK server。

还有 annotations。它是可选元数据,TypeScript 走 tool() 的第五个参数,Python 走 @toolannotations 关键字参数。文档列了四个布尔 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 的公开内容整理。 该产品闭源,本文只复述官方文档写明的机制,不推断其内部实现我们没有对文中涉及的功能做过实测,因此不涉及界面外观、操作手感与运行速度的任何描述。 该产品迭代频繁,文中涉及的命令、配置项与默认值随版本变动,请以官方文档最新内容为准。 本文不涉及价格、额度与限流的具体数值,相关信息请以官方定价与用量说明页为准。

安全与合规相关做法请结合自身环境评估,本文不构成安全方案建议。

想系统学会用 AI?报名体系课或加入会员,照着学、照着用。