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:
- Python environments using
uv
- Tool calls in LLMs
- Agentic loops
- Using MCP to build a client and a server to add robust / reliable capabilities to an LLM agent
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]'
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 beforeuv
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
= llm.get_model('gemini-2.5-flash')
model
# Make an example query
= model.prompt('greet me briefly in French')
response 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
= llm.get_model('gemini-2.5-flash')
model
# 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
= "Take the word 'students', reverse it, then uppercase it."
prompt
= model.chain(
chain
prompt,=[reverse_string, uppercase, count_letters],
tools=before_call, after_call=after_call)
before_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
= FastMCP("Demo")
mcp
# 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__":
="stdio") mcp.run(transport
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
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:
- Click “Connect” to connect to the MCP service.
- Select the “Tools” tab.
- Click “List Tools” to see the tools that the service provides.
- 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)
= StdioServerParameters(
server_params ="uv",
command=["run", "mcp_service.py"],
args
)
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):
= await self.session.call_tool(mcp_tool.name, arguments=kwargs)
result return result.structuredContent
return wrapper
async def prepare_async(self):
print("Fetching tool list from MCP server...")
= await self.session.list_tools()
tools for tool in tools.tools:
= llm.Tool(
llm_tool =tool.name,
name=tool.description,
description=tool.inputSchema,
input_schema=self._make_mcp_wrapper(tool),
implementation
)self.add_tool(llm_tool)
print(f"Registered tool: {llm_tool.name}")
# Get the model (async version, since we need to await tool calls)
= llm.get_async_model('gemini-2.5-flash')
model
# 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()
= MCPToolbox(session)
toolbox await toolbox.prepare_async()
# Start a conversation
= model.conversation()
conversation = "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."
system_prompt
while True:
= input("> ").strip()
prompt if not prompt or prompt.lower() == 'exit':
break
= conversation.chain(
chain
prompt,=system_prompt,
system=[toolbox],
tools=before_call, after_call=after_call)
before_callasync for chunk in chain:
print(chunk, end="")
print() # Newline after completion
= None # only use the system prompt once
system_prompt
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 themcp
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 r
s 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 r
s 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.