Custom tools with the Claude Agent SDK
AI · Aug 2026 · 16 min read
The built-in tools cover files and shell. Everything specific to your business — issue trackers, deploy APIs, internal databases — you supply as an in-process MCP server.
The built-in toolset is deliberately generic: read files, write files, run commands, search the web. The moment you want an agent that can look up a customer, open a ticket, or trigger a deploy, you are writing tools yourself.
The SDK's answer is an in-process MCP server. Despite the name, there is no subprocess and no socket — create_sdk_mcp_server builds an object that lives inside your Python process and speaks the tool protocol. Your handler is an ordinary async function.
Step 1: define a tool
The @tool decorator takes a name, a description, and an input schema. The description is not documentation — it is the entire basis on which the model decides whether to call this thing, so write it as a trigger condition rather than a summary.
from typing import Any
from claude_agent_sdk import tool, create_sdk_mcp_server
@tool(
'lookup_order',
'Look up an order by its id. Call this whenever the user mentions an '
'order number, asks about delivery status, or references a purchase.',
{'order_id': str},
)
async def lookup_order(args: dict[str, Any]) -> dict[str, Any]:
order = await db.orders.find(args['order_id'])
if order is None:
return {'content': [{'type': 'text',
'text': f"No order {args['order_id']}."}]}
return {'content': [{'type': 'text', 'text': order.as_summary()}]}
orders = create_sdk_mcp_server(
name='orders', version='1.0.0', tools=[lookup_order],
)
Write descriptions as triggers: "Look up an order" states what the tool does. "Call this whenever the user mentions an order number" states when to reach for it — and that phrasing measurably raises the should-call rate, particularly on models that are conservative about tool use by default.
Step 2: register it, and name it correctly
This is where most first attempts fail. Registering the server is not enough: tools are addressed by a three-part name, and allowed_tools needs that full name or every call falls through to a permission prompt.
options = ClaudeAgentOptions(
mcp_servers={'orders': orders},
allowed_tools=[
'mcp__orders__lookup_order', # mcp__<server key>__<tool name>
'Read', 'Grep',
],
)
# mcp__orders__lookup_order
# ^^^ ^^^^^^ ^^^^^^^^^^^^
# prefix | '- the name in @tool(...)
# '- the KEY in mcp_servers, not name= in create_sdk_mcp_server
# double underscores throughout. mcp_orders_lookup_order silently
# matches nothing.
The key wins, not the name: If create_sdk_mcp_server(name='orders') is registered as mcp_servers={'shop': orders}, the tool is mcp__shop__lookup_order. The dict key is what addresses it. Keeping the two identical is the cheapest way to never think about this again.
Step 3: design the schema for a model, not a form
Tool schemas are the contract, and the failure mode is not a validation error — it is the model guessing plausibly at a field you left ambiguous. A few rules eliminate most of that.
Prefer enums to free strings. status: 'active' | 'cancelled' cannot be invented; a bare string can. Split compound arguments. One query string that means four different things produces four different interpretations. Name the unit in the field name — timeout_seconds, not timeout. The model has no other way to know. Keep required fields genuinely required. Marking everything required forces the model to fabricate values it does not have.
Step 4: return errors as results
The instinct to raise on a bad input is wrong here. An exception out of a handler is an infrastructure failure; a returned message is something the agent can read, understand, and route around. Almost everything you would raise for is better returned.
@tool('deploy', 'Deploy a service to an environment.',
{'service': str, 'environment': str})
async def deploy(args):
if args['environment'] not in ('staging', 'production'):
return {'content': [{'type': 'text', 'text':
f"Unknown environment {args['environment']!r}. "
'Valid values: staging, production.'}]}
# the agent reads this and retries correctly.
if args['environment'] == 'production' and not approved():
return {'content': [{'type': 'text', 'text':
'Production deploys require an approved change record. '
'Ask the user to approve CR-#### first.'}]}
return {'content': [{'type': 'text', 'text': await run_deploy(**args)}]}
Note what the second branch is doing. The tool refuses, explains the rule, and tells the agent what would unblock it. That is a policy the model cannot argue its way past, expressed in the one place that is not part of the prompt — and therefore not subject to prompt injection.
Step 5: keep credentials out of the agent
The single best property of this design is where the secrets live. Your handler runs in your process, with your environment, holding your credentials. The agent sees a tool name and a text result and never touches the key.
# right: the token stays in the closure, the agent sees a summary
@tool('open_ticket', 'File a bug in the tracker.', {'title': str, 'body': str})
async def open_ticket(args):
issue = await tracker.create(**args, token=TRACKER_TOKEN)
return {'content': [{'type': 'text', 'text': f'Filed {issue.key}'}]}
# wrong: a Bash tool plus a token in the environment
# allowed_tools=['Bash'] + TRACKER_TOKEN in env
# now every command the agent writes can read the token,
# and anything that can influence the prompt can write a command.
Never put a secret in the prompt: Not in the system prompt, not in a user message, not "just for testing". Prompts are persisted in session transcripts and replayed into later context; a key placed there is durably recoverable long after you have forgotten it was there.
Step 6: promote actions out of Bash on purpose
There is a real design decision hiding in this. Bash gives the agent enormous reach for one tool definition — and hands your process an opaque command string, identical in shape whether it is listing a directory or dropping a table.
A dedicated tool gives you a typed call you can gate, log, rate-limit, or render in a UI. So the rule of thumb runs: start with Bash for breadth, then promote an action to its own tool the moment you need to do something specific when it happens. Irreversibility is the usual trigger — anything that sends, charges, deletes, or deploys.
when to promote Signal Example Why a dedicated tool Hard to reverse send_email, refund You can gate it; you cannot gate a curl Needs an invariant edit with staleness check Bash cannot enforce read-before-write Needs rendering ask_user Show a modal, not a shell line Parallel-safe search The harness can batch what it can identify
Testing
Handlers are plain async functions, so the tools themselves test like any other code — call them with a dict, assert on the returned content. What that does not cover is whether the model actually calls them, which is the failure you will actually hit.
Keep a handful of prompts that should each trigger a specific tool, run them against a recording harness, and assert on the ToolUseBlock names in the stream. It catches the case where a description edit quietly stops a tool from ever firing — which is invisible in unit tests and obvious in production.
References
Takeaways
Custom tools are an in-process MCP server — no subprocess, just async functions. Address them as mcp__<mcp_servers key>__<tool name>; the dict key wins over the server's own name. Return errors as text results, not exceptions — the agent can read a result and retry. Keep credentials in your handler, never in the prompt or an environment a Bash tool can read.
All notes · Shehzad Aslam