By now our agent has tools from a few different places. Plain Python functions we wrote. Tools from an MCP server. Provider built-ins. Each has its own shape.
The agent should not have to know the difference. One small interface makes every tool look the same from where the agent stands.
Why a uniform interface earns its keep
Without one, the agent carries a branch for each kind of tool: call a local function this way, an MCP tool that way, keep their schemas in separate lists, remember which is which. Every new source of tools adds another branch, and the loop that was simple gets tangled.
With a uniform interface, the agent holds a list of tools, calls any of them the same way, and never asks where a tool came from. Adding a new source means writing one small adapter, not touching the agent.
The contract: BaseTool
Start with an abstract base class that says what it means to be a tool.
from abc import ABC, abstractmethod
class BaseTool(ABC):
name: str
description: str
@abstractmethod
async def execute(self, context: ExecutionContext, **kwargs):
"""Do the work. Every concrete tool implements this."""
async def __call__(self, context: ExecutionContext, **kwargs):
return await self.execute(context, **kwargs)
Two things are worth reading here.
execute is abstract, so every real tool must supply its own. That is the contract: whatever you are,
you know how to execute.
execute always takes context as its first argument. That is the one design decision that makes
the whole framework hang together, and it deserves a closer look.
Why every tool gets the context
The agent passes the ExecutionContext from the last post to every tool call, whether the tool
wants it or not.
Most tools ignore it. A calculator does not care about the run's history. But some tools genuinely
need it: a tool that reports progress writes to context.state, a tool that summarises the run so far
reads context.events. Handing the context to every tool means the agent runs one uniform call and
never has to know which tools are context-aware.
result = await tool(context, **arguments) # the same call for every tool, always
The agent makes one uniform call. Context-aware tools use the context; the rest ignore it.
Convenience has a cost worth naming now and returning to later: every tool, including ones you did not write, receives the full execution context. The security series comes back to that.
Wrapping a plain function: FunctionTool
Most tools are ordinary functions with no idea ExecutionContext exists. read_tickets(quarter) has
the signature it has. An adapter bridges the gap.
import inspect
class FunctionTool(BaseTool):
"""Wraps a plain Python function as a BaseTool."""
def __init__(self, func):
self.func = func
self.name = func.__name__
self.description = inspect.getdoc(func)
# does this function actually want the context?
self.needs_context = "context" in inspect.signature(func).parameters
async def execute(self, context: ExecutionContext, **kwargs):
if self.needs_context:
return self.func(context=context, **kwargs)
return self.func(**kwargs) # most functions land here
The trick is in __init__. It inspects the wrapped function once and records whether it has a
context parameter. At call time it passes context only to the functions that asked for it. A plain
read_tickets(quarter) works unchanged; a context-aware tool gets what it needs. The agent, meanwhile,
calls both the same way.
This is the schema-from-the-signature idea from earlier in the series, now doing a second job: the same inspection that builds the tool's schema also decides how to call it.
Wrapping an MCP tool
An MCP tool lives in another process behind a client session. The adapter looks different inside and identical outside.
class McpTool(BaseTool):
"""Wraps a tool exposed by an MCP server as a BaseTool."""
def __init__(self, session, name, description):
self.session = session
self.name = name
self.description = description
async def execute(self, context: ExecutionContext, **kwargs):
return await self.session.call_tool(self.name, arguments=kwargs)
Its execute sends a request over the session instead of calling a local function, exactly as the MCP
post described. From the agent's side it is a BaseTool like any other.
| FunctionTool | McpTool | |
|---|---|---|
| Wraps | A local Python function | A tool on an MCP server |
execute does | Calls the function | Sends a call_tool request |
| Where the work runs | Your process | The server's process |
| How the agent calls it | await tool(context, **args) | await tool(context, **args) |
Different inside, identical outside. The agent sees one BaseTool either way.
The last row is the whole point. Two very different tools, one call.
What to take from this
- One small abstract interface lets the agent treat every tool the same and keeps the loop simple as tool sources multiply.
- Every tool receives the execution context. Most ignore it; the ones that need the run's state or history read it, and the agent never branches.
- An adapter wraps each source. Inspecting a function's signature decides whether to pass it the context, so plain functions work unchanged.
The agent now has state and a uniform way to call tools. The next component is the one that actually talks to the model.