The plan

Rover connects to the notes server, gets its menu, and uses it — alongside its own file tools.

pip install mcp

The MCP client library needs Python 3.10 or newer, which is why the course asked for it.

The client

MCP’s Python client is asynchronous, so this file is async. Save as rover_mcp.py:

import asyncio
import sys

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

from llm import connect

llm = connect("ollama:gemma4")


def to_our_shape(mcp_tool):
    """An MCP tool description, in the shape our tools have used since Module 2."""
    return {
        "name": mcp_tool.name,
        "description": mcp_tool.description or "",
        "input_schema": mcp_tool.input_schema,
    }


async def main():
    # sys.executable, not "python3": the server must run on the same interpreter
    # you installed mcp into, not whichever python happens to be on PATH.
    server = StdioServerParameters(command=sys.executable, args=["notes_server.py"])

    async with stdio_client(server) as (read, write):
        async with ClientSession(read, write) as session:
            await session.initialize()                      # the handshake from 5.2

            listed = await session.list_tools()             # tools/list from 5.2
            print("The server offers:", [t.name for t in listed.tools])

            TOOLS = [to_our_shape(t) for t in listed.tools]

            messages = [{"role": "user", "content":
                         "Note that the report is due Friday, then list my notes."}]

            for _ in range(10):
                reply = llm.send(messages, TOOLS)
                if reply.text:
                    print(reply.text)

                messages.append({"role": "assistant", "content": reply.text,
                                 "tool_calls": reply.tool_calls})
                if not reply.wants_tool:
                    break

                results = []
                for call in reply.tool_calls:
                    print(f"[{call.name} {call.arguments}]")
                    out = await session.call_tool(call.name, call.arguments)  # tools/call
                    text = "".join(c.text for c in out.content
                                   if getattr(c, "text", None))
                    results.append({"id": call.id, "content": text,
                                    "is_error": bool(out.is_error)})

                messages.append({"role": "tool_results", "results": results})


asyncio.run(main())

What each step is

Four lines carry the whole idea:

async with stdio_client(server) as (read, write):     # start it, get the pipes
    async with ClientSession(read, write) as session:  # wrap them in the protocol
        await session.initialize()                     # the handshake from 5.2
        listed = await session.list_tools()            # tools/list from 5.2

Then the translation, which is the whole point of the lesson:

def to_our_shape(mcp_tool):
    return {"name": mcp_tool.name,
            "description": mcp_tool.description or "",
            "input_schema": mcp_tool.input_schema}

Three fields in, three fields out. Look at how little happens there. An MCP tool description
and the tool dictionaries you have been writing since Module 2 are the same thing, and
this function is the proof.

Two details worth noticing:

The loop is unchanged. It is the loop from Lesson 1.7, line for line. The only difference
is where TOOLS came from and that run_tool has been replaced by session.call_tool. An
MCP tool is not a special kind of tool — it is a tool whose implementation happens to live in
another process.

On the wire it is inputSchema; in Python it is input_schema. Lesson 5.2 showed you the
camelCase JSON, because that is what actually travels. The Python library renames it to suit
Python. Both are correct, and knowing that the wire and the binding can differ will save you
confusion the first time you compare a packet capture with your code.

Some providers ship a helper that skips this translation for you. You have now written it, so
you know exactly what such a helper does — about six lines.

Watch it happen

Run it:

python3 rover_mcp.py

You should see the server’s tool names, then Rover adding a note and listing them. Check
notes.json.

Two processes. Your agent asked a separate program to do something, over a pipe, in a format
either of them could have implemented from the spec.

Both menus at once

The real payoff is mixing. Rover’s own file tools plus the server’s:

TOOLS = [read_file_tool, list_files_tool] + [to_our_shape(t) for t in listed.tools]

Now ask: “Read notes.txt and save each line as a separate note.”

Rover uses its own tool to read the file and the server’s tool to store each line. It has no
idea some tools are local and some are a subprocess. It sees one menu, because that is all a
menu ever was.

The other kind of MCP client — provider-specific

There is a second way, worth knowing about even though it is not portable.

Some providers will connect to a hosted MCP server for you. You name the server in the
request, the provider’s infrastructure speaks MCP to it, and you never run a subprocess or
touch a client library. Anthropic’s version looks like this:

# Anthropic-specific. Other providers have their own version, or none.
response = client.beta.messages.create(
    model="claude-opus-5",
    max_tokens=16000,
    betas=["mcp-client-2025-11-20"],
    mcp_servers=[{"type": "url", "name": "notes", "url": "https://example.com/mcp"}],
    tools=[{"type": "mcp_toolset", "mcp_server_name": "notes"}],
    messages=[{"role": "user", "content": "What notes do I have?"}],
)

Both parts are required there: mcp_servers says where the server is, and the mcp_toolset
entry in tools says to actually use it. Sending the first without the second is rejected — a
common first-try error.

Which to use:

Situation Route
The server touches your machine The subprocess client you just wrote
You want it to work on any provider The subprocess client
A hosted server, and your provider offers this Either — theirs is less code

Note what the hosted route costs you. Your conversation and the server’s responses pass
through the provider’s infrastructure, and the feature exists on their timetable, not yours.
The client you wrote in this lesson works with every provider in this course and with any
future one, because it only depends on the protocol.

An MCP client is four calls: start it, handshake, list, translate. The helper does the
translation you could now write yourself.

Try this before the next lesson

Give Rover both menus and ask for something that needs both.

Then stop the server file from existing — rename notes_server.py — and run again. Read the
error. Knowing what a dead MCP server looks like is worth the thirty seconds.