Lesson 4 - Guided Project: Connect Claude to an MCP Service

Welcome to the Guided Project

You’ll build a small but realistic MCP server — a team task tracker — then connect Claude to it so it can answer questions and take actions through MCP tools. This is exactly how production AI apps plug into real systems: a team owns a service, wraps it in an MCP server once, and any AI host can drive it. By the end you’ll ask Claude to “Add a high-priority task to ship the release notes, then show me all the open tasks” and watch it reach for two MCP tools in one turn — adding the task, then listing tasks — and stitch the results into one answer.

Nothing here is brand-new theory. Lesson 1 gave you the host/client/server model; Lesson 2 had you discover and call tools over MCP; Lesson 3 connected those tools to Claude. This project snaps those pieces into one program: a server you write, a client that discovers it, and a bridge that lets Claude use what it found. The tool-use loop is the same one from Module 3 — only now the tools come from a server over a standard protocol instead of being hard-wired into your code.

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

  • Build an MCP server with FastMCP: an in-memory store, several tools, and a resource
  • Connect a client and confirm it discovers the server’s tools
  • Bridge MCP to Claude — convert MCP tools to Anthropic tool definitions and run the tool-use loop against the live session
  • Drive a real multi-step interaction where Claude chains MCP tool calls to do work

You’ll need the Anthropic SDK, the MCP Python SDK, and your API key in an environment variable. Let’s build it.


Step 1: Design and Build the MCP Server

Start with the server, in a file called task_server.py. A server is just a program that exposes capabilities over MCP — here, a tiny task tracker. We’ll give it an in-memory store (a Python list), three tools to act on it, and one resource that exposes the current list as readable context.

FastMCP is the high-level server class in the MCP Python SDK. You create one FastMCP instance, then decorate plain Python functions with @mcp.tool() to expose them. FastMCP reads each function’s name, docstring, and type hints and builds the tool’s name, description, and input schema for you — no schema written by hand.

pip install "mcp[cli]" anthropic

Here is the whole server. The store is a module-level list; each task is a dictionary, and a task’s id is just its position in the list plus one.

"""A small MCP server: an in-memory team task tracker."""
from mcp.server.fastmcp import FastMCP

mcp = FastMCP("task-tracker")

# In-memory store. Each task is a dict; the list index + 1 is its id.
_TASKS = []


@mcp.tool()
def add_task(title: str, priority: str = "medium") -> str:
    """Add a new task. priority is 'low', 'medium', or 'high'."""
    if priority not in ("low", "medium", "high"):
        raise ValueError("priority must be 'low', 'medium', or 'high'")
    _TASKS.append({"title": title, "priority": priority, "status": "open"})
    task_id = len(_TASKS)
    return f"Added task #{task_id}: {title} (priority: {priority})"


@mcp.tool()
def list_tasks(status: str = "all") -> str:
    """List tasks. status filters by 'open', 'done', or 'all'."""
    if status not in ("open", "done", "all"):
        raise ValueError("status must be 'open', 'done', or 'all'")
    rows = []
    for i, task in enumerate(_TASKS, start=1):
        if status != "all" and task["status"] != status:
            continue
        rows.append(f"#{i} [{task['status']}] {task['title']} ({task['priority']})")
    return "\n".join(rows) if rows else "No matching tasks."


@mcp.tool()
def complete_task(task_id: int) -> str:
    """Mark the task with the given id as done."""
    if task_id < 1 or task_id > len(_TASKS):
        raise ValueError(f"no task with id {task_id}")
    _TASKS[task_id - 1]["status"] = "done"
    return f"Completed task #{task_id}: {_TASKS[task_id - 1]['title']}"


@mcp.resource("tasks://current")
def current_tasks() -> str:
    """The current task list, as readable context."""
    if not _TASKS:
        return "The task list is empty."
    return "\n".join(
        f"#{i} [{t['status']}] {t['title']} ({t['priority']})"
        for i, t in enumerate(_TASKS, start=1)
    )


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

Three things to notice. First, the type hints carry weight: title: str and priority: str = "medium" become the tool’s input schema, with priority optional because it has a default. Second, the docstrings are the tool descriptions — the same descriptions Claude will read to decide when to call each tool, so they’re written for a reader, not just a comment. Third, two functions raise on bad input (an unknown priority, a missing id); that’s deliberate — MCP turns a raised exception into a tool error the client can see, instead of crashing the server.

The @mcp.resource("tasks://current") decorator exposes the task list as a resource — data a host can read for context, addressed by a URI, distinct from a tool that does something. mcp.run() at the bottom starts the server speaking MCP over standard input and output (stdio), which is how a host launches and talks to a local server.

Tools act, resources are read

add_task and complete_task change the world; list_tasks reads it; the tasks://current resource also reads it but is offered as context rather than an action. That’s the Lesson 1 distinction made concrete: the same data can be reachable both as a tool the model calls and as a resource the host loads. In this project Claude drives the tools — the resource is there so any host can pull the list into context without making a call.


Step 2: Connect a Client and Confirm Discovery

A server is only useful once something connects to it. The client is the connector that launches the server, speaks MCP, and asks what it offers. Before wiring in Claude, confirm the client can discover the tools — the heart of the protocol from Lesson 1.

The MCP SDK gives you stdio_client to spawn a server process and ClientSession to run the protocol over it. You point it at task_server.py, call initialize() to handshake, then list_tools() to discover.

import asyncio
import sys

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

# Launch task_server.py with this same Python interpreter.
server = StdioServerParameters(command=sys.executable, args=["task_server.py"])


async def main():
    async with stdio_client(server) as (read, write):
        async with ClientSession(read, write) as session:
            await session.initialize()
            tools = (await session.list_tools()).tools
            print("=== MCP tools discovered ===")
            for t in tools:
                print(f"  {t.name}: {(t.description or '').splitlines()[0]}")


if __name__ == "__main__":
    asyncio.run(main())

Using sys.executable as the command launches the server with the same Python interpreter that’s running the client — so the server finds the mcp package you installed. Run it and you’ll see the three tools the server built from your decorated functions:

=== MCP tools discovered ===
  add_task: Add a new task. priority is 'low', 'medium', or 'high'.
  list_tasks: List tasks. status filters by 'open', 'done', or 'all'.
  complete_task: Mark the task with the given id as done.

The client knew none of these in advance — it learned them by asking. That discovery step is what lets a host connect to a server it has never seen and immediately know what it can do. Now we hand that list to Claude.


Step 3: Build the Claude Bridge

The bridge is the piece that turns “tools discovered over MCP” into “tools Claude can use.” It does two jobs: convert each MCP tool into the Anthropic tool-definition shape, and run the tool-use loop — but execute each call through the MCP session instead of a local function.

Converting MCP tools to Anthropic tool definitions

An MCP tool exposes a name, a description, and an inputSchema (a JSON Schema). An Anthropic tool definition wants a name, a description, and an input_schema — the same three fields under slightly different names. The conversion is a small mapping.

def to_anthropic_tools(mcp_tools):
    """Convert MCP tool defs into the shape the Anthropic API expects."""
    return [
        {
            "name": t.name,
            "description": t.description or "",
            "input_schema": t.inputSchema,
        }
        for t in mcp_tools
    ]

Because FastMCP already built a valid JSON Schema from your type hints, you can hand t.inputSchema straight to the API — no reshaping. This is the payoff of the standard: the server describes its tools in one well-defined way, and any host can translate that description into its own format with a few lines.

The system prompt

A short system prompt tells Claude what it’s managing and the rule: use the tools, don’t guess. The line about needing more than one tool is what gives it permission to chain calls in a single turn.

SYSTEM = (
    "You manage a team's task tracker through MCP tools. "
    "When the user asks to add, list, or complete tasks, call the matching "
    "tool instead of guessing. A request may need more than one tool — call "
    "each one you need. Once you have the results, reply in plain text, "
    "concisely, suitable for a terminal."
)

The tool-use loop over MCP

Here’s the loop. It’s the same shape as Module 3 — call the model, and if it asked for tools, run them and send the results back — with one change: each tool runs via await session.call_tool(name, input), so the work happens on the MCP server, not in this file.

async def answer(question, session, tools, messages):
    """Run one user turn to completion, executing tools via the MCP session."""
    messages.append({"role": "user", "content": question})

    while True:
        response = client.messages.create(
            model=MODEL, max_tokens=600, system=SYSTEM,
            tools=tools, messages=messages,
        )
        messages.append({"role": "assistant", "content": response.content})

        if response.stop_reason != "tool_use":
            return "".join(b.text for b in response.content if b.type == "text")

        # Run every tool Claude asked for this round, through MCP.
        tool_results = []
        for block in response.content:
            if block.type == "tool_use":
                print(f"   [calling {block.name}({block.input})]")
                result = await session.call_tool(block.name, block.input)
                text = "".join(
                    part.text for part in result.content if part.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})

Three details earn their keep. session.call_tool is the bridge in one line — Claude’s tool_use block carries a name and an input dictionary, and you forward both to the server, which runs the real function. The result is unwrappedresult.content is a list of MCP content parts, so you join the text parts into the string Claude expects. And the error flag passes through — if a tool raised on the server (a bad priority, a missing id), MCP sets result.isError, and you mark is_error on the result so Claude sees the failure and can recover rather than getting a silent wrong answer.

Append the assistant turn before the results

Each pass appends response.content (Claude’s turn, including its tool_use blocks) to messages before appending the tool_results. The API requires every tool_result to follow the matching tool_use in the history — skip the assistant append and the next request fails with a mismatch. Append both, every round. This is unchanged from ordinary tool use; MCP doesn’t alter it.


Step 4: Run a Real Multi-Step Interaction

Now connect everything and drive it. The main function spawns the server, discovers and converts its tools, prints them, then runs a scripted question that needs two tools — add a task, then list the open ones — so you can watch Claude chain MCP calls.

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

            mcp_tools = (await session.list_tools()).tools
            tools = to_anthropic_tools(mcp_tools)
            print("=== MCP tools discovered ===")
            for t in tools:
                print(f"  {t['name']}: {t['description'].splitlines()[0]}")
            print()

            messages = []
            question = ("Add a high-priority task to ship the release notes, "
                        "then show me all the open tasks.")
            print(f"you> {question}")
            reply = await answer(question, session, tools, messages)
            print("claude>", reply)


if __name__ == "__main__":
    asyncio.run(main())

Run python task_client.py and here is a real session — the [calling ...] lines come from the loop printing each MCP tool call as it makes it:

=== MCP tools discovered ===
  add_task: Add a new task. priority is 'low', 'medium', or 'high'.
  list_tasks: List tasks. status filters by 'open', 'done', or 'all'.
  complete_task: Mark the task with the given id as done.

you> Add a high-priority task to ship the release notes, then show me all the open tasks.
   [calling add_task({'title': 'Ship the release notes', 'priority': 'high'})]
   [calling list_tasks({'status': 'open'})]
claude> Done! I've added a high-priority task to ship the release notes. Here are all your open tasks:

- #1 [open] Ship the release notes (high)

That is the whole point of the project. One request — “add a high-priority task… then show me all the open tasks” — needed two distinct actions, and Claude recognized that, calling add_task and list_tasks and weaving the results into one reply. Both calls ran on the MCP server through session.call_tool; the client never knew those functions in advance, yet drove them correctly. That is tool chaining over MCP — the same mechanism a real agent uses to read a database, update a record, and call an API in service of one request, except every tool came from a server speaking an open protocol.


The Complete Client and Bridge

Here is the whole task_client.py — the client, the bridge, and the run loop in one file.

"""Connect Claude to the MCP task server and run a scripted interaction."""
import asyncio
import sys

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

# The Anthropic client reads your key from the ANTHROPIC_API_KEY env var.
client = anthropic.Anthropic()
MODEL = "claude-haiku-4-5"

SYSTEM = (
    "You manage a team's task tracker through MCP tools. "
    "When the user asks to add, list, or complete tasks, call the matching "
    "tool instead of guessing. A request may need more than one tool — call "
    "each one you need. Once you have the results, reply in plain text, "
    "concisely, suitable for a terminal."
)

# Launch task_server.py with this same Python interpreter.
server = StdioServerParameters(command=sys.executable, args=["task_server.py"])


def to_anthropic_tools(mcp_tools):
    """Convert MCP tool defs into the shape the Anthropic API expects."""
    return [
        {
            "name": t.name,
            "description": t.description or "",
            "input_schema": t.inputSchema,
        }
        for t in mcp_tools
    ]


async def answer(question, session, tools, messages):
    """Run one user turn to completion, executing tools via the MCP session."""
    messages.append({"role": "user", "content": question})

    while True:
        response = client.messages.create(
            model=MODEL, max_tokens=600, system=SYSTEM,
            tools=tools, messages=messages,
        )
        messages.append({"role": "assistant", "content": response.content})

        if response.stop_reason != "tool_use":
            return "".join(b.text for b in response.content if b.type == "text")

        # Run every tool Claude asked for this round, through MCP.
        tool_results = []
        for block in response.content:
            if block.type == "tool_use":
                print(f"   [calling {block.name}({block.input})]")
                result = await session.call_tool(block.name, block.input)
                text = "".join(
                    part.text for part in result.content if part.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})


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

            # Discover the server's tools and convert them for Claude.
            mcp_tools = (await session.list_tools()).tools
            tools = to_anthropic_tools(mcp_tools)
            print("=== MCP tools discovered ===")
            for t in tools:
                print(f"  {t['name']}: {t['description'].splitlines()[0]}")
            print()

            messages = []
            question = ("Add a high-priority task to ship the release notes, "
                        "then show me all the open tasks.")
            print(f"you> {question}")
            reply = await answer(question, session, tools, messages)
            print("claude>", reply)


if __name__ == "__main__":
    asyncio.run(main())

The model reads its key from the ANTHROPIC_API_KEY environment variable — you never put the key in the code. Set it once for your terminal session and run:

export ANTHROPIC_API_KEY="your-key-here"
python task_client.py

That’s a complete app: a server that owns the data, a client that discovers it, and Claude driving it through MCP — the architecture every production integration follows.


Take It Further

The tracker works; now make it yours. Each of these is a small extension to the two files you wrote:

  • Publish the server for Claude Desktop. Your task_server.py already speaks MCP over stdio, so a host like Claude Desktop can launch it directly. Add it to the host’s MCP-server config (pointing at your Python and the script path) and your tracker shows up as tools inside the app — the same server, a different host, zero code changes. That is “build once, use anywhere” in action.
  • Add more tools. Write a delete_task(task_id) or an assign_task(task_id, person) tool — add the decorated function to task_server.py, and it appears automatically the next time the client discovers tools. The bridge never changes.
  • Add auth. Real services don’t run open. Give the server a notion of the calling user (an environment variable, a token) and have each tool check it before acting, so the tracker only mutates tasks the caller is allowed to touch.
  • Use the resource. You exposed tasks://current as a resource but only drove the tools. Call session.read_resource("tasks://current") from the client and feed the result into Claude’s context as a user message — letting the model see the whole list without spending a tool call to fetch it.

Summary

You built a real MCP integration end to end. The server (task_server.py) wrapped an in-memory task store in three FastMCP tools and a resource, with type hints generating the schemas and docstrings serving as descriptions. The client spawned that server and discovered its tools by asking — the heart of the protocol. The bridge converted each MCP tool into an Anthropic tool definition and ran the tool-use loop, executing every call through session.call_tool so the work happened on the server. The proof is a single request that drove two MCP tools in one turn: Claude added a task, then listed the open ones, and explained the result — tool chaining over an open standard.

Key Concepts

  • FastMCP server — a FastMCP instance plus @mcp.tool() / @mcp.resource() functions; type hints and docstrings become the tool schemas and descriptions.
  • Discovery — the client learns the server’s tools at connect time via list_tools(), having known none in advance.
  • The bridge — convert MCP tools (name, description, inputSchema) to Anthropic tool defs (name, description, input_schema), then run the loop.
  • Execute via the session — each tool_use block is forwarded to the server with await session.call_tool(name, input); the error flag passes back through is_error.
  • Tool chaining over MCP — one request driving several server tools in a turn, the foundation of agents that act on real systems.

Why This Matters

This is the shape of every production AI integration: a team publishes one MCP server for its system, and any host — your program, an IDE, Claude Desktop — connects, discovers, and drives it without per-app glue. You wrote both sides by hand, so you now understand what those larger systems are doing underneath, and you can connect Claude to any service the same way: wrap it in a server, discover it from a client, bridge it to the model.


Next Steps

Continue to Module 5 - Embeddings & Semantic Search (next in the course)

Move from connecting tools to representing meaning — turn text into vectors and search by similarity instead of keywords.

Back to Module Overview

Return to the Model Context Protocol module overview


Continue Building Your Skills

You’ve connected an LLM to a real system through an open standard — written the server, discovered its tools from a client, bridged them to Claude, and watched the model chain MCP calls to get work done. That’s a genuine milestone: the wiring you did by hand is the same wiring behind every MCP-powered app, and you can now point Claude at any service by giving it a server to talk to. Next you’ll shift from connecting tools to representing meaning, turning text into vectors so your applications can search by what something means rather than the exact words it uses. Onward.