W5D2: Agents and the Model Context Protocol

In this exercise, we’ll connect an LLM agent to tools, first defined locally (within the same Python file) and then remotely using the Model Context Protocol.

This exercise will give you experience with:

References:

We’ll use Gemini again because of their generous free tier, but you can use any LLM that the llm package supports – see the extensive list of plugins.

Setup

Make a new directory (folder) for this activity – mcp-demo will do (but do not call it mcp). Install the llm, llm-gemini, and mcp[cli] packages. Here’s how I did it using uv (but you’re welcome to use a different package manager if you prefer):

cd mcp-demo
uv init --bare
uv add llm llm-gemini 'mcp[cli]'
Tip

To install uv, see the installation instructions. I recommend:

  • On macOS, use brew.
  • On Windows, use winget. You’ll need to close your terminal or VS Code and reopen it before uv will work. (You might even need to log out and log back in, or restart your computer, if it still doesn’t work.)

I don’t recommend pip install uv, but it does work in a pinch.

In VS Code, run the command “Python: Select Interpreter” and choose the one in the .venv directory that uv created. (It may prompt you to do this automatically.)

Now configure your Gemini API key. You can either set it as an environment variable or (easier) use the llm CLI’s key management features:

uv run llm keys set gemini

It will prompt you to enter your API key. Paste it at the prompt. Note that you will not see the key as you type it.

Warmup

Let’s start by making sure the llm package is working. Create a new Python file named hello_llm.py and add the following code:

import llm
from typing import Optional

# Configure which model to use
model = llm.get_model('gemini-2.5-flash')

# Make an example query
response = model.prompt('greet me briefly in French')
print(response.text())

Run that file to ensure everything is working:

uv run hello_llm.py

The llm package is a high-level interface for interacting with LLMs, both on the command line and also in Python code.

Local Tools

Create a new Python file named local_tools.py and add the following code:

import llm
from typing import Optional

# Configure which model to use
model = llm.get_model('gemini-2.5-flash')

# Define some tools.
def reverse_string(text: str) -> str:
    """Reverse the characters in the given string."""
    return text[::-1]

def uppercase(text: str) -> str:
    """Convert the string to uppercase."""
    return text.upper()

def count_letters(text: str, letter: str) -> int:
    """Count the number of occurrences of a given letter in the string."""
    return text.count(letter)

# Debugging hook to see tool calls
def before_call(tool: Optional[llm.Tool], tool_call: llm.ToolCall):
    print(f"About to call tool {tool.name} with args {tool_call.arguments}")
def after_call(tool: llm.Tool, tool_call: llm.ToolCall, tool_result: llm.ToolResult):
    print(f"{tool.name} returned {tool_result.output}")

# Prompt that requires tool chaining
prompt = "Take the word 'students', reverse it, then uppercase it."

chain = model.chain(
    prompt,
    tools=[reverse_string, uppercase, count_letters],
    before_call=before_call, after_call=after_call)

print(chain.text())

Run that file to see the agent in action.

MCP

Now let’s modularize this system by defining the tools in a separate service that implements the Model Context Protocol (MCP).

This means we’re going to create two separate Python files: one for the MCP service that defines the tools, and another for the agent that uses those tools.

MCP Service

Create a new Python file named mcp_service.py and add the following code:

from mcp.server.fastmcp import FastMCP

# Create an MCP service
mcp = FastMCP("Demo")

# Define some tools.
@mcp.tool()
def reverse_string(text: str) -> str:
    """Reverse the characters in the given string."""
    return text[::-1]

# TODO: Add the other two tools here (uppercase and count_letters). Don't forget the @mcp.tool() decorator!

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

This code uses the mcp package to create a simple MCP server. See the docs on GitHub for more details.

Test this service by running the following command in your terminal:

uv run mcp dev mcp_service.py
Note

This starts the MCP Inspector, which needs nodejs to run. If you don’t have nodejs installed, you can skip this step.

In the web page that pops up:

  1. Click “Connect” to connect to the MCP service.
  2. Select the “Tools” tab.
  3. Click “List Tools” to see the tools that the service provides.
  4. Click on a tool to see its details. Fill in the input fields and click “Call Tool” to test it.

MCP Client

Now we need to adapt the agent code to use the MCP service we just created.

Create a new Python file named mcp_client.py and add the following code (don’t worry about how MCPToolbox works; it’s a wrapper that makes MCP tools available to the llm package):

import asyncio
from typing import Optional
import llm

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

# How to run our MCP service
# (typically this is configured using a JSON config file)
server_params = StdioServerParameters(
    command="uv",
    args=["run", "mcp_service.py"],
)

class MCPToolbox(llm.Toolbox):
    """An LLM toolbox that calls MCP tools."""

    def __init__(self, session: ClientSession):
        self.session = session

    def _make_mcp_wrapper(self, mcp_tool: Tool):
        """Create a wrapper function that calls the given MCP tool."""
        async def wrapper(**kwargs):
            result = await self.session.call_tool(mcp_tool.name, arguments=kwargs)
            return result.structuredContent
        return wrapper

    async def prepare_async(self):
        print("Fetching tool list from MCP server...")
        tools = await self.session.list_tools()
        for tool in tools.tools:
            llm_tool = llm.Tool(
                name=tool.name,
                description=tool.description,
                input_schema=tool.inputSchema,
                implementation=self._make_mcp_wrapper(tool),
            )
            self.add_tool(llm_tool)
            print(f"Registered tool: {llm_tool.name}")


# Get the model (async version, since we need to await tool calls)
model = llm.get_async_model('gemini-2.5-flash')

# Debugging hook to see tool calls
def before_call(tool: Optional[llm.Tool], tool_call: llm.ToolCall):
    if not tool:
        return
    print(f"About to call tool {tool.name} with args {tool_call.arguments}")

def after_call(tool: llm.Tool, tool_call: llm.ToolCall, tool_result: llm.ToolResult):
    print(f"{tool.name} returned {tool_result.output}")



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

            # Start a conversation
            conversation = model.conversation()
            system_prompt = "Call tools as much as possible to answer the user's requests. If the user gives only a single word, reverse it and uppercase it using tools."

            while True:
                prompt = input("> ").strip()
                if not prompt or prompt.lower() == 'exit':
                    break
                chain = conversation.chain(
                    prompt,
                    system=system_prompt,
                    tools=[toolbox],
                    before_call=before_call, after_call=after_call)
                async for chunk in chain:
                    print(chunk, end="")
                print()  # Newline after completion
                system_prompt = None # only use the system prompt once

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

Ask an LLM to explain any parts of this code you don’t understand. A few things to notice:

  • It uses async / await syntax because that’s how the mcp package works.
  • It uses a conversation object to maintain context across multiple user inputs.
  • It includes a system_prompt to guide the agent’s behavior. You might want to modify this prompt to see how it affects the agent’s behavior.
  • It includes a loop to allow multiple user inputs until the user types “exit”.

Run that file to see the agent in action, now using the MCP service to call the tools:

uv run mcp_client.py

You’ll see a prompt (>) where you can type inputs. For example, you can type a single word, or a phrase like “Please reverse the word ‘programmer’ and count how many rs are in it.” It should call the appropriate tools to fulfill your request.

It should also remember the context of previous inputs. For example, if you first ask it to reverse and uppercase “students”, and then just type “Now do ‘programmer’”, it should understand that you want to reverse and uppercase “programmer” as well.

Try asking it to count how many rs there are in the word programmer, or strawberry.

Exercise

Start by adding another tool of your choice.

For a fun example, have a tool to check a guessed number, and ask the agent to guess your number between 1 and 10.

Then, add a “search_courses” tool from your course advisor bot.