
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