Skip to content

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

GoalHow
Define a tooltool() in TypeScript or @tool in Python: name, description, schema, handler
Make an argument optionalMark it optional in the schema and default it in the handler
Register toolsWrap them with createSdkMcpServer / create_sdk_mcp_server and pass the server in mcpServers
Run without promptingAdd mcp__<server>__<tool> (or mcp__<server>__*) to allowedTools
Hide built-ins Claude does not needPass tools listing only the built-ins you want, or [] for none
Allow parallel callsSet readOnlyHint: true on side-effect-free tools
Control the error text Claude seesCatch errors and return isError: true with your own message
Return images or filesUse image or resource blocks in content
Return machine-readable dataSet structuredContent (TypeScript in-process servers)
Scale to many toolsRely on tool search to load schemas on demand

Anatomy of a tool

Every tool has four parts:

  1. Name. A unique identifier Claude uses to call it.
  2. 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.
  3. Input schema.
    • TypeScript: a Zod schema. The handler's args are 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 in Annotated (for example Annotated[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.
  4. Handler. An async function that receives validated arguments and returns:
    • content (required): a list of blocks whose type is "text", "image", "audio", "resource" or "resource_link";
    • structuredContent (optional): the result as a JSON object;
    • isError (optional): true to 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 with args.get(). (A TypedDict class 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.

HintDefaultEffect
readOnlyHintfalseTool does not change anything. Lets it run in parallel with other read-only calls.
destructiveHinttrueMay make destructive changes. Informational.
idempotentHintfalseRepeating a call with the same arguments has no further effect. Informational.
openWorldHinttrueReaches 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?).

OptionLayerEffect
tools: ["Read", "Grep"]AvailabilityOnly these built-ins are present. MCP tools are unaffected.
tools: []AvailabilityNo built-ins at all; only your MCP tools
allowedToolsPermissionListed tools run without prompting; others go through the permission flow
disallowedTools with a bare name, such as "Bash"AvailabilityRemoves the tool from context, same as leaving it out of tools
disallowedTools with a scoped rule, such as "Bash(rm *)"PermissionTool 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 doesWhat Claude sees
ThrowsThe 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 resourceLinks on the user message's tool_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.

FieldValue
type"image"
dataRaw base64, without a data:image/...;base64, prefix
mimeTypeRequired, 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.

FieldNotes
type"resource"
resource.uriAny URI scheme; a label, not something the SDK reads
resource.textText content (use this or blob)
resource.blobBase64 binary content. TypeScript only; Python drops binary resources with a warning.
resource.mimeTypeOptional
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 @tool decorator only forwards content and is_error. To return structuredContent from 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.