
Claude Agent SDK: Custom Tools with In-Process MCP
Summary
Add custom Python tools to Claude agents with one decorator. No server. No HTTP. Just code.
Most agent frameworks make tool-calling sound complex: spin up an MCP server, expose HTTP endpoints, wire up a transport, then connect a client. The Claude Agent SDK for Python flips that. You define a tool with a decorator, register it with one helper, and your agent calls Python functions directly — no separate process, no network hop.
This guide walks through building a working agent with two custom in-process tools. By the end, you will know how to expose any Python function to Claude, validate inputs with type hints, and ship the whole thing in a single file.
Why this matters now
In April 2026 the Claude Agent SDK shipped first-class support for in-process MCP via create_sdk_mcp_server(). That removed the biggest friction in agent development: managing a fleet of small tool servers. For internal tools, batch jobs, and CLIs, you should default to in-process unless you actually need cross-process sharing.
Prerequisites
- Python 3.10 or newer
- An Anthropic API key in
ANTHROPIC_API_KEY pip install claude-agent-sdk(0.6.0+)- Basic familiarity with async/await
Step 1 — Project setup
Create a fresh virtual environment and install the SDK.
python -m venv .venv
source .venv/bin/activate
pip install --upgrade claude-agent-sdk
export ANTHROPIC_API_KEY=sk-ant-...
Step 2 — Define a tool with @tool
Tools are plain async Python functions. The @tool decorator pulls metadata from your name, docstring, and an explicit input schema. Return a dict that matches the MCP content shape.
from claude_agent_sdk import tool
@tool(
"word_count",
"Count words and characters in a piece of text.",
{"text": str},
)
async def word_count(args: dict) -> dict:
text = args["text"]
return {
"content": [{
"type": "text",
"text": f"words={len(text.split())} chars={len(text)}",
}]
}
Three things to notice. First, the schema is just {"text": str} — the SDK turns that into JSON schema for you. Second, the function is async, which lets it call HTTP APIs without blocking. Third, the return shape is a list of MCP content blocks, not a raw string.
Step 3 — Add a tool with side effects
In-process tools shine when they touch your local filesystem or call internal services. Here is a tool that appends a line to a log file.
import datetime
from pathlib import Path
LOG = Path("agent.log")
@tool(
"log_event",
"Append a timestamped line to agent.log.",
{"event": str, "level": str},
)
async def log_event(args: dict) -> dict:
ts = datetime.datetime.utcnow().isoformat(timespec="seconds")
line = f"{ts} [{args.get('level','info').upper()}] {args['event']}\n"
LOG.write_text((LOG.read_text() if LOG.exists() else "") + line)
return {"content": [{"type": "text", "text": f"logged: {line.strip()}"}]}
Step 4 — Bundle tools into an SDK MCP server
create_sdk_mcp_server() wraps a list of tools into an MCP server that runs inside your process. No socket, no port, no startup script.
from claude_agent_sdk import create_sdk_mcp_server
tools_server = create_sdk_mcp_server(
name="my-tools",
version="0.1.0",
tools=[word_count, log_event],
)
Step 5 — Run the agent loop
Give the agent your server in mcp_servers, list the tool names you want it allowed to call, and stream a query. The SDK handles the agent loop, message back-and-forth, and tool dispatch.
import anyio
from claude_agent_sdk import ClaudeAgentOptions, query
async def main():
options = ClaudeAgentOptions(
mcp_servers={"local": tools_server},
allowed_tools=["mcp__local__word_count", "mcp__local__log_event"],
system_prompt="You are a precise assistant. Use tools instead of guessing.",
)
prompt = (
"Count the words in 'Custom tools should be boring and obvious.' "
"Then log the result with level=info."
)
async for msg in query(prompt=prompt, options=options):
print(msg)
anyio.run(main)
Example output
AssistantMessage(role=assistant, content=[ToolUseBlock(name=mcp__local__word_count, input={'text': 'Custom tools should be boring and obvious.'})])
ToolResultMessage(content=[{'type': 'text', 'text': 'words=7 chars=42'}])
AssistantMessage(role=assistant, content=[ToolUseBlock(name=mcp__local__log_event, input={'event': 'word_count words=7 chars=42', 'level': 'info'})])
ToolResultMessage(content=[{'type': 'text', 'text': 'logged: 2026-04-30T03:11:02 [INFO] word_count words=7 chars=42'}])
AssistantMessage(role=assistant, content=[TextBlock(text='The phrase has 7 words (42 characters). Logged.')])
Common pitfalls
- Forgetting
allowed_tools. The SDK ignores tools the agent has not been explicitly allowed to call. The naming convention ismcp__<server-name>__<tool-name>. - Returning a string instead of MCP content. Always return
{"content": [{"type": "text", "text": ...}]}; raw strings will raise a validation error. - Blocking I/O inside an async tool. Wrap CPU-heavy or sync work with
anyio.to_thread.run_syncor you will stall the loop. - Mutating shared state without locks. In-process means tools share memory with the rest of your app; treat counters, caches, and files like you would in a web handler.
Quick reference
| What you want | Use this |
|---|---|
| Define a tool | @tool(name, description, schema) |
| Bundle tools | create_sdk_mcp_server(name, version, tools) |
| Run an agent | query(prompt, options) + async for |
| Allow a tool | allowed_tools=['mcp__server__tool'] |
| Stream output | Iterate the async generator from query |
Next steps
- Add a tool that calls an internal HTTP API with
httpx.AsyncClient. - Pass
permission_mode='acceptAll'for a CI-friendly run, or'plan'to dry-run. - Combine in-process tools with a remote MCP server by adding a second entry to
mcp_servers. - Wrap the whole script in a CLI with
typerso teammates can use it without touching the code.
Custom tools used to be the boring middle of agent development. With in-process MCP, they are a one-liner. Build the smallest tool you can today, run it, and watch your agent stop hallucinating answers it can simply look up.
Comments
Be the first to comment
Found this useful?
Get new AI guides for builders by email. Free.