All posts
Fundamentals

One interface for every tool

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.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.

FunctionToolMcpTool
WrapsA local Python functionA tool on an MCP server
execute doesCalls the functionSends a call_tool request
Where the work runsYour processThe server's process
How the agent calls itawait tool(context, **args)await tool(context, **args)

Different inside, identical outside. The agent sees one BaseTool either way.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.