AI Agent Development #6: Building Your Own MCP Server

5 min read

We first encountered MCP in Part 11 of LLM App Development. Back then we were on the side that connects Claude to an MCP server someone else had already built. This time we stand on the other side. We build our own tools as an MCP server, so that not only our agent but also other clients like Claude Desktop and Claude Code can use the same tools.

When to split tools out into a server #

So far our tools have been Python functions inside the agent code. This approach is simple and fast, but the tools are locked inside that agent. To use the same tool in another agent or another tool, you have to copy the code.

Split them out into an MCP server and the tools become an independent process that any MCP-compatible client can connect to in the standard way. You build an internal order-system lookup tool once, and your agent, your teammates’ Claude Desktop, and Claude Code all share the same server. Conversely, if a tool is used by only one agent, there is no reason to split it out. A function is enough.

Building a server with FastMCP #

With FastMCP, included in the Python MCP SDK, a server shrinks to little more than a few functions. First, install it.

terminal
pip install "mcp[cli]"

Let’s move the two order tools we used up through Part 11 into a server.

order_server.py
from mcp.server.fastmcp import FastMCP

mcp = FastMCP("order-tools")

@mcp.tool()
def search_orders(query: str) -> str:
    """Search orders by order number, customer name, or date.
    When the user asks about the status or details of a specific order,
    find the order with this tool first."""
    results = do_search(query)          # actual implementation
    return format_results(results)

@mcp.tool()
def get_order_detail(order_id: str) -> str:
    """Look up order details by order number. Example: ORD-2026-0001"""
    return fetch_detail(order_id)

if __name__ == "__main__":
    mcp.run()   # runs over stdio by default

The @mcp.tool() decorator builds the tool name, description, and input schema from the function signature and docstring. The tool design principles from Part 2 apply here as is. The docstring becomes the description, so write down when to use the tool too.

The default behavior of mcp.run() is stdio. The client launches this script as a child process and talks to it over standard input and output, which suits a local tool server.

Wiring it into our agent loop #

Now we connect to this server from the agent side. We launch the server through an MCP client, convert the tool list the server reports into Claude’s tools format, and forward Claude’s tool calls to the server. The MCP client is async-based, so the loop becomes async too.

agent_with_mcp.py
import asyncio
import anthropic
from mcp import ClientSession, StdioServerParameters
from mcp.client.stdio import stdio_client

client = anthropic.AsyncAnthropic()

async def run_agent(goal: str, max_steps: int = 20) -> str:
    server = StdioServerParameters(command="python", args=["order_server.py"])
    async with stdio_client(server) as (read, write):
        async with ClientSession(read, write) as mcp_session:
            await mcp_session.initialize()

            # 1. convert the server's tool list into Claude tools format
            listed = await mcp_session.list_tools()
            tools = [
                {
                    "name": t.name,
                    "description": t.description,
                    "input_schema": t.inputSchema,
                }
                for t in listed.tools
            ]

            # 2. the loop is the same as Part 1; only tool execution becomes an MCP call
            messages = [{"role": "user", "content": goal}]
            for _ in range(max_steps):
                response = await client.messages.create(
                    model="claude-opus-4-8",
                    max_tokens=16000,
                    tools=tools,
                    messages=messages,
                )
                if response.stop_reason == "end_turn":
                    return next(b.text for b in response.content if b.type == "text")

                messages.append({"role": "assistant", "content": response.content})
                tool_results = []
                for block in response.content:
                    if block.type == "tool_use":
                        result = await mcp_session.call_tool(block.name, block.input)
                        text = "\n".join(c.text for c in result.content if c.type == "text")
                        tool_results.append({
                            "type": "tool_result",
                            "tool_use_id": block.id,
                            "content": text,
                            "is_error": bool(result.isError),
                        })
                messages.append({"role": "user", "content": tool_results})

    raise RuntimeError("Could not finish the task within the step limit.")

print(asyncio.run(run_agent("Tell me the status of order ORD-2026-0001.")))

The skeleton of the loop is identical to Part 1. Only two things changed: instead of hardcoding the tool list, we fetch it from the server, and tool execution became call_tool instead of a direct Python function call. Add or fix tools on the server side and the agent code stays untouched — that is the power of this structure.

The same server in other clients #

Register the same server in Claude Desktop or Claude Code and they can use the order tools too, without writing a single additional line of code. Registration amounts to putting the server’s launch command in each client’s config file, following the same connection flow we saw in Part 11. MCP’s promise — build a tool once and every client shares it — is realized at this point.

Common MCP pitfalls #

  • Writing a sloppy docstring — in FastMCP, the docstring becomes the tool description as is. If it is empty or a single word, Claude has no basis for deciding when to use the tool.
  • Not checking isError — the result of call_tool carries whether an error occurred. Ignore it and pass along only the body, and the error-recovery circuit we built in Part 1 stops working for MCP tools.
  • Turning every tool into a server — splitting out comes with the cost of process management. Make servers only out of tools that multiple clients will share, and keep single-agent tools as functions.

Key takeaways #

In this post we split tools out into an MCP server.

  • With FastMCP, attaching a decorator to a function is all it takes to get a tool server. The docstring is the tool description.
  • Leave the agent loop as is, swap only tool-list retrieval and tool execution for MCP calls, and the connection is done.
  • The value of splitting out is reuse. Make servers only out of tools that multiple clients will use.

Now all the parts covered in this series have come together. In the next post, “AI Agent Development #7: Capstone Project — An Issue Triage Agent,” we tie together the robust loop, tool design, human approval, and evaluation to complete one working agent and close out the series.

X