Tools and MCP Integration
Connecting external systems safely
Now that your agent can speak naturally and you can measure its performance, it's time to give it capabilities beyond conversation. Tools let your agent call external APIs, look up data, and take actions on behalf of users.
Why tools matter
Without tools, your agent can only respond based on what's in its training data and conversation context. Tools let the agent:
- Fetch real-time information (weather, stock prices, order status)
- Take actions in external systems (create tickets, send emails, update records)
- Access your private data (customer databases, internal docs)
Building a function tool
LiveKit uses the @function_tool decorator to expose Python functions to the LLM. The LLM decides when to call them based on the conversation context.
Let's add a weather lookup tool to our agent. This example uses the Open-Meteo API, which is free and requires no API key.
First, add the httpx library for making HTTP requests:
uv add httpx
Add the new imports:
import httpx
from livekit.agents import function_tool, RunContext, ToolError
Then update the Assistant class with the weather tool:
class Assistant(Agent):
def __init__(self) -> None:
super().__init__(
instructions=(
"You are an upbeat, slightly sarcastic voice AI for tech support. "
"Help the caller fix issues without rambling, and keep replies under 3 sentences. "
"You can also look up the weather if asked."
),
)
# The @function_tool decorator registers this method as a tool the LLM can call
@function_tool()
async def lookup_weather(
self,
context: RunContext, # Gives access to the session, speech handle, and user data
location: str, # Type hints help the LLM understand what arguments to pass
) -> dict:
"""Look up current weather for a location.
Args:
location: City name or location to get weather for.
"""
# The docstring above becomes the tool description the LLM sees
# when deciding which tool to call
async with httpx.AsyncClient() as client:
# First, geocode the location to get coordinates
geo_response = await client.get(
"https://geocoding-api.open-meteo.com/v1/search",
params={"name": location, "count": 1}
)
geo_data = geo_response.json()
if not geo_data.get("results"):
raise ToolError(f"Could not find location: {location}")
lat = geo_data["results"][0]["latitude"]
lon = geo_data["results"][0]["longitude"]
place_name = geo_data["results"][0]["name"]
# Get current weather for those coordinates
weather_response = await client.get(
"https://api.open-meteo.com/v1/forecast",
params={
"latitude": lat,
"longitude": lon,
"current": "temperature_2m,weather_code",
"temperature_unit": "fahrenheit"
}
)
weather = weather_response.json()
# Return a dict with the weather data
# The LLM will use this to form a natural response
return {
"location": place_name,
"temperature_f": weather["current"]["temperature_2m"],
"conditions": weather["current"]["weather_code"]
}
Here's what each part does:
@function_tool()registers the method as a callable tool for the LLMcontext: RunContextprovides access to the current session, the speech handle for interruption detection, and any custom user data you've stored- Type hints like
location: strtell the LLM what type of data to pass - The docstring is critical. The LLM reads this text to understand what the tool does and when it should be called. A vague docstring means the LLM might not call your tool when it should, or might call it incorrectly.
- The Args section in the docstring tells the LLM what each parameter means and what values are appropriate
- The return value (a dict here) gets serialized and sent back to the LLM, which then uses that data to craft a spoken response
When your tool returns data, it gets serialized to a string and sent back to the LLM. The LLM reads the result and generates a spoken response based on it. The agent never reads the raw data aloud. If your weather tool returns {"temperature_f": 72, "conditions": "sunny"}, the LLM might say "It's a beautiful 72 degrees and sunny right now!"
The ToolError exception sends an error message back to the LLM instead of a result. When the user asks about an invalid location, the LLM receives this error and might respond with "I couldn't find that location. Could you double-check the spelling or try a different city?"
Testing the weather tool
Run the agent and test the weather tool:
uv run agent.py console
Try these prompts:
- "What's the weather like in San Francisco?"
- "How about in Tokyo?"
- "What about on Mars?" (should trigger the error)
Watch the console logs to see when the tool is called and what it returns. You'll see the tool invocation, the API calls, and the data that gets sent back to the LLM.
Adding MCP servers
Model Context Protocol (MCP) lets you connect to external tool servers. This is useful when:
- You want to share tools across multiple agents
- Tools are managed by a separate team or service
- You're integrating with third-party MCP-compatible systems
Let's connect to the LiveKit documentation MCP server. This turns your agent into a voice interface for searching LiveKit docs.
First, install the MCP dependencies:
uv add "livekit-agents[mcp]"
Add the MCP import:
from livekit.agents import mcp
Connect to the MCP server in your session. Your existing configuration, including fallback adapters, turn detection, preemptive generation, and metrics, all remain active.
session = AgentSession(
# ... existing config ...
mcp_servers=[
mcp.MCPServerHTTP(url="https://docs.livekit.io/mcp")
]
)
Update your agent instructions to let it know about the new capability:
class Assistant(Agent):
def __init__(self) -> None:
super().__init__(
instructions=(
"You are an upbeat, slightly sarcastic voice AI for tech support. "
"Help the caller fix issues without rambling, and keep replies under 3 sentences. "
"You can look up the weather if asked. You can also answer questions about "
"LiveKit by searching the documentation. When users ask about LiveKit "
"features, APIs, or how to build something, use the docs search tools "
"to find accurate information."
),
)
How MCP tool discovery works
When the session starts, it connects to the MCP server and fetches a list of available tools. Each MCP tool has a name and description, just like your local @function_tool methods.
These MCP tools are automatically registered alongside your local tools. The LLM sees all of them and can call any of them. The tool descriptions from the MCP server guide the LLM on when to use each tool.
For example, the LiveKit MCP server exposes tools like docs_search and get_pages. When a user asks "How do I publish a track?", the LLM sees the docs_search tool description, decides it's relevant, calls it with the query, and uses the results to answer the question.
Testing MCP tools
Run the agent and test the MCP integration:
uv run agent.py console
Try asking about LiveKit:
- "How do I create a room with the LiveKit API?"
- "What STT providers does LiveKit support?"
- "How do I add a function tool to my agent?"
Watch the console output to see when the agent calls MCP tools. You'll see the tool name, parameters, and results.
You can also still ask about the weather. The agent now has both capabilities available.
Testing all tools together
Now test the agent with all its tools available. The LLM will choose which tool to use based on what the user asks.
uv run agent.py console
Try a mix of questions:
- "What's the weather in New York?"
- "How do I use function tools in LiveKit?"
Watch how the agent selects the right tool for each question. The console logs show which tool was called and with what parameters.
Tool best practices
Good tool design is critical for reliable behavior:
- Be specific about what the tool does and when to use it
- Keep tools fast (under 2 seconds ideally) or provide user feedback during long operations
- Handle errors gracefully with the
ToolErrorexception - Return meaningful data that helps the LLM form a good response
Handling long-running tools
Some tools take time. For these, you'll want to give the user feedback and handle potential interruptions.
Use context.session.say to let the user know you're working on it before starting the expensive operation:
@function_tool()
async def search_knowledge_base(
self,
context: RunContext,
query: str,
) -> str:
"""Search the knowledge base for relevant articles."""
# Let the user know we're working on it
await context.session.say("Let me search for that...")
# Your actual search logic here
results = await do_expensive_search(query)
return results
If your tool takes external actions that can't be rolled back, disable interruptions at the start:
@function_tool()
async def submit_order(self, context: RunContext, order_id: str) -> str:
"""Submit an order for processing."""
context.disallow_interruptions()
# Safe to proceed knowing we won't be interrupted
result = await process_order(order_id)
return f"Order {order_id} submitted successfully"
Dynamic tool management
You can add or remove tools at runtime based on conversation state:
# Add a tool dynamically
await agent.update_tools(agent.tools + [new_tool])
# Remove a tool
await agent.update_tools(agent.tools - [old_tool])
This is useful for progressive disclosure, where you only show certain tools after the user has been authenticated or reached a certain point in the workflow.
In the final lesson, you'll build production workflows for collecting consent and escalating to human agents.