Custom tools
Give an Agent SDK agent your own functions as tools using the in-process MCP server, with schemas, annotations, error handling and rich results.
Built-in tools cover files, shells and the web. Everything specific to your business (looking up an order, checking stock, raising a ticket) needs a custom tool. In the Agent SDK you write a normal async function, describe its inputs, and register it on an MCP server that runs inside your own process. Claude can then call it like any other tool.
Cheat sheet
| Goal | How |
|---|---|
| Define a tool | tool() in TypeScript or @tool in Python: name, description, schema, handler |
| Make an argument optional | Mark it optional in the schema and default it in the handler |
| Register tools | Wrap them with createSdkMcpServer / create_sdk_mcp_server and pass the server in mcpServers |
| Run without prompting | Add mcp__<server>__<tool> (or mcp__<server>__*) to allowedTools |
| Hide built-ins Claude does not need | Pass tools listing only the built-ins you want, or [] for none |
| Allow parallel calls | Set readOnlyHint: true on side-effect-free tools |
| Control the error text Claude sees | Catch errors and return isError: true with your own message |
| Return images or files | Use image or resource blocks in content |
| Return machine-readable data | Set structuredContent (TypeScript in-process servers) |
| Scale to many tools | Rely on tool search to load schemas on demand |
Anatomy of a tool
Every tool has four parts:
- Name. A unique identifier Claude uses to call it.
- Description. What it does and when to use it. Claude reads this to decide whether to call the tool, so it matters more than people expect.
- Input schema.
- TypeScript: a Zod schema. The handler's
argsare typed from it, and.describe()on a field gives Claude a per-field description. - Python: a dict of names to types such as
{"order_id": str}, converted to JSON Schema for you. Wrap a type inAnnotated(for exampleAnnotated[str, "Order reference such as ORD-1042"]) to add a description. For enums, ranges, optional fields or nesting, pass a full JSON Schema dict instead.
- TypeScript: a Zod schema. The handler's
- Handler. An async function that receives validated arguments and returns:
content(required): a list of blocks whosetypeis"text","image","audio","resource"or"resource_link";structuredContent(optional): the result as a JSON object;isError(optional):trueto report a failure.
Tools are then grouped on a server built with createSdkMcpServer (TypeScript) or create_sdk_mcp_server (Python). The server runs in your process, not as a separate program.
A first tool
An order lookup against an internal API. The Python version uses httpx (uv add httpx or pip install httpx).
import { tool, createSdkMcpServer } from "@anthropic-ai/claude-agent-sdk";
import { z } from "zod";
const getOrder = tool(
"get_order",
"Look up a customer order by its reference and return status, items and delivery date",
{
reference: z.string().describe("Order reference, for example ORD-1042"),
},
async ({ reference }) => {
const res = await fetch(`https://orders.internal.example/api/orders/${encodeURIComponent(reference)}`);
const order = await res.json();
return {
content: [{ type: "text", text: `${order.reference}: ${order.status}, due ${order.delivery_date}, ${order.items.length} items` }],
};
},
);
export const shop = createSdkMcpServer({ name: "shop", version: "1.0.0", tools: [getOrder] });
from typing import Annotated, Any
import httpx
from claude_agent_sdk import tool, create_sdk_mcp_server
@tool(
"get_order",
"Look up a customer order by its reference and return status, items and delivery date",
{"reference": Annotated[str, "Order reference, for example ORD-1042"]},
)
async def get_order(args: dict[str, Any]) -> dict[str, Any]:
async with httpx.AsyncClient() as client:
r = await client.get(f"https://orders.internal.example/api/orders/{args['reference']}")
order = r.json()
return {"content": [{"type": "text",
"text": f"{order['reference']}: {order['status']}, due {order['delivery_date']}"}]}
shop = create_sdk_mcp_server(name="shop", version="1.0.0", tools=[get_order])
Calling it
Pass the server in mcpServers. The key you use becomes the middle part of every tool's full name: mcp__<key>__<tool>. Add that name to allowedTools so it runs without a permission prompt.
import { query } from "@anthropic-ai/claude-agent-sdk";
import { shop } from "./shop-tools";
for await (const msg of query({
prompt: "Where is order ORD-1042 and when will it arrive?",
options: {
mcpServers: { shop },
allowedTools: ["mcp__shop__get_order"],
},
})) {
if (msg.type === "result" && msg.subtype === "success") console.log(msg.result);
}
opts = ClaudeAgentOptions(mcp_servers={"shop": shop}, allowed_tools=["mcp__shop__get_order"])
async for msg in query(prompt="Where is order ORD-1042 and when will it arrive?", options=opts):
if isinstance(msg, ResultMessage) and msg.subtype == "success":
print(msg.result)
Run it with npx tsx orders.ts, uv run orders.py or python orders.py. Claude calls get_order and answers in a sentence.
Optional arguments and more tools
A server holds as many tools as you list. Allow them one by one, or all at once with mcp__shop__*.
To make an argument optional:
- TypeScript: add
.optional()to the Zod field and apply the default in the handler. - Python: the simple dict form treats every key as required, so use full JSON Schema, leave the field out of
required, and read it withargs.get(). (ATypedDictclass is another option; see the Python reference.)
const listRecentOrders = tool(
"list_recent_orders",
"List a customer's most recent orders",
{
customer_id: z.string(),
limit: z.number().int().min(1).max(50).optional().describe("How many orders to return (default 10)"),
},
async ({ customer_id, limit }) => {
const n = limit ?? 10;
const orders = await ordersApi.recent(customer_id, n);
return { content: [{ type: "text", text: orders.map((o) => `${o.reference} ${o.status}`).join("\n") }] };
},
);
const shop = createSdkMcpServer({ name: "shop", version: "1.0.0", tools: [getOrder, listRecentOrders] });
@tool(
"list_recent_orders",
"List a customer's most recent orders",
{
"type": "object",
"properties": {
"customer_id": {"type": "string"},
"limit": {"type": "integer", "minimum": 1, "maximum": 50,
"description": "How many orders to return (default 10)"},
},
"required": ["customer_id"],
},
)
async def list_recent_orders(args):
n = args.get("limit", 10)
...
Tool search is on by default and defers SDK MCP tools: Claude sees a compact list of names and loads a tool's full schema when it needs it. With tool search off, every tool's schema is sent on every turn. In TypeScript, pass alwaysLoad: true in the extras argument of tool() (or in the createSdkMcpServer() options) to keep a schema in the initial prompt regardless.
Annotations
MCP tool annotations describe how a tool behaves. Pass them as the fifth argument to tool() in TypeScript, or with annotations=ToolAnnotations(...) on the Python decorator. All are booleans.
| Hint | Default | Effect |
|---|---|---|
readOnlyHint | false | Tool does not change anything. Lets it run in parallel with other read-only calls. |
destructiveHint | true | May make destructive changes. Informational. |
idempotentHint | false | Repeating a call with the same arguments has no further effect. Informational. |
openWorldHint | true | Reaches systems outside your process. Informational. |
Annotations are promises, not enforcement. A tool marked read-only can still write if its handler does, so keep them honest.
tool("get_order", "Look up an order by reference", { reference: z.string() }, handler, {
annotations: { readOnlyHint: true, openWorldHint: false },
});
from claude_agent_sdk import tool, ToolAnnotations
@tool("get_order", "Look up an order by reference", {"reference": str},
annotations=ToolAnnotations(readOnlyHint=True))
async def get_order(args): ...
Controlling which tools Claude sees
There are two separate layers: availability (is the tool in Claude's context at all?) and permission (is a call approved once attempted?).
| Option | Layer | Effect |
|---|---|---|
tools: ["Read", "Grep"] | Availability | Only these built-ins are present. MCP tools are unaffected. |
tools: [] | Availability | No built-ins at all; only your MCP tools |
allowedTools | Permission | Listed tools run without prompting; others go through the permission flow |
disallowedTools with a bare name, such as "Bash" | Availability | Removes the tool from context, same as leaving it out of tools |
disallowedTools with a scoped rule, such as "Bash(rm *)" | Permission | Tool stays visible; matching calls are denied |
To truly remove a built-in, leave it out of tools or list its bare name in disallowedTools. A scoped deny keeps the tool visible, so Claude may waste a turn trying it. Naming one of the task-tracking tools in allowedTools also opts the session into them.
For a focused support bot I usually go all the way:
options: {
mcpServers: { shop },
tools: [],
allowedTools: ["mcp__shop__*"],
}
Errors
An exception in your handler does not end the run. The in-process server catches it and returns it to Claude as an error result. The only question is what Claude gets to read:
| What your handler does | What Claude sees |
|---|---|
| Throws | The raw exception message |
Catches and returns isError: true (Python: "is_error": True) | Your message, with whatever context you add |
Either way Claude can retry, try something else, or explain the problem. Catching is worth it when the raw message would not help Claude decide what to do.
@tool("get_order", "Look up an order by reference", {"reference": str})
async def get_order(args):
try:
async with httpx.AsyncClient(timeout=10) as client:
r = await client.get(f"https://orders.internal.example/api/orders/{args['reference']}")
if r.status_code == 404:
return {"content": [{"type": "text",
"text": f"No order {args['reference']}. References look like ORD-1234; ask the customer to check."}],
"is_error": True}
r.raise_for_status()
return {"content": [{"type": "text", "text": r.text}]}
except httpx.HTTPError as exc:
return {"content": [{"type": "text", "text": f"Order service unavailable ({exc}). Try again shortly."}],
"is_error": True}
Images, files and other content
The content list can mix text, image, audio, resource and resource_link blocks.
- Audio: TypeScript saves audio blocks to disk and Claude gets a text block with the path. Python drops them and logs a warning.
- Resource links: Claude receives each as text containing its name, URI and description. In TypeScript your app also gets the links as
resourceLinkson the user message'stool_use_result; Python flattens them to text first, so that key is never produced for in-process tools.
Images
Images travel inline as base64; there is no URL field. If the image lives at a URL, fetch it and encode the bytes. PNG, JPEG, GIF and WebP reach Claude as visual input; other types are saved to disk and Claude gets the path as text.
| Field | Value |
|---|---|
type | "image" |
data | Raw base64, without a data:image/...;base64, prefix |
mimeType | Required, for example image/png |
tool(
"product_photo",
"Fetch the catalogue photo for a product SKU",
{ sku: z.string() },
async ({ sku }) => {
const res = await fetch(`https://cdn.internal.example/products/${sku}.jpg`);
const bytes = Buffer.from(await res.arrayBuffer());
return { content: [{ type: "image", data: bytes.toString("base64"), mimeType: "image/jpeg" }] };
},
);
Resources
A resource block carries content identified by a URI, with the content itself inline. Use it for generated files or records from another system.
| Field | Notes |
|---|---|
type | "resource" |
resource.uri | Any URI scheme; a label, not something the SDK reads |
resource.text | Text content (use this or blob) |
resource.blob | Base64 binary content. TypeScript only; Python drops binary resources with a warning. |
resource.mimeType | Optional |
return {"content": [{
"type": "resource",
"resource": {"uri": "report://returns/2026-09", "mimeType": "text/csv",
"text": "sku,returns\nMUG-01,14\nTEE-RED-M,9\n"},
}]}
These shapes come from the MCP CallToolResult type.
Structured data
structuredContent is a JSON object returned alongside content. When it is set, Claude receives the JSON plus any image or resource blocks, but not the text blocks, which are assumed to duplicate it.
return {
content: [{ type: "image", data: chartPng.toString("base64"), mimeType: "image/png" }],
structuredContent: { metric: "weekly_returns", unit: "items", values: [41, 37, 52, 29] },
};
Note: The Python
@tooldecorator only forwardscontentandis_error. To returnstructuredContentfrom Python, run a standalone MCP server and connect it as described in MCP in the SDK.
Worked example: a VAT calculator
This tool shows two patterns at once: an enum-constrained argument, and returning isError for input it cannot handle. UK VAT has a few rates, which makes a tidy example.
import { tool, createSdkMcpServer, query } from "@anthropic-ai/claude-agent-sdk";
import { z } from "zod";
const RATES = { standard: 0.2, reduced: 0.05, zero: 0 } as const;
const vat = tool(
"calculate_vat",
"Add or remove UK VAT from an amount in pounds",
{
rate: z.enum(["standard", "reduced", "zero"]).describe("VAT rate band"),
direction: z.enum(["add", "remove"]).describe("add VAT to a net amount, or remove it from a gross amount"),
amount: z.number().describe("Amount in pounds"),
},
async ({ rate, direction, amount }) => {
if (amount < 0) {
return { content: [{ type: "text", text: "Amount must not be negative." }], isError: true };
}
const r = RATES[rate];
const result = direction === "add" ? amount * (1 + r) : amount / (1 + r);
return { content: [{ type: "text", text: `£${amount.toFixed(2)} -> £${result.toFixed(2)} (${rate}, ${direction})` }] };
},
{ annotations: { readOnlyHint: true, openWorldHint: false } },
);
const finance = createSdkMcpServer({ name: "finance", version: "1.0.0", tools: [vat] });
for (const prompt of ["What is £480 plus standard VAT?", "Strip VAT from a £63 invoice for children's car seats"]) {
try {
for await (const msg of query({ prompt, options: { mcpServers: { finance }, allowedTools: ["mcp__finance__*"] } })) {
if (msg.type === "assistant") {
for (const b of msg.message.content) if (b.type === "tool_use") console.log("[tool]", b.name, b.input);
} else if (msg.type === "result" && msg.subtype === "success") {
console.log(msg.result);
}
}
} catch (err) {
console.error(`Prompt failed: ${err}`);
}
}
In Python, the enum needs the full JSON Schema form because the simple dict schema has no way to express it. Because tool search is on by default, you may also see a ToolSearch call in the output as Claude loads the deferred schema.
Where next
One server can mix database tools, API wrappers and renderers. As it grows, lean on tool search. To connect existing MCP servers (GitHub, databases, browsers) rather than writing your own, see MCP in the SDK, and to decide what runs without approval see SDK permissions.