The earlier MCP articles built servers that expose tools and a client that calls those tools by hand: you name the tool, you supply the arguments. An agent is the piece that makes those decisions for you. Given a question and a set of MCP tools, it asks a model which tool to call, runs it, feeds the result back, and repeats until the model has an answer.
This article builds that agent with the Anthropic Python SDK
and FastMCP, on macOS with uv, make, and pytest.
The loop is the standard Anthropic tool-use loop; the only twist is that the
tools come from an MCP server instead of being defined inline.
One practical detail shapes the whole design: the live agent calls the Claude API, which costs tokens and needs a key. So the agent takes its Anthropic client as an argument rather than constructing one. The test suite passes a fake model that returns scripted tool calls, which drives the real server and the real loop with no key and no network. You can build and verify the entire project for free, then spend a few cents running it against Claude at the end.
What you will build
demo_server.py: a small MCP server with three deterministic tools.agent.py: connect to the server, convert its MCP tools into Anthropic tool definitions, and run the tool-use loop until the model produces an answer.- A
pytestsuite that drives the real server and loop with a fake model, and proves each tool result is fed back to the model. maketargets to install, run the agent live, and test.
Prerequisites
- macOS 13+ with Homebrew (brew.sh).
- uv 0.5+ —
brew install uv; verify withuv --version. - Xcode Command Line Tools (
xcode-select --install) formake. - An Anthropic API key for the live run only, from console.anthropic.com. The test suite needs no key.
- Basic familiarity with
async/await.
Everything runs through uv and make. Only the final make chat step reaches
the network.
Step 1: Scaffold and lock down hygiene
Create the workspace and its .gitignore first, before any other file, so a
stray .env holding your API key can never be committed.
Create the files
mkdir -p agent-mcp-server-macos/tests
cd agent-mcp-server-macos
echo '3.10' > .python-version
touch tests/__init__.py
touch .gitignore
Add the code: .gitignore
# Python
__pycache__/
*.py[cod]
.venv/
# uv
.uv/
# Secrets
.env
# Tooling caches
.pytest_cache/
.ruff_cache/
.mypy_cache/
# OS / editor noise
.DS_Store
*.log
Detailed breakdown
.envis listed explicitly. The agent readsANTHROPIC_API_KEYfrom the environment, and during development that key often lives in a.envfile; this keeps it out of git..venv/and the tool caches (.pytest_cache/, etc.) are build artifacts thatuvandpytestregenerate, so they are ignored rather than committed..python-versionpins the interpreteruvuses for this project. The emptytests/__init__.pymarks the test directory as a package.
Step 2: The MCP server
The agent needs tools to call. This server exposes three, kept trivial on
purpose: add and multiply let the model chain one tool’s output into the
next, and word_count gives it a non-numeric option so tool selection is a
real decision, not a foregone conclusion.
Create the file
touch demo_server.py
Add the code: demo_server.py
"""A small MCP server the agent drives in this tutorial.
The tools are deliberately trivial and deterministic so the agent's behavior is
easy to follow: the model has to choose which tool to call and, for a
multi-step prompt, chain one tool's output into the next.
"""
from fastmcp import FastMCP
mcp = FastMCP("Calculator Server")
@mcp.tool
def add(a: int, b: int) -> int:
"""Add two integers and return the sum."""
return a + b
@mcp.tool
def multiply(a: int, b: int) -> int:
"""Multiply two integers and return the product."""
return a * b
@mcp.tool
def word_count(text: str) -> int:
"""Count the number of whitespace-separated words in a string."""
return len(text.split())
if __name__ == "__main__":
# Run over stdio so the agent can launch this file as a subprocess.
mcp.run()
Detailed breakdown
- Each
@mcp.toolfunction becomes an MCP tool. FastMCP builds the tool’s JSON schema from the type hints (a: int,text: str) and uses the docstring as the tool’s description. The model reads both to decide when and how to call it, so the hints and docstrings are load-bearing, not decoration. - The tools return plain Python values. FastMCP serializes them for the wire; the agent turns them back into text before returning them to the model.
mcp.run()under__main__starts the server over stdio. That is what lets the agent launch this file as a subprocess and talk to it over the process’s standard input and output — no ports, no network. The same file is also imported directly by the tests, which use FastMCP’s in-memory transport instead of a subprocess.
Step 3: Declare dependencies and install
The project needs two runtime packages, anthropic and fastmcp, and two dev
packages for the async test suite.
Create the file
touch pyproject.toml README.md
Add the code: pyproject.toml
[project]
name = "agent-mcp-server-macos"
version = "0.1.0"
description = "A Claude agent that calls tools from an MCP server"
readme = "README.md"
requires-python = ">=3.10"
dependencies = [
"anthropic>=0.40.0",
"fastmcp>=3.4.2",
]
[dependency-groups]
dev = [
"pytest>=9.1.1",
"pytest-asyncio>=1.4.0",
]
Detailed breakdown
anthropicis the official SDK for the Claude API;fastmcpprovides both the server decorator from Step 2 and the client the agent uses to reach it.pytest-asyncioruns theasync deftests. It is configured in Step 6 to pick up async tests automatically.readme = "README.md"points at a file that must exist foruvto build the project, which is why the scaffold command creates an emptyREADME.mdalongsidepyproject.toml.
Install everything:
uv sync
uv creates a .venv, resolves the dependencies, and writes a uv.lock so the
versions are reproducible. This tutorial was validated against anthropic
0.116.0 and fastmcp 3.4.3; the lower bounds above let uv pick the newest
compatible releases.
Step 4: The agent loop
This is the core of the project. The agent connects to the MCP server, converts its tools into the shape the Anthropic API expects, and then runs the tool-use loop.
Create the file
touch agent.py
Add the code: agent.py
"""An agent that answers a prompt by calling tools from an MCP server.
The agent runs the standard Anthropic tool-use loop: it asks Claude what to do,
executes any tool Claude requests against the MCP server, feeds the results
back, and repeats until Claude produces a final answer. The Anthropic client is
passed in rather than constructed here so the loop can be tested against a fake
model without a network call or an API key.
"""
import argparse
import asyncio
import os
from typing import Any
from anthropic import AsyncAnthropic
from fastmcp import Client
DEFAULT_MODEL = "claude-opus-4-8"
def to_anthropic_tool(mcp_tool: Any) -> dict[str, Any]:
"""Convert one MCP tool description into an Anthropic tool definition.
MCP and the Anthropic API describe tools with almost the same fields; only
the JSON-schema key differs (`inputSchema` vs `input_schema`).
"""
schema = getattr(mcp_tool, "inputSchema", None) or {"type": "object", "properties": {}}
return {
"name": mcp_tool.name,
"description": mcp_tool.description or "",
"input_schema": schema,
}
def final_text(response: Any) -> str:
"""Concatenate the text blocks of a Claude response into one string."""
return "".join(block.text for block in response.content if block.type == "text")
async def run_agent(
user_prompt: str,
*,
mcp_target: Any,
anthropic_client: Any,
model: str = DEFAULT_MODEL,
max_steps: int = 8,
) -> str:
"""Answer `user_prompt` by letting Claude call the MCP server's tools.
`mcp_target` is anything FastMCP's Client understands: a path to a server
`.py` file (launched over stdio), an http(s) URL, or an in-process FastMCP
server object (used by the tests). `anthropic_client` is an
`AsyncAnthropic` instance in production or a fake in the tests.
"""
async with Client(mcp_target) as mcp:
mcp_tools = await mcp.list_tools()
tools = [to_anthropic_tool(tool) for tool in mcp_tools]
messages: list[dict[str, Any]] = [{"role": "user", "content": user_prompt}]
for _ in range(max_steps):
response = await anthropic_client.messages.create(
model=model,
max_tokens=1024,
tools=tools,
messages=messages,
)
# Record Claude's turn verbatim, including any tool_use blocks.
messages.append({"role": "assistant", "content": response.content})
if response.stop_reason != "tool_use":
return final_text(response)
# Execute every tool Claude asked for and collect the results.
tool_results = []
for block in response.content:
if block.type != "tool_use":
continue
result = await mcp.call_tool(block.name, block.input)
tool_results.append(
{
"type": "tool_result",
"tool_use_id": block.id,
"content": str(result.data),
}
)
# All tool results for a turn go back in a single user message.
messages.append({"role": "user", "content": tool_results})
raise RuntimeError(f"Agent did not finish within {max_steps} steps")
def main() -> None:
parser = argparse.ArgumentParser(description="An MCP-tool-using Claude agent.")
parser.add_argument("prompt", help="The question to answer")
parser.add_argument(
"--server", default="demo_server.py", help="MCP server .py file or URL"
)
parser.add_argument("--model", default=DEFAULT_MODEL, help="Claude model ID")
args = parser.parse_args()
if not os.environ.get("ANTHROPIC_API_KEY"):
raise SystemExit("Set ANTHROPIC_API_KEY before running the agent.")
client = AsyncAnthropic()
answer = asyncio.run(
run_agent(
args.prompt,
mcp_target=args.server,
anthropic_client=client,
model=args.model,
)
)
print(answer)
if __name__ == "__main__":
main()
Detailed breakdown
- Tool conversion (
to_anthropic_tool). An MCP tool and an Anthropic tool carry the same information — a name, a description, and a JSON schema for the arguments — but the schema field is namedinputSchemaon the MCP side andinput_schemaon the Anthropic side. This function renames it and passes the rest through. Thegetattrfallback supplies an empty object schema for a tool that takes no arguments, so the conversion never crashes on a valid tool. - The loop (
run_agent). Each pass sends the full conversation plus the tool list to the model. The model replies with astop_reason: if it is nottool_use, the model is done andfinal_textreturns its answer. If it istool_use, the response contains one or moretool_useblocks naming a tool and its arguments. - Feeding results back. The assistant turn is appended to
messagesverbatim — the wholeresponse.content, including thetool_useblocks — so the model sees its own request on the next pass. Each tool is run through the MCP client, and its output goes back as atool_resultblock keyed bytool_use_id. All results for one turn go in a single user message; the API pairs each result to its request by ID. str(result.data). FastMCP returns a structured result object;.datais the tool’s return value (aninthere). The API wants text in atool_result, so the value is stringified. A richer agent might serialize JSON instead.max_steps. A model that keeps calling tools without ever answering would loop forever. The counter turns that into a clearRuntimeErrorafter a bounded number of round trips.- Injected client.
run_agentnever constructs an Anthropic client; the caller passes one in.mainbuilds a realAsyncAnthropic(which readsANTHROPIC_API_KEYfrom the environment) and checks the key is present before spending anything. The tests pass a fake in the same slot.
Step 5: Configure pytest
The tests are async def functions, so pytest-asyncio needs to run in auto
mode, and the project root has to be importable so import agent and
import demo_server resolve.
Create the file
touch pytest.ini
Add the code: pytest.ini
[pytest]
pythonpath = .
asyncio_mode = auto
testpaths = tests
Detailed breakdown
pythonpath = .puts the project root onsys.pathso the test files can importagentanddemo_serverdirectly.asyncio_mode = autoletspytest-asynciotreat everyasync def test_*as a coroutine test with no per-test decorator.testpaths = testsscopes collection to thetests/directory.
Step 6: Test the loop with a fake model
These tests run the real MCP server and the real loop. Only the model is
faked: FakeAnthropic returns responses you script. Because the fake reads the
tool results the loop feeds back to it, a passing test proves the loop returns
each tool’s output to the model — not just that it called the tool.
Create the file
touch tests/test_agent.py
Add the code: tests/test_agent.py
"""Tests for the agent loop.
These exercise the real MCP server (in-memory) and the real tool-use loop. Only
Claude is faked: `FakeAnthropic` returns scripted responses, so no API key or
network call is needed. The fake reads the tool results the loop feeds back to
it, which proves the loop actually returns each tool's output to the model.
"""
from dataclasses import dataclass
from typing import Any
import demo_server
import pytest
from agent import final_text, run_agent, to_anthropic_tool
# --- Minimal stand-ins for the Anthropic SDK's response objects ---------------
@dataclass
class TextBlock:
text: str
type: str = "text"
@dataclass
class ToolUseBlock:
id: str
name: str
input: dict[str, Any]
type: str = "tool_use"
@dataclass
class FakeResponse:
stop_reason: str
content: list[Any]
class FakeMessages:
def __init__(self, script):
self._script = script
self.calls: list[list[dict[str, Any]]] = []
async def create(self, **kwargs):
self.calls.append(kwargs["messages"])
return self._script(kwargs["messages"], len(self.calls) - 1)
class FakeAnthropic:
def __init__(self, script):
self.messages = FakeMessages(script)
def last_tool_result(messages: list[dict[str, Any]]) -> str:
"""Return the content of the most recent tool_result the loop fed back."""
for block in reversed(messages[-1]["content"]):
if block["type"] == "tool_result":
return block["content"]
raise AssertionError("no tool_result found in the last message")
# --- Tests --------------------------------------------------------------------
async def test_tool_conversion_matches_mcp_schema():
from fastmcp import Client
async with Client(demo_server.mcp) as mcp:
tools = [to_anthropic_tool(t) for t in await mcp.list_tools()]
add = next(t for t in tools if t["name"] == "add")
assert set(add["input_schema"]["properties"]) == {"a", "b"}
assert add["description"] == "Add two integers and return the sum."
async def test_single_step_answer_without_tools():
def script(messages, step):
return FakeResponse("end_turn", [TextBlock("Hello there.")])
answer = await run_agent(
"Just say hello",
mcp_target=demo_server.mcp,
anthropic_client=FakeAnthropic(script),
)
assert answer == "Hello there."
async def test_chained_tool_calls_feed_results_back():
seen_multiply_input: dict[str, Any] = {}
def script(messages, step):
if step == 0:
return FakeResponse(
"tool_use", [ToolUseBlock("t1", "add", {"a": 12, "b": 8})]
)
if step == 1:
# The loop must have fed the add result (20) back to us.
add_result = int(last_tool_result(messages))
seen_multiply_input["a"] = add_result
return FakeResponse(
"tool_use", [ToolUseBlock("t2", "multiply", {"a": add_result, "b": 3})]
)
return FakeResponse(
"end_turn", [TextBlock(f"The answer is {last_tool_result(messages)}.")]
)
answer = await run_agent(
"What is (12 + 8) times 3?",
mcp_target=demo_server.mcp,
anthropic_client=FakeAnthropic(script),
)
assert seen_multiply_input["a"] == 20 # add executed and was fed back
assert answer == "The answer is 60." # multiply executed and was fed back
async def test_word_count_tool():
def script(messages, step):
if step == 0:
return FakeResponse(
"tool_use",
[ToolUseBlock("t1", "word_count", {"text": "the quick brown fox"})],
)
return FakeResponse(
"end_turn", [TextBlock(f"{last_tool_result(messages)} words.")]
)
answer = await run_agent(
"How many words in 'the quick brown fox'?",
mcp_target=demo_server.mcp,
anthropic_client=FakeAnthropic(script),
)
assert answer == "4 words."
async def test_step_limit_raises():
def script(messages, step):
# Never stop asking for a tool.
return FakeResponse("tool_use", [ToolUseBlock("t", "add", {"a": 1, "b": 1})])
with pytest.raises(RuntimeError, match="did not finish"):
await run_agent(
"loop forever",
mcp_target=demo_server.mcp,
anthropic_client=FakeAnthropic(script),
max_steps=3,
)
def test_final_text_joins_text_blocks_only():
response = FakeResponse(
"end_turn",
[TextBlock("one "), ToolUseBlock("x", "add", {}), TextBlock("two")],
)
assert final_text(response) == "one two"
Detailed breakdown
- The fakes.
TextBlock,ToolUseBlock, andFakeResponseare dataclasses with just the fields the loop touches (type,text,id,name,input,stop_reason,content).FakeMessages.createis anasyncmethod matching the SDK’s call, soawait anthropic_client.messages.create(...)works unchanged. - Scripts. Each test passes a
script(messages, step)that returns the next fake response. Thestepindex selects the response;messageslets the script inspect what the loop fed back. test_chained_tool_calls_feed_results_backis the key test. Step 0 asks foradd(12, 8). Step 1 reads the result the loop returned (last_tool_result), asserts it is20, and asks formultiply(20, 3). The final step reads60and states it. Because the multiply argument and the final answer both come from values the loop fed back, the test fails if the loop drops a result. The arithmetic is deterministic:12 + 8 = 20,20 × 3 = 60.test_tool_conversion_matches_mcp_schemaconfirms the real server’saddschema survives conversion with both parameters and the docstring intact.test_step_limit_raisesscripts a model that never stops calling tools and confirmsrun_agentgives up with aRuntimeErrorinstead of looping forever.test_final_text_joins_text_blocks_onlyis a plain synchronous unit test:final_textconcatenates text blocks and skipstool_useblocks.
Step 7: Add a Makefile
The Makefile wraps the common commands and prints a help screen by default.
Create the file
touch Makefile
Add the code: Makefile
.DEFAULT_GOAL := help
.PHONY: help install chat test
help: ## Show this help screen
@grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) \
| awk 'BEGIN {FS = ":.*?## "}; {printf " \033[36m%-10s\033[0m %s\n", $$1, $$2}'
install: ## Sync runtime and dev dependencies
uv sync
chat: ## Ask the agent a question (needs ANTHROPIC_API_KEY)
uv run python agent.py "What is (12 + 8) times 3?"
test: ## Run the test suite (no API key needed)
uv run pytest -v
Detailed breakdown
.DEFAULT_GOAL := helpmakes a baremakeprint the help screen. Thehelptarget greps the Makefile for target lines with a##comment and formats them into a table, so every documented target lists itself automatically.installandtestrun throughuv, so they use the locked environment. The recipes are tab-indented, asmakerequires.chatruns the agent live against the defaultdemo_server.py. It is the one target that needsANTHROPIC_API_KEYand network access.
Confirm the default target prints help:
make
help Show this help screen
install Sync runtime and dev dependencies
chat Ask the agent a question (needs ANTHROPIC_API_KEY)
test Run the test suite (no API key needed)
Step 8: Run the tests, then the agent
Run the suite first. It needs no key:
make test
tests/test_agent.py::test_tool_conversion_matches_mcp_schema PASSED [ 16%]
tests/test_agent.py::test_single_step_answer_without_tools PASSED [ 33%]
tests/test_agent.py::test_chained_tool_calls_feed_results_back PASSED [ 50%]
tests/test_agent.py::test_word_count_tool PASSED [ 66%]
tests/test_agent.py::test_step_limit_raises PASSED [ 83%]
tests/test_agent.py::test_final_text_joins_text_blocks_only PASSED [100%]
============================== 6 passed in 2.02s ===============================
Now run it for real. Export your key and ask a question that needs two tools:
export ANTHROPIC_API_KEY=sk-ant-...
make chat
The agent launches demo_server.py over stdio, and the model calls add(12, 8),
then multiply(20, 3), then answers. FastMCP prints a startup banner from the
subprocess; after it, the final line is the agent’s answer — something like:
(12 + 8) × 3 = 60
The wording is model-generated and varies from run to run; the number is fixed by
the tools at 60. To ask your own question, call the script directly:
uv run python agent.py "How many words are in 'the quick brown fox jumps'?"
Troubleshooting
Set ANTHROPIC_API_KEY before running the agent.Thechattarget found no key in the environment.export ANTHROPIC_API_KEY=sk-ant-...in the same shell, or put it in a.envandsourceit, then retry. The tests never hit this path.make testreports “no tests ran” or import errors. Confirmpytest.inihaspythonpath = .andasyncio_mode = auto, and thattests/__init__.pyexists. Without auto mode, theasync deftests are skipped or error.- The agent raises
Agent did not finish within 8 steps. The model kept calling tools without answering. Raisemax_steps, or sharpen the tool docstrings so the model knows when it has enough to answer. - A tool result looks wrong in the model’s answer. Print each
tool_resultbefore appending it. Becausestr(result.data)stringifies the value, a tool returning a structure will send itsrepr; switch to JSON if the model needs to parse it.
Recap
The agent is a short loop around one idea: let the model choose a tool, run it,
return the result, and repeat until the model stops asking. Converting MCP tools
into Anthropic tool definitions is a field rename, and feeding results back is a
tool_result block keyed by tool_use_id. Injecting the Anthropic client kept
the whole loop testable with a fake model, so the server, the conversion, and the
feed-back all get verified without a key.
From here you can point --server at any MCP server from the earlier articles —
an HTTP URL works as well as a local .py file — add a system prompt to steer the
agent, or stream the model’s responses for a live view of each tool call.