Giving Agents Tools: Function Calling and MCP, Explained
How agents actually do things, how to design tools they use well, and where MCP fits
An agent without tools can think and reply, but it cannot act. Tools are the difference between an assistant that answers you and one that gets things done. In the first post in this series I said an agent is a model, a set of tools, and the freedom to choose between them. We have leaned on tools in every post since, including the retriever in the agentic RAG build. This post is about the tools themselves: how the model actually calls them, how to design ones it uses well, and how MCP lets you hand your agent tools you never wrote.
How tool calling actually works#
People imagine the model reaching out and running code. It does not. The model only ever produces text. The trick is that you describe your tools to the model, and when it wants one, it produces a structured request that says "call get_weather with city=Tokyo." Your code sees that request, runs the real function, and feeds the result back into the conversation. The model reads the result and decides what to do next.
So tool calling is a handshake, not magic. The model picks the tool and the arguments. Your code does the actual work. That separation is the whole reason agents are safe to build: the model never touches your systems directly, it only ever asks.
In LangChain, defining a tool is a decorator and a function. The docstring and the type hints are not decoration, they are what the model reads to decide when and how to call it.
from langchain.agents import create_agent
from langchain.tools import tool
@tool
def get_weather(city: str) -> str:
"""Get the current weather for a city. Use when the user asks about weather."""
# In real life this hits a weather API.
return f"It is 18C and clear in {city}."
agent = create_agent(model="openai:gpt-4o", tools=[get_weather])
agent.invoke({"messages": [{"role": "user", "content": "what's the weather in Lagos?"}]})The model sees a tool called get_weather that takes a city string, matches it to the question, and emits the call. You did not write any routing logic. The description did the routing.
What makes a tool the model can actually use#
This is where most agent problems actually live. The model is only as good as the tools you describe to it, and a few habits make a real difference.
Write the description like you are briefing a new hire. "Use when the user asks about weather" tells the model exactly when to reach for it. A vague description like "weather tool" leaves it guessing.
Keep the schema tight. Ask for exactly the arguments you need, with clear names and types. A city: str is easy for the model to fill. A single params: dict of mystery keys is not.
Give each tool one job. It is tempting to build one mega-tool that can read, write, update, and delete. Resist it. The model's decision gets fuzzier the more a single tool can do, and your logs get useless. Split read_order and cancel_order into separate tools so the model's intent is obvious and you can see exactly what it did.
Validate inputs and return useful errors. The model will call tools with imperfect arguments. If something is wrong, return a message that tells it what to fix, like "no city named that, did you mean one of these," rather than throwing. The model can read that and correct itself on the next turn.
Treat tool descriptions and errors as part of your prompt, because they are. The model reads both. Time spent wording them well pays back every single run.
The too-many-tools problem#
There is a trap waiting once your agent gets useful: you keep adding tools. It works fine at five. At fifty it falls apart, for two reasons.
First, every tool definition costs tokens. A pile of tools can quietly eat tens of thousands of tokens of context before the user has said a word, which is slower and more expensive on every call.
Second, and worse, the model gets less accurate at picking the right tool as the list grows. Choosing the best of fifty options is genuinely harder than choosing the best of five, for a model just as for a person.
The fix is the same one from the multi-agent post: do not put everything in front of the model at once. Group related tools behind specialised subagents, or use a tool-search approach where the model looks up the tool it needs instead of carrying all of them all the time. If you find yourself with dozens of tools on one agent, that is the signal to split.
MCP: tools you did not have to write#
Here is the problem MCP solves. Every team was wrapping the same services, GitHub, Slack, a database, in their own one-off tool code, in slightly different ways, over and over. The Model Context Protocol is an open standard for how a program exposes tools and context to a model. A service publishes an MCP server once, and any MCP-aware agent can use its tools without you writing a custom wrapper.
The practical payoff: hundreds of MCP servers already exist, and you can plug them straight into a LangChain agent with the langchain-mcp-adapters library. Your agent gets new abilities without you writing the tool layer.
pip install langchain-mcp-adapters langgraph "langchain[openai]"import asyncio
from langchain_mcp_adapters.client import MultiServerMCPClient
from langchain.agents import create_agent
async def main():
client = MultiServerMCPClient({
"math": { # a local server, run as a subprocess
"transport": "stdio",
"command": "python",
"args": ["/path/to/math_server.py"],
},
"weather": { # a remote server, over HTTP
"transport": "http",
"url": "http://localhost:8000/mcp",
},
})
tools = await client.get_tools() # discover tools from both servers
agent = create_agent("openai:gpt-4o", tools)
result = await agent.ainvoke(
{"messages": [{"role": "user", "content": "what's (3 + 5) x 12?"}]}
)
print(result["messages"][-1].content)
asyncio.run(main())Notice you never named the individual tools. The agent discovered them from the servers. Add a server, and the agent gains its tools automatically.
Writing your own MCP server is just as light, using FastMCP. This is the same idea as the @tool decorator above, except now any MCP client can use it, not just this one script.
from fastmcp import FastMCP
mcp = FastMCP("Math")
@mcp.tool()
def add(a: int, b: int) -> int:
"""Add two numbers."""
return a + b
@mcp.tool()
def multiply(a: int, b: int) -> int:
"""Multiply two numbers."""
return a * b
if __name__ == "__main__":
mcp.run(transport="stdio")If you have used the tools inside this very assistant, you have already seen MCP in action. The same protocol that lets an agent reach a weather server is the one wiring up real integrations behind the scenes.
Plain tool or MCP server?#
Reach for a plain @tool function when the capability is specific to one app and only that app will ever use it. It is the least ceremony, and most tools start here.
Build an MCP server when the capability should be reusable across agents, owned by a separate team, or shared more widely. The standard interface is the whole point: write it once, and anything that speaks MCP can use it. The nice thing is you are not locked in. A function you wrote as a local tool becomes an MCP tool by moving it behind a FastMCP server, with the body unchanged.
Wrapping up#
Tools are how an agent stops talking and starts doing. The model only ever asks to call a tool; your code does the work and hands back the result. Good tools have clear descriptions, tight schemas, one job each, and helpful errors, and you avoid drowning the model in too many at once. MCP takes this further by making tools a shared standard, so your agent can use capabilities you never wrote and expose its own to the world.
This post only scratched the surface of MCP. Next in this series, I get hands-on and build MCP servers with FastMCP: the anatomy of a server, the rules that matter, stdio versus remote, the pitfalls I hit, and how to test and deploy.
Folarin Akinloye is an AI Engineer based in London, UK. He builds production-ready agentic AI systems, multi-agent architectures, and sophisticated RAG implementations, and writes about the engineering decisions behind them.