Skip to content

5. Tools and MCP

Intermediate · 13 min read

An agent is only as good as its tools. A model with clear, well-shaped tools takes fewer steps and makes fewer mistakes; give it vague or overlapping tools and it guesses. This page covers the tool layer that sits between the model and the real world, then MCP, the open standard for sharing tools across apps.

The basics (what a tool call is, the message protocol, the loop) are in Structured output & tool calling. This page is about making tools robust.

5.1 A tool registry from type hints

Frameworks generate a tool's JSON schema from the function's signature and docstring. Here's that idea from scratch, so you can see exactly what the model receives:

import inspect
import json
import typing
from typing import Literal

REGISTRY: dict[str, dict] = {}
PY_TO_JSON = {str: "string", int: "integer", float: "number", bool: "boolean"}

def tool(fn):
    """Register fn as a tool: name, docstring as description, JSON schema from the type hints."""
    hints = typing.get_type_hints(fn)
    props, required = {}, []
    for name, param in inspect.signature(fn).parameters.items():
        hint = hints[name]
        if typing.get_origin(hint) is Literal:
            props[name] = {"type": "string", "enum": list(typing.get_args(hint))}
        else:
            props[name] = {"type": PY_TO_JSON[hint]}
        if param.default is inspect.Parameter.empty:
            required.append(name)
    REGISTRY[fn.__name__] = {"fn": fn, "schema": {
        "type": "function",
        "function": {"name": fn.__name__, "description": inspect.getdoc(fn),
                     "parameters": {"type": "object", "properties": props, "required": required}}}}
    return fn

@tool
def refund_order(order_id: str, reason: Literal["damaged", "late", "wrong_item"], amount_inr: int) -> dict:
    """Refund an order. Use only after confirming the order exists. Returns the refund ID."""
    return {"refund_id": f"rf_{order_id}", "amount_inr": amount_inr}

@tool
def get_order_status(order_id: str) -> dict:
    """Shipping status of an order. Use when the user gives an order ID like A100."""
    return {"A100": {"status": "shipped", "eta": "Friday"}}.get(order_id, {"error": f"order {order_id} not found"})

print(json.dumps(REGISTRY["refund_order"]["schema"], indent=1))
Output
{
 "type": "function",
 "function": {
  "name": "refund_order",
  "description": "Refund an order. Use only after confirming the order exists. Returns the refund ID.",
  "parameters": {
   "type": "object",
   "properties": {
    "order_id": {
     "type": "string"
    },
    "reason": {
     "type": "string",
     "enum": [
      "damaged",
      "late",
      "wrong_item"
     ]
    },
    "amount_inr": {
     "type": "integer"
    }
   },
   "required": [
    "order_id",
    "reason",
    "amount_inr"
   ]
  }
 }
}

That JSON is the whole interface: the model sees the name, description and parameter schema, never your code. Write descriptions as instructions for the model: what it does, when to use it, what it returns.

5.2 Validate before you run

The model's arguments are untrusted input. Check them against the schema before anything touches a real system, and turn every problem into a readable observation:

def run_tool(name: str, args_json: str) -> str:
    if name not in REGISTRY:
        return f"Error: unknown tool {name!r}. Available: {sorted(REGISTRY)}"
    try:
        args = json.loads(args_json)
    except json.JSONDecodeError as err:
        return f"Error: arguments are not valid JSON ({err.msg})."
    params = REGISTRY[name]["schema"]["function"]["parameters"]
    problems = [f"missing required argument {r!r}" for r in params["required"] if r not in args]
    problems += [f"unexpected argument {k!r}" for k in args if k not in params["properties"]]
    types = {"string": str, "integer": int, "number": (int, float), "boolean": bool}
    for key, value in args.items():
        spec = params["properties"].get(key)
        if spec and not isinstance(value, types[spec["type"]]):
            problems.append(f"{key!r} must be {spec['type']}, got {type(value).__name__}")
        if spec and "enum" in spec and value not in spec["enum"]:
            problems.append(f"{key!r} must be one of {spec['enum']}")
    if problems:
        return "Error: " + "; ".join(problems) + ". Fix the arguments and try again."
    return json.dumps(REGISTRY[name]["fn"](**args))

for name, args in [("get_order_status", '{"order_id": "A100"}'),
                   ("refund_order", '{"order_id": "A100", "reason": "broken", "amount_inr": "1200"}'),
                   ("refund_order", '{"order_id": "A100"}'),
                   ("cancel_order", '{"order_id": "A100"}')]:
    print(f"{name}({args})\n  -> {run_tool(name, args)}")
Output
get_order_status({"order_id": "A100"})
  -> {"status": "shipped", "eta": "Friday"}
refund_order({"order_id": "A100", "reason": "broken", "amount_inr": "1200"})
  -> Error: 'reason' must be one of ['damaged', 'late', 'wrong_item']; 'amount_inr' must be integer, got str. Fix the arguments and try again.
refund_order({"order_id": "A100"})
  -> Error: missing required argument 'reason'; missing required argument 'amount_inr'. Fix the arguments and try again.
cancel_order({"order_id": "A100"})
  -> Error: unknown tool 'cancel_order'. Available: ['get_order_status', 'refund_order']

Every failure names exactly what to fix, so the model can correct itself on the next step instead of guessing. In production, use Pydantic models or a JSON-schema validator for the same job (see Pydantic for GenAI).

5.3 Good tool outputs

Do Why
Return compact, relevant fields A 5,000-token API dump fills the context and buries the answer
Truncate long text and say so ("showing 10 of 240 rows") The model knows more exists and can page or filter
Return errors as text with the fix ("order not found: check the ID format A123") The model can recover instead of looping
Include IDs the next step needs Saves an extra lookup call
Be deterministic and idempotent where possible Retries don't double-refund
Never return secrets or raw internal errors Tool output goes straight into the model's context, and possibly to the user

5.4 Choosing the right tools out of many

With 50+ tools, models choose worse, and every tool definition costs prompt tokens on every call. Two common fixes: group tools into specialised agents (multi-agent), or retrieve only the few tools relevant to the current request:

import math
import re
from collections import Counter

CATALOG = {
    "get_order_status": "shipping status delivery tracking of an order",
    "refund_order": "refund money back for an order damaged late wrong item",
    "update_address": "change delivery address of an order",
    "search_policies": "search company policy documents returns warranty",
    "create_invoice": "create a GST invoice bill for a business order",
    "book_callback": "schedule a phone call back with support",
}

def vec(text):
    return Counter(re.findall(r"[a-z]+", text.lower()))

def similarity(a, b):
    dot = sum(a[t] * b[t] for t in a)
    return dot / (math.sqrt(sum(v * v for v in a.values())) * math.sqrt(sum(v * v for v in b.values())) or 1)

def pick_tools(request: str, k: int = 3) -> list[str]:
    q = vec(request)
    return sorted(CATALOG, key=lambda name: -similarity(q, vec(name.replace("_", " ") + " " + CATALOG[name])))[:k]

for request in ["My laptop arrived damaged, I want my money back", "Please change the delivery address"]:
    print(f"{request!r}\n  -> send only: {pick_tools(request)}")
Output
'My laptop arrived damaged, I want my money back'
  -> send only: ['refund_order', 'book_callback', 'get_order_status']
'Please change the delivery address'
  -> send only: ['update_address', 'get_order_status', 'refund_order']

Real systems embed tool descriptions with an embedding model; the idea is the same. Always keep a small set of "always available" tools (like asking the user a clarifying question).

5.5 MCP: tools as a shared standard

The Model Context Protocol (MCP) lets you build a tool server once and use it from any MCP-capable app: Claude, IDEs, LangGraph or OpenAI agents, your own code.

flowchart LR
    subgraph Host [Host app: chat app, IDE, your agent]
        C1[MCP client] --- C2[MCP client]
    end
    C1 <-->|stdio| S1[Orders server<br/>tools: get_order_status, refund_order]
    C2 <-->|Streamable HTTP| S2[Docs server<br/>resources + search tool]
Piece Role
Server Exposes tools (actions), resources (readable data) and prompts (templates)
Client Lives in the host; one connection per server; lists and calls the server's tools
Transport stdio for local servers (a child process), Streamable HTTP for remote ones

A server is a few lines with the official Python SDK:

# no-run — pip install mcp ; save as orders_server.py
from mcp.server.fastmcp import FastMCP

mcp = FastMCP("orders")

@mcp.tool()
def get_order_status(order_id: str) -> dict:
    """Shipping status of an order. Use when the user gives an order ID like A100."""
    return lookup_order(order_id)

@mcp.resource("policy://refunds")
def refund_policy() -> str:
    """The current refund policy text."""
    return open("refund_policy.md").read()

if __name__ == "__main__":
    mcp.run()                     # stdio by default; mcp.run(transport="streamable-http") for remote use

And a client that lists and calls its tools:

# no-run
from mcp import ClientSession, StdioServerParameters
from mcp.client.stdio import stdio_client

async def main():
    params = StdioServerParameters(command="python", args=["orders_server.py"])
    async with stdio_client(params) as (read, write):
        async with ClientSession(read, write) as session:
            await session.initialize()
            tools = await session.list_tools()                  # name, description, input schema for each tool
            result = await session.call_tool("get_order_status", {"order_id": "A100"})

For the full picture (the protocol, servers, hosts, remote servers with OAuth and MCP security), see the MCP series.

To give MCP tools to a LangChain or LangGraph agent, the langchain-mcp-adapters package converts them: tools = await MultiServerMCPClient({...}).get_tools(), then pass tools to create_agent.

5.6 Tool and MCP security

  • Tools run with your permissions. Scope each one tightly (read-only where possible, allow-listed tables and endpoints) and authorise every call against the end user, not the agent's service account.
  • Only connect trusted MCP servers. Tool descriptions and results go into the model's context, so a malicious server can inject instructions.
  • Destructive tools (refund, delete, send) need approval gates (see Safety and human approval).

Interview questions

What makes a good tool for an LLM agent?

A clear name and a description that says what it does, when to use it and what it returns; a strict argument schema with types and enums; compact, relevant outputs; errors returned as actionable text; idempotent behaviour; and the least privilege needed. Few distinct tools beat many overlapping ones.

Why validate tool arguments if the model already follows a schema?

Model output is untrusted: it can still send wrong types, invented enum values, missing fields or values a user injected. Validating before execution protects real systems, and returning precise errors lets the model fix the call on the next step.

What is MCP and why does it matter?

The Model Context Protocol is an open standard for connecting AI applications to tools and data. Servers expose tools, resources and prompts over stdio or Streamable HTTP, and any MCP-capable host can use them. It means building an integration once instead of once per framework or app. Servers must be trusted, because their descriptions and outputs enter the model's context.

An agent has 80 tools and keeps picking the wrong one. What do you do?

Reduce the choice: retrieve the few tools relevant to each request using embeddings of their descriptions, or split them across specialised sub-agents behind a router. Also improve names and descriptions, merge overlapping tools, and evaluate tool-selection accuracy on a test set.

Practice

  • Add a bool parameter notify_customer to refund_order and check the generated schema.
  • Send run_tool("refund_order", ...) a negative amount. Add a rule that rejects it with a clear message.
  • Add a "track my parcel" request to 5.4 and check which tools are picked.

Next: 6. Agents in LangGraph Back to: AI Agents overview · Notes overview