Give Claude custom tools
> ## Documentation Index > Fetch the complete documentation index at: https://code.claude.com/docs/llms.txt > Use this file to discover all available pages before exploring further.
Overview
Documentation Index
Fetch the complete documentation index at: https://code.claude.com/docs/llms.txt Use this file to discover all available pages before exploring further.
Give Claude custom tools
Define custom tools with the Claude Agent SDK's in-process MCP server so Claude can call your functions, hit your APIs, and perform domain-specific operations.
Custom tools extend the Agent SDK by letting you define your own functions that Claude can call during a conversation. Using the SDK's in-process MCP server, you can give Claude access to databases, external APIs, domain-specific logic, or any other capability your application needs.
This guide covers how to define tools with input schemas and handlers, bundle them into an MCP server, pass them to query, and control which tools Claude can access. It also covers error handling, tool annotations, and returning non-text content like images.
Quick reference
| If you want to... | Do this |
|---|---|
| Define a tool | Use @tool (Python) or tool() (TypeScript) with a name, description, schema, and handler. See Create a custom tool. |
| Register a tool with Claude | Wrap in create_sdk_mcp_server / createSdkMcpServer and pass to mcpServers in query(). See Call a custom tool. |
| Pre-approve a tool | Add to your allowed tools. See Configure allowed tools. |
| Remove a built-in tool from Claude's context | Pass a tools array listing only the built-ins you want. See Configure allowed tools. |
| Let Claude call tools in parallel | Set readOnlyHint: true on tools with no side effects. See Add tool annotations. |
| Handle errors without stopping the loop | Return isError: true instead of throwing. See Handle errors. |
| Return images or files | Use image or resource blocks in the content array. See Return images and resources. |
| Scale to many tools | Use tool search to load tools on demand. |
Create a custom tool
A tool is defined by four parts, passed as arguments to the tool() helper in TypeScript or the @tool decorator in Python:
- Name: a unique identifier Claude uses to call the tool.
- Description: what the tool does. Claude reads this to decide when to call it.
- Input schema: the arguments Claude must provide. In TypeScript this is always a Zod schema, and the handler's
argsare typed from it automatically. In Python this is a dict mapping names to types, like{"latitude": float}, which the SDK converts to JSON Schema for you. The Python decorator also accepts a full JSON Schema dict directly when you need enums, ranges, optional fields, or nested objects. - Handler: the async function that runs when Claude calls the tool. It receives the validated arguments and must return an object with:
content(required): an array of result blocks, each with atypeof"text","image", or"resource". See Return images and resources for non-text blocks.isError(optional): set totrueto signal a tool failure so Claude can react to it. See Handle errors.
After defining a tool, wrap it in a server with createSdkMcpServer (TypeScript) or create_sdk_mcp_server (Python). The server runs in-process inside your application, not as a separate process.
Weather tool example
This example defines a get_temperature tool and wraps it in an MCP server. It only sets up the tool; to pass it to query and run it, see Call a custom tool below.
from typing import Any
from claude_agent_sdk import tool, create_sdk_mcp_server
# Define a tool: name, description, input schema, handler
@tool(
"get_temperature",
"Get the current temperature at a location",
{"latitude": float, "longitude": float},
)
async def get_temperature(args: dict[str, Any]) -> dict[str, Any]:
async with httpx.AsyncClient() as client:
response = await client.get(
"https://api.open-meteo.com/v1/forecast",
params={
"latitude": args["latitude"],
"longitude": args["longitude"],
"current": "temperature_2m",
"temperature_unit": "fahrenheit",
},
)
data = response.json()
# Return a content array - Claude sees this as the tool result
return {
"content": [
{
"type": "text",
"text": f"Temperature: {data['current']['temperature_2m']}°F",
}
]
}
# Wrap the tool in an in-process MCP server
weather_server = create_sdk_mcp_server(
name="weather",
version="1.0.0",
tools=[get_temperature],
)
// Define a tool: name, description, input schema, handler
const getTemperature = tool(
"get_temperature",
"Get the current temperature at a location",
{
latitude: z.number().describe("Latitude coordinate"), // .describe() adds a field description Claude sees
longitude: z.number().describe("Longitude coordinate")
},
async (args) => {
// args is typed from the schema: { latitude: number; longitude: number }
const response = await fetch(
`https://api.open-meteo.com/v1/forecast?latitude=${args.latitude}&longitude=${args.longitude}¤t=temperature_2m&temperature_unit=fahrenheit`
);
const data: any = await response.json();
// Return a content array - Claude sees this as the tool result
return {
content: [{ type: "text", text: `Temperature: ${data.current.temperature_2m}°F` }]
};
}
);
// Wrap the tool in an in-process MCP server
const weatherServer = createSdkMcpServer({
name: "weather",
version: "1.0.0",
tools: [getTemperature]
});
See the tool() TypeScript reference or the @tool Python reference for full parameter details, including JSON Schema input formats and return value structure.
To make a parameter optional: in TypeScript, add .default() to the Zod field. In Python, the dict schema treats every key as required, so leave the parameter out of the schema, mention it in the description string, and read it with args.get() in the handler. The get_precipitation_chance tool below shows both patterns.
Call a custom tool
Pass the MCP server you created to query via the mcpServers option. The key in mcpServers becomes the {server_name} segment in each tool's fully qualified name: mcp__{server_name}__{tool_name}. List that name in allowedTools so the tool runs without a permission prompt.
These snippets reuse the weatherServer from the example above to ask Claude what the weather is in a specific location.
from claude_agent_sdk import query, ClaudeAgentOptions, ResultMessage
async def main():
options = ClaudeAgentOptions(
mcp_servers={"weather": weather_server},
allowed_tools=["mcp__weather__get_temperature"],
)
async for message in query(
prompt="What's the temperature in San Francisco?",
options=options,
):
# ResultMessage is the final message after all tool calls complete
if isinstance(message, ResultMessage) and message.subtype == "success":
print(message.result)
asyncio.run(main())
for await (const message of query({
prompt: "What's the temperature in San Francisco?",
options: {
mcpServers: { weather: weatherServer },
allowedTools: ["mcp__weather__get_temperature"]
}
})) {
// "result" is the final message after all tool calls complete
if (message.type === "result" && message.subtype === "success") {
console.log(message.result);
}
}
Add more tools
A server holds as many tools as you list in its tools array. With more than one tool on a server, you can list each one in allowedTools individually or use the wildcard mcp__weather__* to cover every tool the server exposes.
The example below adds a second tool, get_precipitation_chance, to the weatherServer from the weather tool example and rebuilds it with both tools in the array.
# Define a second tool for the same server
@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: dict[str, Any]) -> dict[str, Any]:
# 'hours' isn't in the schema - read it with .get() to make it optional
hours = args.get("hours", 12)
async with httpx.AsyncClient() as client:
response = await client.get(
"https://api.open-meteo.com/v1/forecast",
params={
"latitude": args["latitude"],
"longitude": args["longitude"],
"hourly": "precipitation_probability",
"forecast_days": 1,
},
)
data = response.json()
chances = data["hourly"]["precipitation_probability"][:hours]
return {
"content": [
{
"type": "text",
"text": f"Next {hours} hours: {'%, '.join(map(str, chances))}%",
}
]
}
# Rebuild the server with both tools in the array
weather_server = create_sdk_mcp_server(
name="weather",
version="1.0.0",
tools=[get_temperature, get_precipitation_chance],
)
// Define a second tool for the same server
const getPrecipitationChance = tool(
"get_precipitation_chance",
"Get the hourly precipitation probability for a location",
{
latitude: z.number(),
longitude: z.number(),
hours: z
.number()
.int()
.min(1)
.max(24)
.default(12) // .default() makes the parameter optional
.describe("How many hours of forecast to return")
},
async (args) => {
const response = await fetch(
`https://api.open-meteo.com/v1/forecast?latitude=${args.latitude}&longitude=${args.longitude}&hourly=precipitation_probability&forecast_days=1`
);
const data: any = await response.json();
const chances = data.hourly.precipitation_probability.slice(0, args.hours);
return {
content: [{ type: "text", text: `Next ${args.hours} hours: ${chances.join("%, ")}%` }]
};
}
);
// Rebuild the server with both tools in the array
const weatherServer = createSdkMcpServer({
name: "weather",
version: "1.0.0",
tools: [getTemperature, getPrecipitationChance]
});
Every tool in this array consumes context window space on every turn. If you're defining dozens of tools, see tool search to load them on demand instead.
Add tool annotations
Tool annotations are optional metadata describing how a tool behaves. Pass them as the fifth argument to tool() helper in TypeScript or via the annotations keyword argument for the @tool decorator in Python. All hint fields are Booleans.
| Field | Default | Meaning |
|---|---|---|
readOnlyHint | false | Tool does not modify its environment. Controls whether the tool can be called in parallel with other read-only tools. |
destructiveHint | true | Tool may perform destructive updates. Informational only. |
idempotentHint | false | Repeated calls with the same arguments have no additional effect. Informational only. |
openWorldHint | true | Tool reaches systems outside your process. Informational only. |
Annotations are metadata, not enforcement. A tool marked readOnlyHint: true can still write to disk if that's what the handler does. Keep the annotation accurate to the handler.
This example adds readOnlyHint to the get_temperature tool from the weather tool example.
from claude_agent_sdk import tool, ToolAnnotations
@tool