Lesson 3 - Building Tools Over MCP

Welcome to Building Tools Over MCP

In Lesson 2 you wrote a client that spawned a server, discovered its tools, and called them. You were the consumer. Now you switch sides: you’ll build the server. By the end you’ll have a small MCP server of your own, written in a few lines of Python, and you’ll watch Claude call one of its tools to answer a question.

This is where the protocol pays off. The same server you write here works for your own client, for Claude Desktop, for an IDE extension — for any MCP host. You write the tool once; the ecosystem reuses it.

By the end of this lesson, you will be able to:

  • Create an MCP server with FastMCP and expose a function as a tool with @mcp.tool()
  • Explain how the docstring becomes the description and type hints become the input schema
  • Add a resource for readable data and run the server over stdio
  • Bridge discovered MCP tools to Claude and run the tool-use loop on them

You’ll reuse the client from Lesson 2 to confirm your tools appear. Let’s build.


FastMCP: A Server in a Few Lines

The MCP Python SDK ships a helper called FastMCP that removes almost all of the boilerplate. You create a server, decorate a function, and run it. Here is a complete, working server:

from mcp.server.fastmcp import FastMCP

mcp = FastMCP("text-tools")

@mcp.tool()
def word_count(text: str) -> int:
    """Count the number of words in a piece of text."""
    return len(text.split())

if __name__ == "__main__":
    mcp.run(transport="stdio")

Three things are happening:

  • FastMCP("text-tools") creates a server and gives it a name. The name is how hosts and logs identify it.
  • @mcp.tool() registers the function below it as an MCP tool. The function is still ordinary Python — you can call word_count("a b c") directly — but the decorator also makes it discoverable over the protocol.
  • mcp.run(transport="stdio") starts the server and speaks MCP over standard input/output, the same transport your Lesson 2 client connected to.

That’s a real, complete server. No schema files, no registration tables.

The Decorator Reads Your Function

The part worth slowing down on is what @mcp.tool() does automatically. It doesn’t ask you to write a schema. Instead it inspects the function:

  • The docstring becomes the tool’s description — the text Claude reads to decide when to use the tool.
  • The type hints become the input schematext: str tells MCP the tool takes one required string argument named text.

So this plain function definition is enough to produce a fully-described tool. You write Python the normal way; the protocol metadata is generated for you.


Adding More Tools

A server can expose as many tools as you like — just decorate more functions. Let’s add a second tool and keep the first:

from mcp.server.fastmcp import FastMCP

mcp = FastMCP("text-tools")

@mcp.tool()
def word_count(text: str) -> int:
    """Count the number of words in a piece of text."""
    return len(text.split())

@mcp.tool()
def reverse_text(text: str) -> str:
    """Reverse the characters in a piece of text."""
    return text[::-1]

if __name__ == "__main__":
    mcp.run(transport="stdio")

Each tool is independent. A host that connects to this server will discover both, with their own descriptions and schemas, and can call either one.

Confirming the Tools With Your Lesson 2 Client

Before involving any model, verify the server works by pointing your Lesson 2 client at it. The client connects, calls list_tools(), and prints what it finds — including the schema FastMCP generated:

import asyncio
from mcp import ClientSession, StdioServerParameters
from mcp.client.stdio import stdio_client

# Path to the Python interpreter and to text_server.py on your machine.
server_params = StdioServerParameters(command="python", args=["text_server.py"])

async def main():
    async with stdio_client(server_params) as (read, write):
        async with ClientSession(read, write) as session:
            await session.initialize()
            print("=== Tools served ===")
            for tool in (await session.list_tools()).tools:
                print(f"  {tool.name}: {tool.description}")
                print(f"    input schema: {tool.inputSchema}")

asyncio.run(main())

Running it produces:

=== Tools served ===
  word_count: Count the number of words in a piece of text.
    input schema: {'properties': {'text': {'title': 'Text', 'type': 'string'}}, 'required': ['text'], 'title': 'word_countArguments', 'type': 'object'}
  reverse_text: Reverse the characters in a piece of text.
    input schema: {'properties': {'text': {'title': 'Text', 'type': 'string'}}, 'required': ['text'], 'title': 'reverse_textArguments', 'type': 'object'}

Look closely at the output. You never wrote a schema, yet each tool has one: a text property of type string, marked required. That came straight from the text: str hint, and the description came straight from the docstring. The decorator did the translation.

Run the server with the same interpreter that has the SDK

The client spawns the server as a subprocess via command. Make sure that command points at the Python environment where the MCP SDK is installed (for example, the full path to your virtual environment’s python), or the server process won’t start.


Resources: Serving Readable Data

Tools do things. Sometimes you instead want to expose data the host can read — a config value, a file’s contents, a record. That’s a resource. You declare one with @mcp.resource(...), giving it a URI so clients can address it:

@mcp.resource("config://app-name")
def app_name() -> str:
    """The display name of this application."""
    return "DataTweets Text Toolkit"

The string "config://app-name" is the resource’s address. A host can read it the way it reads a file — to load context — rather than calling it as an action. The distinction is the same one from Lesson 1: tools are executed, resources are read. You’ll lean on tools for most of this course, but it’s worth knowing the server can offer both.


Bridging MCP Tools to Claude

Here is the payoff. You have a server that exposes tools. In Module 3 you taught Claude to use tools by writing Anthropic tool definitions yourself and running a loop. The only thing that changes now is where the tools come from: instead of hand-writing definitions, you discover them over MCP and convert them.

The conversion is mechanical. An MCP tool has a name, a description, and an inputSchema. An Anthropic tool definition needs a name, a description, and an input_schema. So each discovered tool maps over directly:

mcp_tools = (await session.list_tools()).tools
anthropic_tools = [
    {
        "name": t.name,
        "description": t.description,
        "input_schema": t.inputSchema,
    }
    for t in mcp_tools
]

That’s the whole adapter. The schema FastMCP generated is already valid as an Anthropic input_schema, so it passes through unchanged.

Executing a Tool Means Calling the MCP Session

In Module 3, when Claude asked to use a tool, you ran the matching Python function. Now executing a tool means one thing: call it on the MCP session. When Claude returns a tool_use block, you forward its name and input to session.call_tool(...) and hand the result back as the tool_result:

result = await session.call_tool(block.name, block.input)
tool_results.append({
    "type": "tool_result",
    "tool_use_id": block.id,
    "content": result.content[0].text,
})

The server runs the actual function; Claude never touches your code directly. Putting the adapter and the loop together gives a complete bridge:

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

client = anthropic.Anthropic()  # reads ANTHROPIC_API_KEY from the environment
MODEL = "claude-haiku-4-5"

server_params = StdioServerParameters(command="python", args=["text_server.py"])

async def main():
    async with stdio_client(server_params) as (read, write):
        async with ClientSession(read, write) as session:
            await session.initialize()

            # Discover MCP tools and convert them to Anthropic tool definitions.
            mcp_tools = (await session.list_tools()).tools
            anthropic_tools = [
                {"name": t.name, "description": t.description,
                 "input_schema": t.inputSchema}
                for t in mcp_tools
            ]

            messages = [{
                "role": "user",
                "content": "How many words are in this sentence: "
                           "'Model Context Protocol makes tools reusable everywhere'?",
            }]

            while True:
                response = client.messages.create(
                    model=MODEL, max_tokens=1024,
                    tools=anthropic_tools, messages=messages,
                )
                if response.stop_reason != "tool_use":
                    print("=== Claude's final answer ===")
                    print(response.content[0].text)
                    break

                messages.append({"role": "assistant", "content": response.content})
                tool_results = []
                for block in response.content:
                    if block.type == "tool_use":
                        print(f"[Claude calls MCP tool] {block.name}({block.input})")
                        result = await session.call_tool(block.name, block.input)
                        tool_results.append({
                            "type": "tool_result",
                            "tool_use_id": block.id,
                            "content": result.content[0].text,
                        })
                messages.append({"role": "user", "content": tool_results})

asyncio.run(main())

This is the exact tool-use loop from Module 3 — create, check stop_reason, append the assistant turn, run the requested tools, append the results, repeat. The single difference is that each tool runs through session.call_tool against your MCP server.

A Real Run

Running the bridge against the server produces this:

[Claude calls MCP tool] word_count({'text': 'Model Context Protocol makes tools reusable everywhere'})
=== Claude's final answer ===
The sentence 'Model Context Protocol makes tools reusable everywhere' contains **7 words**.

Trace what happened. Claude read the question, saw a word_count tool whose description matched, and asked to call it with the sentence as input. Your loop forwarded that call to the MCP session, which ran the function on the server and returned 7. Claude folded that result into a sentence and answered. You never hard-coded the tool into the model call — it arrived through MCP and was used like any other tool.

The same server, every host

You just connected this server to Claude through your own Python program. Without changing a line of the server, you could instead register it with Claude Desktop or an IDE that speaks MCP, and it would expose the very same tools there. That reusability is the entire point of building tools over MCP rather than wiring them into one app.


Practice Exercises

Exercise 1: Add a third tool

Extend the text-tools server with a tool to_upper(text: str) -> str that returns the text in uppercase. Then run your Lesson 2 client and confirm three tools now appear, each with a generated schema.

Hint

Just decorate another function: @mcp.tool() above def to_upper(text: str) -> str: with a one-line docstring and return text.upper(). The decorator turns the docstring into the description and the text: str hint into the schema — you write nothing else.

Exercise 2: Predict the bridge’s behavior

You change the bridge’s question to “Reverse the word ‘context’ for me.” Given the tools on the server, which tool will Claude ask to call, and what input will it send? What will appear in the tool_result?

Hint

Claude matches the request to reverse_text, whose description says it reverses characters. It sends {'text': 'context'}. The session runs the function on the server and the tool_result content is txetnoc, which Claude then reports back.

Exercise 3: Spot the one line that changed

Compare this lesson’s tool-use loop to the one you wrote in Module 3 with hand-written tools. Which single line is different, and why does that line capture the whole idea of “tools over MCP”?

Hint

In Module 3 you dispatched to a local Python function. Here that line is result = await session.call_tool(block.name, block.input). Execution now goes to the MCP server instead of your own dictionary of functions — so the tools are decoupled from this program and reusable by any host.


Summary

You built an MCP server with FastMCP: mcp = FastMCP("text-tools"), a couple of functions decorated with @mcp.tool(), and mcp.run(transport="stdio"). The decorator did the heavy lifting — turning each function’s docstring into its description and its type hints into its input schema, with no schema written by hand. You confirmed the tools with your Lesson 2 client, saw how @mcp.resource(...) exposes readable data, and then bridged the server to Claude: discover the MCP tools, convert each into an Anthropic tool definition, and run the Module 3 tool-use loop where executing a tool means calling session.call_tool(...). In a real run, Claude used your MCP-served word_count tool to answer correctly.

Key Concepts

  • FastMCP — the SDK helper for building a server: create, decorate, run.
  • @mcp.tool() — registers a function as a tool; docstring → description, type hints → input schema, automatically.
  • @mcp.resource("scheme://...") — exposes readable data at a URI instead of an action.
  • Bridging — convert each discovered MCP tool to {"name", "description", "input_schema"} for Anthropic.
  • session.call_tool(name, input) — how a tool is executed in the loop: the server runs it and returns the result.

Why This Matters

The skill of wrapping your own systems as MCP tools is what makes Claude useful against your data and your APIs, not just generic ones. Because the server is decoupled from any single app, the work you do once is reused everywhere — by your scripts, by Claude Desktop, by IDEs, by agent frameworks. That reuse is the difference between writing throwaway glue and building durable infrastructure for AI.


Next Steps

Continue to Lesson 4 - Guided Project: MCP Integration

Put it together: build a server, bridge it to Claude, and ship a small end-to-end MCP integration.

Back to Module Overview

Return to the Model Context Protocol module overview


Continue Building Your Skills

You can now build an MCP server and hand its tools to Claude. Next you’ll bring everything together in a guided project — designing a server around a real task and wiring it into a working Claude integration end to end.