Agent Tools & MCP

Extend agents with tools, Model Context Protocol, and custom integrations

intermediate40 min
On this page

The Big Picture

Large language models are reasoning engines. They excel at understanding context, planning, and making decisions—but they cannot natively interact with the external world. Tool use extends LLMs with agency: the ability to call functions, query databases, send emails, execute code, and gather real-time information.

Why Tools Matter

Without tools, LLMs can only generate text. With tools, they become agents—autonomous systems capable of breaking down complex problems and taking action. A weather assistant without tool access can only recite training data; with a weather API tool, it retrieves live forecasts.

Tool Categories

Computation

Math, code execution, simulations. Example: calculate_expression("2**10") returns 1024.

Data Access

Search, database queries, file reading. Example: search_docs(query) returns relevant passages.

Action

Send emails, create files, update records. Example: send_email(to, subject, body).

Perception

Read URLs, OCR images, parse audio. Example: read_url(url) returns page markdown.

The Tool-Calling Loop

Agents operate in a tight feedback loop:

  1. Reason: LLM analyzes the user query and decides which tool to call
  2. Call: LLM generates structured tool invocation (name + arguments)
  3. Execute: Tool runs and returns a result
  4. Observe: LLM receives result as new context
  5. Repeat: LLM may call more tools, synthesize answer, or ask for clarification
Tool-Calling Loop
LLM Reason Select tool Call Invoke func Execute Run tool Observe Get result
Cross-reference: See LangGraph Guide for managing complex agent loops with state machines.

Function Calling Fundamentals

Function calling is how LLMs invoke tools. The LLM doesn't execute code directly—instead, it generates structured JSON describing which tool to call and what arguments to pass. The host system executes the tool and feeds the result back to the LLM.

The Tool Schema

Every tool is defined by a JSON schema with four key parts:

Field Purpose Example
name Unique identifier; action verb + noun send_email
description What it does; when to use it "Send an email to a recipient"
input_schema JSON Schema of parameters { type: "object", properties: {...} }
required List of mandatory parameters ["to", "subject", "body"]

Function Calling Flow

When you ask an LLM a question, here's what happens:

User: "What's the weather in New York?"

[System injects available tools into context]
Available tools:
- get_weather(city, unit)
- search_web(query)
- get_time(timezone)

[LLM decides which tool to call]
LLM Output:
{
  "type": "tool_use",
  "id": "call_1",
  "name": "get_weather",
  "input": {
    "city": "New York",
    "unit": "fahrenheit"
  }
}

[Host executes tool]
Tool Result: {
  "city": "New York",
  "temp": 72,
  "condition": "Partly Cloudy",
  "humidity": 65
}

[System adds result to conversation]
User Message: "What's the weather in New York?"
Assistant: [tool call above]
Tool Result: {temp: 72, ...}

[LLM continues reasoning with new context]
LLM Output (text): "It's currently 72°F and partly cloudy in New York."

Tool Choice Modes

Mode Behavior Use Case
auto LLM decides if/when to call tools Default; LLM has full agency
required LLM must call at least one tool Force tool invocation for verification
specific Force a specific tool Route query to predetermined handler

Parallel Tool Calls

LLMs can call multiple independent tools in a single turn. Instead of:

1. Call weather_api("NYC")
2. Get result
3. Call weather_api("LA")
4. Get result
5. Compare temperatures

LLM can instead do this in one turn:
{
  "type": "tool_use",
  "tools": [
    {
      "name": "get_weather",
      "input": {"city": "New York"}
    },
    {
      "name": "get_weather",
      "input": {"city": "Los Angeles"}
    }
  ]
}

Error Handling

When a tool fails, the system returns an error message, and the LLM can adapt:

Tool Error: Tool returned { "error": "City not found" }. The LLM sees this and can: (1) try a different city, (2) ask the user for clarification, (3) use general knowledge.
Design principle: Good tool schemas make tool selection automatic. If the LLM rarely calls your tool, the description isn't clear enough.

Tool Schema Design

The quality of your tool schema directly impacts whether the LLM will use it correctly. Vague schemas lead to wrong tool calls, missing calls, or parameter misuse.

Good Tool Names

Tool names should be:

  • Action-focused: Start with a verb (get_, send_, search_, list_, create_)
  • Specific: send_email not communicate
  • Lowercase + snake_case: fetch_user_profile not FetchUserProfile
  • Unique: No two tools with similar names

Bad: process, action, do_thing

Good: search_documents, list_repositories, calculate_mortgage

Powerful Descriptions

A description should answer:

  1. What does this tool do?
  2. When should the LLM call it?
  3. What are the edge cases?
WEAK description:
"Searches for documents"

STRONG description:
"Search a document database for relevant passages. Use this tool when the user asks a question that requires information from stored documents. Searches by keyword and returns up to 10 results ranked by relevance. Does NOT perform calculations—use calculator_tool for math. Returns empty list if no documents match."

Parameter Schema

Each parameter needs a clear type and description:

{
  "name": "send_email",
  "description": "Send an email to a recipient",
  "input_schema": {
    "type": "object",
    "properties": {
      "to": {
        "type": "string",
        "description": "Recipient email address (e.g., user@example.com)"
      },
      "subject": {
        "type": "string",
        "description": "Email subject line (max 100 characters)"
      },
      "body": {
        "type": "string",
        "description": "Email body; supports plain text and markdown"
      },
      "cc": {
        "type": "array",
        "items": {"type": "string"},
        "description": "Optional CC recipients (array of emails)"
      }
    },
    "required": ["to", "subject", "body"]
  }
}

Schema Anti-Patterns

Anti-Pattern Problem Fix
Vague names: get_data LLM unsure when to call Use get_user_profile
Too many parameters (>10) LLM overwhelmed; low accuracy Split into multiple tools
Nested objects without examples LLM can't fill parameters Add concrete examples in description
Missing "when to use" guidance Tool called in wrong contexts Describe specific use cases
Anatomy of a Well-Designed Tool
NAME send_slack_message Action verb + clear noun DESCRIPTION "Send a message to a Slack channel. Use when user wants to notify team members. Supports markdown formatting. Tells LLM WHAT + WHEN to use. Clarifies edge cases. INPUT_SCHEMA channel: string, "e.g., #alerts" message: string, required thread_ts?: string (optional) Types + descriptions for each param REQUIRED ["channel", "message"] Mandatory params. Optional ones go in properties only.

Tool Implementation Patterns

Once you define a tool schema, you implement the actual function. Good tool implementations are predictable, fast, and handle errors gracefully.

Synchronous Tools

Simplest case: LLM calls tool, waits for result, continues immediately.

def calculate_expression(expression: str) -> dict:
    """Evaluate a mathematical expression safely."""
    try:
        result = eval(expression)
        return {"success": True, "result": result}
    except Exception as e:
        return {"success": False, "error": str(e)}

Asynchronous Tools

Long-running operations (file uploads, API calls) use async + polling:

async def upload_large_file(file_path: str) -> dict:
    """Start file upload and return job ID."""
    job_id = start_upload(file_path)
    return {"job_id": job_id, "status": "uploading"}

# LLM can then poll:
def check_upload_status(job_id: str) -> dict:
    """Check status of upload job."""
    status = get_upload_status(job_id)
    return {"job_id": job_id, "status": status, "progress": ...}

Idempotent Tools

Tools should be safe to call multiple times. If LLM calls create_user(email="john@example.com") twice, the second call should either:

  • Return the existing user (preferred)
  • Return an error explaining the user exists

Never create duplicate records.

Read-Only vs Side-Effectful

Type Example Safety
Read-only get_user(id), search_docs(query) Safe; LLM can call freely
Side-effectful delete_record(id), send_email(to) Risky; require human approval

Error Handling Pattern

def robust_tool(param: str) -> dict:
    """Tool with comprehensive error handling."""
    # Validate input
    if not param:
        return {"error": "Parameter is empty", "code": "INVALID_INPUT"}

    try:
        # Main logic
        result = perform_operation(param)
        return {"success": True, "data": result}

    except TimeoutError:
        return {"error": "Operation timed out", "code": "TIMEOUT"}
    except PermissionError:
        return {"error": "Access denied", "code": "PERMISSION_DENIED"}
    except Exception as e:
        return {"error": str(e), "code": "INTERNAL_ERROR"}

Tool Composition

Tools can call other tools. Example: compare_sentiment(text_a, text_b) might internally call analyze_sentiment(text_a) and analyze_sentiment(text_b), then compare. From the LLM's perspective, this is a single tool call.

Timeout Handling

Every tool should have a timeout. If a tool takes >30 seconds, something is wrong:

import signal

def timeout_handler(signum, frame):
    raise TimeoutError("Tool execution exceeded 30 seconds")

def run_with_timeout(tool_func, args, timeout_seconds=30):
    signal.signal(signal.SIGALRM, timeout_handler)
    signal.alarm(timeout_seconds)
    try:
        result = tool_func(*args)
        signal.alarm(0)  # Cancel alarm
        return result
    except TimeoutError:
        return {"error": "Execution timeout", "code": "TIMEOUT"}
Best practice: Return structured JSON with {"success": bool, "data": ..., "error": ...}. Avoid plain text results—LLM can parse JSON more reliably.

Model Context Protocol (MCP)

MCP is an open standard for connecting LLMs to data sources and tools. Before MCP, every tool integration was custom, duplicated work, and fragile. MCP standardizes it.

The Problem MCP Solves

Without MCP:

  • Integrating a Slack bot required custom code for Slack's API
  • Adding GitHub tool access required learning GitHub's SDK
  • Each integration was isolated; no code reuse
  • Credentials scattered across configs and env vars

With MCP:

  • One standard interface for all tools
  • Plug-and-play servers from any provider
  • Centralized credential management
  • Reusable across different applications

MCP Architecture

Three components:

Host

Your application (Claude, custom agent). Initiates connections, calls tools, manages state.

Client

Proxy layer. Translates host requests into MCP messages. Handles transport (stdio, HTTP).

Server

Tool provider (Slack, GitHub, database). Implements tools, resources, and prompts.

Transport

Communication mechanism. Stdio (local subprocess) or HTTP+SSE (remote).

MCP Architecture
Host (Claude, Agent) Client (MCP Proxy) Server (Slack, GitHub, ...) Transport: Stdio or HTTP+SSE Three Primitives Tools: callable functions Resources: data access Prompts: templates All exposed via server

Three Primitives

Primitive Purpose Example
Tools Callable functions the LLM invokes send_message(channel, text)
Resources Data the LLM can read (files, DB tables) Database schema, file contents
Prompts Parameterized prompt templates "Summarize this: {text}"

Transport: Stdio vs HTTP

Stdio (Local): MCP server runs as subprocess. Fast, secure, no network. Use for local tools.

HTTP+SSE (Remote): MCP server runs on a server. Network latency, but centralized. Use for shared tools across teams.

Authentication

Each MCP server handles its own auth. Examples:

  • Slack: OAuth token stored securely
  • GitHub: Personal access token in env var
  • Database: Connection string with password

The client forwards auth to the server at initialization. The host doesn't see credentials directly.

Ecosystem

Available MCP servers include:

  • Filesystem: List/read/write files
  • Slack: Send messages, read conversations
  • GitHub: Create issues, read repos, manage PRs
  • PostgreSQL: Query databases
  • Web Browser: Navigate URLs, read pages
See also: Production Agent Workflows for MCP deployment patterns.

MCP Protocol Details

MCP uses JSON-RPC 2.0 over stdio or HTTP. Understanding the protocol flow helps you debug and build MCP servers.

Message Format

Every message is JSON-RPC 2.0:

{
  "jsonrpc": "2.0",
  "id": 1,
  "method": "initialize",
  "params": {
    "protocolVersion": "2024-11-05",
    "capabilities": {...},
    "clientInfo": {
      "name": "claude-host",
      "version": "1.0"
    }
  }
}

Lifecycle: Initialize → ListTools → CallTool → Shutdown

  1. Initialize: Client sends initialize with client info and capabilities
  2. List Tools: Server responds to list_tools with available tools and schemas
  3. Call Tool: Client sends call_tool with tool name and arguments
  4. Resource Access: Client can call list_resources and read_resource
  5. Shutdown: Client calls shutdown to close connection
MCP Message Sequence
Host Client Server initialize initialize result result list_tools list_tools [tool schemas] [tool schemas] call_tool(name, args) call_tool result/error

Error Codes

Code Meaning Example
-32700 Parse error (malformed JSON) Invalid JSON in message
-32600 Invalid request (missing required field) Missing "method" field
-32601 Method not found (unknown RPC method) Calling non-existent tool
-32602 Invalid params (args don't match schema) Wrong parameter types
-32603 Internal error (server-side exception) Database connection failed

Initialize Response

Server responds with its capabilities:

{
  "jsonrpc": "2.0",
  "id": 1,
  "result": {
    "protocolVersion": "2024-11-05",
    "serverInfo": {
      "name": "github-mcp-server",
      "version": "1.0.0"
    },
    "capabilities": {
      "tools": {
        "listChanged": false
      },
      "resources": {
        "subscribe": true
      },
      "prompts": {}
    }
  }
}
Tip: Always include listChanged: false if your tool list is static. If tools can be added/removed dynamically, set listChanged: true.

Building an MCP Server

Building an MCP server means implementing the three primitives (tools, resources, prompts) and exposing them via the MCP protocol.

Server Architecture

Basic structure:

import mcp

class MyMCPServer(mcp.Server):
    def __init__(self):
        super().__init__("my-server")

    async def initialize(self):
        """Called when client connects."""
        return {
            "name": "my-server",
            "version": "1.0.0"
        }

    async def list_tools(self):
        """Return list of available tools with schemas."""
        return [
            {
                "name": "add_numbers",
                "description": "Add two numbers",
                "inputSchema": {
                    "type": "object",
                    "properties": {
                        "a": {"type": "number"},
                        "b": {"type": "number"}
                    },
                    "required": ["a", "b"]
                }
            }
        ]

    async def call_tool(self, name: str, arguments: dict):
        """Execute a tool."""
        if name == "add_numbers":
            return arguments["a"] + arguments["b"]
        else:
            raise ValueError(f"Unknown tool: {name}")

if __name__ == "__main__":
    server = MyMCPServer()
    server.run()  # Runs over stdio

Tool Implementation with Decorators

@server.list_tools()
async def list_tools():
    return [
        {
            "name": "calculate",
            "description": "Evaluate math expression",
            "inputSchema": {...}
        },
        {
            "name": "search_documents",
            "description": "Search document store",
            "inputSchema": {...}
        }
    ]

@server.call_tool()
async def call_tool(name: str, arguments: dict):
    if name == "calculate":
        return eval(arguments["expression"])
    elif name == "search_documents":
        return search_db(arguments["query"])
    else:
        raise ValueError(f"Unknown tool: {name}")

Resources & Prompts

Resources expose data (files, schemas). Prompts are reusable templates:

# List and read resources
@server.list_resources()
async def list_resources():
    return [{"uri": "file:///config.json", "name": "Config"}]

@server.read_resource()
async def read_resource(uri: str):
    return open(uri).read()

# Expose prompt templates
@server.list_prompts()
async def list_prompts():
    return [{"name": "code_review", "description": "Review code"}]

@server.get_prompt()
async def get_prompt(name: str, arguments: dict):
    if name == "code_review":
        return f"Review: {arguments['code']}"
See also: Multi-Agent Systems for coordinating multiple MCP servers.

Tool Safety & Guardrails

Not all tools are safe. Deleting files, sending emails, making purchases—these need protection. Guardrails prevent abuse while enabling powerful tool use.

Tool Risk Levels

Level Examples Guardrails
Safe read_file, search_docs, calculate None
Risky create_file, update_record, send_message Audit log, confirmation
Dangerous delete_file, delete_user, transfer_money Human approval, MFA, strict limits

Principle: Least Privilege

Tools should have minimal permissions. Bad: database user can DELETE anything. Good: database user can SELECT only from public tables.

Human Approval

For dangerous operations, require explicit confirmation:

async def call_tool(name: str, arguments: dict):
    if name == "delete_file":
        path = arguments["path"]
        print(f"Agent wants to delete: {path}")
        if input("Approve? (yes/no): ").lower() != "yes":
            return {"error": "Denied"}
        os.remove(path)
        return {"success": True}
    elif name == "search":  # Safe
        return search(arguments["query"])

Sandboxing

Run untrusted code in isolated containers with timeouts:

import subprocess

def execute_code_safely(code: str, timeout: int = 5):
    """Execute code in sandbox. Returns stdout."""
    try:
        result = subprocess.run(
            ["python", "-c", code],
            capture_output=True,
            text=True,
            timeout=timeout,
            cwd="/tmp/sandbox"  # Isolated dir
        )
        return result.stdout
    except subprocess.TimeoutExpired:
        return "ERROR: Timeout"
    except Exception as e:
        return f"ERROR: {e}"

Input Validation

Always validate before execution:

def validate_file_operation(path: str):
    """Ensure path is safe."""
    abs_path = os.path.abspath(path)
    if not abs_path.startswith("/safe/directory"):
        raise ValueError(f"Path outside safe zone: {abs_path}")
    if not os.path.exists(abs_path):
        raise FileNotFoundError(f"Path not found: {abs_path}")
    return abs_path

Audit Logging

Log all tool calls for accountability:

import json
from datetime import datetime

logger = logging.getLogger("tool_audit")

async def call_tool_with_audit(name: str, arguments: dict, user: str):
    """Call tool and log everything."""
    log_entry = {
        "timestamp": datetime.utcnow().isoformat(),
        "tool": name,
        "user": user,
        "arguments": arguments
    }
    try:
        result = await execute_tool(name, arguments)
        log_entry["status"] = "success"
        log_entry["result_summary"] = str(result)[:100]
    except Exception as e:
        log_entry["status"] = "error"
        log_entry["error"] = str(e)
        raise
    finally:
        logger.info(json.dumps(log_entry))

Rate Limiting

Prevent runaway agent loops:

from collections import defaultdict
from time import time

class RateLimiter:
    def __init__(self, max_calls: int = 100, window: int = 60):
        self.max_calls = max_calls
        self.window = window
        self.calls = defaultdict(list)

    def allow_call(self, tool_name: str) -> bool:
        """Check if tool call is allowed."""
        now = time()
        self.calls[tool_name] = [
            t for t in self.calls[tool_name]
            if now - t < self.window
        ]
        if len(self.calls[tool_name]) >= self.max_calls:
            return False
        self.calls[tool_name].append(now)
        return True
Safety Layers Around Tool Execution
Authentication & Authorization Rate Limiting & Quotas Input Validation Sandboxing & Isolation Tool Execution Audit Logging (all calls recorded)
Defense-in-depth: Use multiple safety layers.

Tool Selection & Routing

With many tools, which one does the LLM call? Selection accuracy depends on descriptions.

Selection Quality

Better descriptions = better selection. Compare:

Bad Good
"Get user" "Fetch user profile by ID. Returns name, email, role. Error if ID doesn't exist."
"Search" "Search documents by keyword. Returns 10 results ranked by relevance. Does NOT calculate."

The 20-Tool Cliff

With >20 tools, LLM accuracy drops significantly. Solutions:

  • Hierarchical tools: one meta-tool selects sub-tools
  • Tool routing: pre-filter tools by query type
  • Consolidation: merge similar tools

Tool Routing

Show LLM only relevant tools:

def get_relevant_tools(query: str) -> list:
    """Filter tools by query type."""
    if "weather" in query.lower():
        return [weather_tool, location_tool]
    elif "calculate" in query.lower():
        return [calculator_tool, converter_tool]
    elif "file" in query.lower():
        return [read_file, list_files]
    else:
        return [search_tool]

# LLM only sees 2-4 tools, not 20
relevant = get_relevant_tools(user_query)
llm_response = llm.call(
    messages=user_messages,
    tools=relevant
)

Hierarchical Tools

One meta-tool orchestrates many sub-tools:

tools = [
    {
        "name": "database_query",
        "description": "Execute SQL",
        "input_schema": {
            "operation": {"enum": ["select","insert","update"]}
        }
    }
]

async def database_query(query: str, operation: str):
    if operation == "select":
        return execute_select(query)
    elif operation == "insert":
        return execute_insert(query)
    elif operation == "update":
        return execute_update(query)

Fallback Chain

Try tools in sequence if one fails:

async def robust_search(query: str):
    try:
        return search_primary(query)  # Primary
    except TimeoutError:
        return search_cache(query)  # Fallback
    except Exception:
        return {"error": "Search unavailable"}

Parallel Execution

Call independent tools concurrently:

import asyncio

async def get_user_profile(user_id: str):
    profile, messages, settings = await asyncio.gather(
        fetch_profile(user_id),
        fetch_messages(user_id),
        fetch_settings(user_id)
    )
    return {
        "profile": profile,
        "messages": messages,
        "settings": settings
    }
Analytics: Log which tools are called for which queries. Identify low-performing tools.

Evaluation & Debugging

Measure agent quality. Identify problems through traces and metrics.

Tool Call Trace

Always log the full flow:

User: "What's the weather in NYC?"

[TRACE]
Reasoning: "User asked about weather"
Tool: get_weather
Arguments: {"city": "New York", "unit": "fahrenheit"}
Result: {"temp": 72, "condition": "Partly cloudy"}
Response: "It's 72°F and partly cloudy in NYC"

Evaluation Metrics

Metric Measures Good
Faithfulness Did agent use result correctly? >95%
Tool Accuracy Did tool return correct result? >99%
Latency Time per response <1s per tool
Token Cost Context used by tools <20%

Debugging: Inspection

When things go wrong, inspect the trace:

Tool not called?
→ Check tool description (too vague?)
→ Check if tool shown to LLM?

Wrong arguments?
→ Check schema clarity
→ Check examples in description

Result not used?
→ Is result JSON or plain text?
→ Check error indicator

LLM refusing tool?
→ Security guard triggered?
→ Tool name seems malicious?

Unit Testing

import unittest

class TestCalculatorTool(unittest.TestCase):
    def test_add(self):
        result = calculate_tool("2 + 2")
        self.assertEqual(result, 4)

    def test_syntax_error(self):
        result = calculate_tool("2 +")
        self.assertEqual(result["error"], "Syntax error")

    def test_timeout(self):
        result = calculate_tool("while True: pass")
        self.assertEqual(result["error"], "Timeout")

Integration Testing

class TestAgentWorkflow(unittest.TestCase):
    def test_weather_query(self):
        """Test agent uses weather tool."""
        agent = Agent(tools=[weather_tool, search_tool])
        response = agent.run("What's the weather in NYC?")
        self.assertIn("tool_call", response["trace"])
        self.assertIn("72", response["text"])
See also: RAG Patterns for evaluating retrieval quality.

Python Implementation: Complete Agent

Build a complete tool-using agent in pure Python. No external APIs—mock tools only.

Base Tool Class

import json
from typing import Any, Dict, Optional

class Tool:
    """Base class for tools."""
    def __init__(self, name: str, description: str):
        self.name = name
        self.description = description

    def execute(self, **kwargs) -> Dict[str, Any]:
        """Override in subclasses."""
        raise NotImplementedError

    def schema(self) -> Dict[str, Any]:
        """Return JSON schema."""
        return {
            "name": self.name,
            "description": self.description,
            "input_schema": self._input_schema()
        }

    def _input_schema(self) -> Dict[str, Any]:
        """Return parameter schema."""
        raise NotImplementedError

Concrete Tools

class CalculatorTool(Tool):
    def __init__(self):
        super().__init__(
            "calculate",
            "Evaluate a math expression safely"
        )

    def execute(self, expression: str) -> Dict[str, Any]:
        try:
            result = eval(expression)
            return {"success": True, "result": result}
        except Exception as e:
            return {"success": False, "error": str(e)}

    def _input_schema(self):
        return {
            "type": "object",
            "properties": {
                "expression": {"type": "string"}
            },
            "required": ["expression"]
        }

class SearchTool(Tool):
    def __init__(self, documents: list):
        super().__init__(
            "search_documents",
            "Search documents by keyword"
        )
        self.documents = documents

    def execute(self, query: str) -> Dict[str, Any]:
        query_lower = query.lower()
        results = [
            doc for doc in self.documents
            if query_lower in doc.lower()
        ]
        return {
            "success": True,
            "results": results[:5],
            "count": len(results)
        }

    def _input_schema(self):
        return {
            "type": "object",
            "properties": {
                "query": {"type": "string"}
            },
            "required": ["query"]
        }

class WeatherTool(Tool):
    def __init__(self):
        super().__init__(
            "get_weather",
            "Get weather for a city"
        )
        self.data = {
            "new york": {"temp": 72, "condition": "Cloudy"},
            "london": {"temp": 59, "condition": "Rainy"},
            "tokyo": {"temp": 81, "condition": "Sunny"}
        }

    def execute(self, city: str) -> Dict[str, Any]:
        city_lower = city.lower()
        if city_lower in self.data:
            return {
                "success": True,
                "city": city,
                "data": self.data[city_lower]
            }
        return {"success": False, "error": f"City not found"}

    def _input_schema(self):
        return {
            "type": "object",
            "properties": {
                "city": {"type": "string"}
            },
            "required": ["city"]
        }

Tool Registry

class ToolRegistry:
    """Register and discover tools."""
    def __init__(self):
        self.tools = {}

    def register(self, tool: Tool) -> None:
        """Register a tool."""
        self.tools[tool.name] = tool

    def get_tool(self, name: str) -> Optional[Tool]:
        """Get tool by name."""
        return self.tools.get(name)

    def list_tools(self) -> list:
        """Return all tool schemas."""
        return [tool.schema() for tool in self.tools.values()]

    def call_tool(self, name: str, arguments: Dict) -> Dict:
        """Execute a tool."""
        tool = self.get_tool(name)
        if not tool:
            return {"error": f"Unknown tool: {name}"}
        return tool.execute(**arguments)

Mock LLM

import re

class MockLLM:
    """Simulate LLM decision-making."""
    def __init__(self, registry: ToolRegistry):
        self.registry = registry

    def call(self, user_message: str) -> Dict[str, Any]:
        """Decide which tool to call."""
        msg = user_message.lower()

        if any(w in msg for w in ["calculate", "math", "2+2"]):
            match = re.search(r'\b(\d+[\+\-\*/]\d+)\b', user_message)
            expr = match.group(1) if match else "2+2"
            return {
                "success": True,
                "tool_selected": "calculate",
                "arguments": {"expression": expr}
            }

        elif any(w in msg for w in ["weather", "temperature"]):
            city = "new york"
            if "london" in msg:
                city = "london"
            elif "tokyo" in msg:
                city = "tokyo"
            return {
                "success": True,
                "tool_selected": "get_weather",
                "arguments": {"city": city}
            }

        elif any(w in msg for w in ["search", "find"]):
            return {
                "success": True,
                "tool_selected": "search_documents",
                "arguments": {"query": user_message}
            }

        return {
            "success": False,
            "response": "I'm not sure how to help."
        }

ReAct Agent Loop

class Agent:
    """Reason → Act → Observe loop."""
    def __init__(self, registry: ToolRegistry):
        self.registry = registry
        self.llm = MockLLM(registry)
        self.trace = []

    def run(self, user_message: str) -> Dict[str, Any]:
        """Run agent."""
        self.trace = []

        # Reason
        reasoning = self.llm.call(user_message)
        self.trace.append({"step": "reason", "result": reasoning})

        if not reasoning["success"]:
            return {
                "user_message": user_message,
                "response": reasoning["response"],
                "trace": self.trace
            }

        # Act
        tool_name = reasoning["tool_selected"]
        arguments = reasoning["arguments"]
        tool_result = self.registry.call_tool(tool_name, arguments)
        self.trace.append({
            "step": "act",
            "tool": tool_name,
            "arguments": arguments,
            "result": tool_result
        })

        # Observe
        response = self._generate_response(tool_name, tool_result)
        self.trace.append({"step": "observe", "response": response})

        return {
            "user_message": user_message,
            "response": response,
            "trace": self.trace
        }

    def _generate_response(self, tool: str, result: Dict) -> str:
        """Generate response from tool result."""
        if not result.get("success", True):
            return f"Error: {result.get('error')}"

        if tool == "calculate":
            return f"The answer is {result['result']}"
        elif tool == "get_weather":
            data = result.get("data", {})
            city = result.get("city", "the city")
            return f"In {city}: {data.get('temp', 'N/A')}°F, {data.get('condition')}"
        elif tool == "search_documents":
            count = result.get("count", 0)
            return f"Found {count} matching documents"
        else:
            return "Done"

Example Execution

# Setup
registry = ToolRegistry()
registry.register(CalculatorTool())
registry.register(SearchTool([
    "Claude is an AI assistant",
    "Python is a programming language",
    "Tools extend LLM capabilities"
]))
registry.register(WeatherTool())
agent = Agent(registry)

# Query 1: Math
r = agent.run("What is 10 * 5?")
print(f"User: {r['user_message']}")
print(f"Agent: {r['response']}\n")

# Query 2: Weather
r = agent.run("Tell me the weather in Tokyo")
print(f"User: {r['user_message']}")
print(f"Agent: {r['response']}\n")

# Query 3: Search
r = agent.run("Search for Python in documents")
print(f"User: {r['user_message']}")
print(f"Agent: {r['response']}\n")

# Full trace
print("=== Trace ===")
for step in r["trace"]:
    print(json.dumps(step, indent=2))
Next steps: Replace MockLLM with Claude API. Add real API calls. The ReAct loop stays the same.
End of Agent Tools & MCP