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 pytest suite that drives the real server and loop with a fake model, and proves each tool result is fed back to the model.
  • make targets to install, run the agent live, and test.

Prerequisites

  • macOS 13+ with Homebrew (brew.sh).
  • uv 0.5+brew install uv; verify with uv --version.
  • Xcode Command Line Tools (xcode-select --install) for make.
  • 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

  • .env is listed explicitly. The agent reads ANTHROPIC_API_KEY from the environment, and during development that key often lives in a .env file; this keeps it out of git.
  • .venv/ and the tool caches (.pytest_cache/, etc.) are build artifacts that uv and pytest regenerate, so they are ignored rather than committed.
  • .python-version pins the interpreter uv uses for this project. The empty tests/__init__.py marks 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.tool function 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

  • anthropic is the official SDK for the Claude API; fastmcp provides both the server decorator from Step 2 and the client the agent uses to reach it.
  • pytest-asyncio runs the async def tests. It is configured in Step 6 to pick up async tests automatically.
  • readme = "README.md" points at a file that must exist for uv to build the project, which is why the scaffold command creates an empty README.md alongside pyproject.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 named inputSchema on the MCP side and input_schema on the Anthropic side. This function renames it and passes the rest through. The getattr fallback 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 a stop_reason: if it is not tool_use, the model is done and final_text returns its answer. If it is tool_use, the response contains one or more tool_use blocks naming a tool and its arguments.
  • Feeding results back. The assistant turn is appended to messages verbatim — the whole response.content, including the tool_use blocks — 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 a tool_result block keyed by tool_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; .data is the tool’s return value (an int here). The API wants text in a tool_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 clear RuntimeError after a bounded number of round trips.
  • Injected client. run_agent never constructs an Anthropic client; the caller passes one in. main builds a real AsyncAnthropic (which reads ANTHROPIC_API_KEY from 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 on sys.path so the test files can import agent and demo_server directly.
  • asyncio_mode = auto lets pytest-asyncio treat every async def test_* as a coroutine test with no per-test decorator.
  • testpaths = tests scopes collection to the tests/ 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, and FakeResponse are dataclasses with just the fields the loop touches (type, text, id, name, input, stop_reason, content). FakeMessages.create is an async method matching the SDK’s call, so await anthropic_client.messages.create(...) works unchanged.
  • Scripts. Each test passes a script(messages, step) that returns the next fake response. The step index selects the response; messages lets the script inspect what the loop fed back.
  • test_chained_tool_calls_feed_results_back is the key test. Step 0 asks for add(12, 8). Step 1 reads the result the loop returned (last_tool_result), asserts it is 20, and asks for multiply(20, 3). The final step reads 60 and 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_schema confirms the real server’s add schema survives conversion with both parameters and the docstring intact.
  • test_step_limit_raises scripts a model that never stops calling tools and confirms run_agent gives up with a RuntimeError instead of looping forever.
  • test_final_text_joins_text_blocks_only is a plain synchronous unit test: final_text concatenates text blocks and skips tool_use blocks.

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 := help makes a bare make print the help screen. The help target greps the Makefile for target lines with a ## comment and formats them into a table, so every documented target lists itself automatically.
  • install and test run through uv, so they use the locked environment. The recipes are tab-indented, as make requires.
  • chat runs the agent live against the default demo_server.py. It is the one target that needs ANTHROPIC_API_KEY and 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. The chat target found no key in the environment. export ANTHROPIC_API_KEY=sk-ant-... in the same shell, or put it in a .env and source it, then retry. The tests never hit this path.
  • make test reports “no tests ran” or import errors. Confirm pytest.ini has pythonpath = . and asyncio_mode = auto, and that tests/__init__.py exists. Without auto mode, the async def tests are skipped or error.
  • The agent raises Agent did not finish within 8 steps. The model kept calling tools without answering. Raise max_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_result before appending it. Because str(result.data) stringifies the value, a tool returning a structure will send its repr; 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.