Post

Tool Calling, Function Calling, and MCP Systems: How LLMs Safely Interact with Software

A model that only generates text can answer questions. A model that can safely invoke tools can take actions — query databases, call APIs, run code, trigger workflows. This is the architectural shift that separates a chatbot from an agent.

This article covers the full picture: how function calling works at the protocol level, how to implement it with OpenAI and Anthropic, how the Model Context Protocol (MCP) standardizes tool ecosystems, and how to build production-grade tool-using systems.

What You’ll Learn

  • How function/tool calling works under the hood (JSON Schema, message flow)
  • Practical implementation with OpenAI API and Anthropic API
  • Parallel tool calls and multi-step tool chains
  • What MCP is, its architecture, and how to build an MCP server
  • Error handling, validation, and security patterns
  • LangChain tool integration
  • Metrics and common failure modes

1. How Function Calling Works

The Core Mechanism

Function calling is not the model executing code. The model produces a structured JSON object that describes which function to call and with what arguments. Your application then executes the actual function and feeds the result back to the model.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
User message
    │
    ▼
┌──────────────────────────────────────┐
│  LLM  (sees: user message + tool     │
│  definitions)                        │
│                                      │
│  Output: tool_call JSON OR text      │
└──────────────────────────────────────┘
    │
    ▼  (if tool_call)
┌──────────────────────────────────────┐
│  Your application                    │
│  1. Parse tool_call                  │
│  2. Validate arguments               │
│  3. Execute the actual function      │
│  4. Return result to LLM             │
└──────────────────────────────────────┘
    │
    ▼
┌──────────────────────────────────────┐
│  LLM  (sees: tool result)            │
│  Output: final answer to user        │
└──────────────────────────────────────┘

The Message Flow in Detail

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
# 1. Initial conversation (user + tool definitions)
messages = [{"role": "user", "content": "What's the weather in Paris?"}]

tools = [{
    "type": "function",
    "function": {
        "name": "get_weather",
        "description": "Get current weather for a city",
        "parameters": {
            "type": "object",
            "properties": {
                "city":  {"type": "string",  "description": "City name"},
                "units": {"type": "string",  "enum": ["celsius", "fahrenheit"], "default": "celsius"},
            },
            "required": ["city"],
        },
    },
}]

# 2. LLM responds with a tool call (not text)
# response.choices[0].message.tool_calls[0]:
# {
#   "id": "call_abc123",
#   "type": "function",
#   "function": {"name": "get_weather", "arguments": '{"city": "Paris", "units": "celsius"}'}
# }

# 3. Your app executes the real function, then adds the result to messages
messages.append(response.choices[0].message)   # assistant message with tool_call
messages.append({
    "role":         "tool",
    "tool_call_id": "call_abc123",
    "content":      '{"temperature": 18, "condition": "Partly cloudy"}',
})

# 4. LLM sees the result and produces the final answer
# "The weather in Paris is currently 18°C and partly cloudy."

2. Function Calling with OpenAI API

Basic Single-Tool Example

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
import json
from openai import OpenAI

client = OpenAI()

# ── Tool definitions ────────────────────────────────────────────────────────
tools = [
    {
        "type": "function",
        "function": {
            "name": "get_stock_price",
            "description": "Get the current stock price for a ticker symbol",
            "parameters": {
                "type": "object",
                "properties": {
                    "ticker":   {"type": "string",  "description": "Stock ticker, e.g. AAPL"},
                    "currency": {"type": "string",  "enum": ["USD", "EUR"], "default": "USD"},
                },
                "required": ["ticker"],
            },
        },
    },
    {
        "type": "function",
        "function": {
            "name": "get_company_info",
            "description": "Get company name, sector, and market cap for a ticker",
            "parameters": {
                "type": "object",
                "properties": {
                    "ticker": {"type": "string"},
                },
                "required": ["ticker"],
            },
        },
    },
]

# ── Actual function implementations ─────────────────────────────────────────
def get_stock_price(ticker: str, currency: str = "USD") -> dict:
    # In production: call a real financial API
    prices = {"AAPL": 189.5, "TSLA": 245.0, "MSFT": 415.2}
    price = prices.get(ticker.upper())
    if price is None:
        return {"error": f"Ticker {ticker} not found"}
    return {"ticker": ticker, "price": price, "currency": currency}

def get_company_info(ticker: str) -> dict:
    info = {
        "AAPL": {"name": "Apple Inc.", "sector": "Technology", "market_cap": "2.9T"},
        "TSLA": {"name": "Tesla Inc.", "sector": "Automotive",  "market_cap": "780B"},
    }
    return info.get(ticker.upper(), {"error": "Not found"})

TOOL_REGISTRY = {
    "get_stock_price":  get_stock_price,
    "get_company_info": get_company_info,
}

# ── Execution loop ───────────────────────────────────────────────────────────
def execute_tool_call(tool_call) -> str:
    name = tool_call.function.name
    args = json.loads(tool_call.function.arguments)

    if name not in TOOL_REGISTRY:
        return json.dumps({"error": f"Unknown tool: {name}"})

    try:
        result = TOOL_REGISTRY[name](**args)
        return json.dumps(result)
    except Exception as e:
        return json.dumps({"error": str(e)})

def chat_with_tools(user_message: str) -> str:
    messages = [{"role": "user", "content": user_message}]

    while True:
        response = client.chat.completions.create(
            model    = "gpt-4o",
            messages = messages,
            tools    = tools,
            tool_choice = "auto",   # "auto" | "required" | "none" | specific tool
        )

        msg = response.choices[0].message

        # No tool call → final answer
        if not msg.tool_calls:
            return msg.content

        # Execute all tool calls (may be parallel)
        messages.append(msg)
        for tc in msg.tool_calls:
            result = execute_tool_call(tc)
            messages.append({
                "role":         "tool",
                "tool_call_id": tc.id,
                "content":      result,
            })

# Usage
print(chat_with_tools("What is the current price of Apple and who are they?"))

Parallel Tool Calls

When the model determines multiple tools can be called simultaneously (e.g., “Compare AAPL and TSLA prices”), it returns multiple tool_calls in a single response. The loop above handles this automatically — note the for tc in msg.tool_calls.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
# The model may return this in a single response:
# msg.tool_calls = [
#   ToolCall(id="call_1", function=Function(name="get_stock_price", arguments='{"ticker":"AAPL"}')),
#   ToolCall(id="call_2", function=Function(name="get_stock_price", arguments='{"ticker":"TSLA"}')),
# ]

# Execute both in parallel
import concurrent.futures

def execute_tools_parallel(tool_calls: list) -> list[dict]:
    with concurrent.futures.ThreadPoolExecutor() as executor:
        futures = {
            executor.submit(execute_tool_call, tc): tc
            for tc in tool_calls
        }
        results = []
        for future, tc in futures.items():
            results.append({
                "role":         "tool",
                "tool_call_id": tc.id,
                "content":      future.result(),
            })
    return results

Forcing a Specific Tool

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
# Force the model to always use get_stock_price
response = client.chat.completions.create(
    model       = "gpt-4o",
    messages    = messages,
    tools       = tools,
    tool_choice = {"type": "function", "function": {"name": "get_stock_price"}},
)

# Disable all tools for a specific turn
response = client.chat.completions.create(
    model       = "gpt-4o",
    messages    = messages,
    tools       = tools,
    tool_choice = "none",
)

3. Function Calling with Anthropic API

Anthropic uses tools instead of functions and slightly different message structure:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
import anthropic
import json

client = anthropic.Anthropic()

tools = [
    {
        "name": "search_documents",
        "description": "Search internal knowledge base for relevant documents",
        "input_schema": {                         # Anthropic uses input_schema
            "type": "object",
            "properties": {
                "query":    {"type": "string",  "description": "Search query"},
                "top_k":    {"type": "integer", "description": "Number of results", "default": 5},
                "category": {"type": "string",  "enum": ["policy", "technical", "legal"]},
            },
            "required": ["query"],
        },
    },
]

def search_documents(query: str, top_k: int = 5, category: str = None) -> list:
    # Mock implementation
    return [{"title": f"Doc about {query}", "score": 0.95}]

def chat_with_claude(user_message: str) -> str:
    messages = [{"role": "user", "content": user_message}]

    while True:
        response = client.messages.create(
            model      = "claude-opus-4-5",
            max_tokens = 1024,
            tools      = tools,
            messages   = messages,
        )

        # No tool use → final text answer
        if response.stop_reason == "end_turn":
            return next(b.text for b in response.content if b.type == "text")

        # Tool use block
        if response.stop_reason == "tool_use":
            messages.append({"role": "assistant", "content": response.content})

            tool_results = []
            for block in response.content:
                if block.type == "tool_use":
                    fn = {"search_documents": search_documents}.get(block.name)
                    result = fn(**block.input) if fn else {"error": "Unknown tool"}
                    tool_results.append({
                        "type":        "tool_result",
                        "tool_use_id": block.id,
                        "content":     json.dumps(result),
                    })

            messages.append({"role": "user", "content": tool_results})

OpenAI vs Anthropic Tool Calling — Key Differences

AspectOpenAIAnthropic
Schema keyparametersinput_schema
Stop signalfinish_reason == "tool_calls"stop_reason == "tool_use"
Tool result role"tool" message"user" message with tool_result block
Tool call IDtool_call_idtool_use_id
Parallel callsNative (list in one response)Native (multiple tool_use blocks)

4. Complex Tool Schemas with JSON Schema

Real tools require precise schemas. JSON Schema gives you the full vocabulary:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
# A realistic database query tool
db_query_tool = {
    "type": "function",
    "function": {
        "name": "query_database",
        "description": "Execute a read-only query against the analytics database. Returns up to 1000 rows.",
        "parameters": {
            "type": "object",
            "properties": {
                "table": {
                    "type": "string",
                    "enum": ["orders", "customers", "products", "events"],
                    "description": "Target table name",
                },
                "filters": {
                    "type": "array",
                    "description": "List of filter conditions",
                    "items": {
                        "type": "object",
                        "properties": {
                            "column":   {"type": "string"},
                            "operator": {"type": "string", "enum": ["=", ">", "<", ">=", "<=", "LIKE", "IN"]},
                            "value":    {"type": ["string", "number", "array"]},
                        },
                        "required": ["column", "operator", "value"],
                    },
                },
                "columns": {
                    "type": "array",
                    "items": {"type": "string"},
                    "description": "Columns to return. Omit for all columns.",
                },
                "limit": {
                    "type": "integer",
                    "minimum": 1,
                    "maximum": 1000,
                    "default": 100,
                },
                "order_by": {
                    "type": "object",
                    "properties": {
                        "column":    {"type": "string"},
                        "direction": {"type": "string", "enum": ["ASC", "DESC"]},
                    },
                    "required": ["column"],
                },
            },
            "required": ["table"],
            "additionalProperties": False,
        },
    },
}

Schema Design Rules

RuleWhy it matters
Write a precise descriptionThe model uses it for tool selection
Use enum for fixed choicesPrevents hallucinated values
Mark required fields explicitlyAvoids missing argument errors
Set additionalProperties: falseRejects unexpected fields from the model
Use minimum/maximum for numbersPrevents out-of-range arguments
Avoid ambiguous parameter namescustomer_id not id

5. Model Context Protocol (MCP)

What MCP Is

The Model Context Protocol (introduced by Anthropic in November 2024) is an open standard that defines how AI applications communicate with external tools, data sources, and capabilities. It is the USB-C of AI tool integration: one protocol to connect any model to any tool.

Before MCP, every AI application had to build custom integrations for each tool — N models × M tools = N×M integrations. MCP reduces this to N + M.

1
2
3
4
5
Without MCP:                  With MCP:
                              
  Claude ──► custom ──► DB      Claude ──►┐
  GPT    ──► custom ──► DB      GPT    ──►│  MCP Client  ──►  MCP Server  ──►  DB
  Gemini ──► custom ──► DB      Gemini ──►┘

MCP Architecture

MCP defines three roles:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
┌─────────────────────────────────────────────────────────┐
│                     Host Application                     │
│  (Claude Desktop, VS Code Copilot, custom agent)        │
│                                                          │
│  ┌──────────────────────────────────────────────────┐   │
│  │              MCP Client                           │   │
│  │  - manages connections to MCP servers            │   │
│  │  - translates tool calls to MCP protocol         │   │
│  │  - aggregates tool catalogs from all servers     │   │
│  └──────────────────┬───────────────────────────────┘   │
└─────────────────────┼───────────────────────────────────┘
                      │  MCP Protocol (JSON-RPC 2.0)
          ┌───────────┼────────────┐
          ▼           ▼            ▼
   ┌──────────┐ ┌──────────┐ ┌──────────┐
   │MCP Server│ │MCP Server│ │MCP Server│
   │ Database │ │ GitHub   │ │ File sys │
   └──────────┘ └──────────┘ └──────────┘

MCP servers expose three primitives:

  • Tools — callable functions (like function calling, but standardized)
  • Resources — data/files the model can read
  • Prompts — reusable prompt templates

MCP Transport Types

TransportUse caseProtocol
stdioLocal servers, CLI toolsJSON-RPC over stdin/stdout
HTTP + SSERemote servers, cloud toolsJSON-RPC over HTTP with Server-Sent Events
Streamable HTTPModern remote serversJSON-RPC with streaming support (MCP 2025-03-26+)

6. Building an MCP Server in Python

1
pip install mcp

Minimal MCP Server (stdio transport)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
# server.py
from mcp.server import Server
from mcp.server.stdio import stdio_server
from mcp import types
import json
import httpx

app = Server("analytics-server")

# ── Define tools ────────────────────────────────────────────────────────────
@app.list_tools()
async def list_tools() -> list[types.Tool]:
    return [
        types.Tool(
            name        = "query_sales",
            description = "Query sales data by date range and product category",
            inputSchema = {
                "type": "object",
                "properties": {
                    "start_date": {"type": "string", "description": "ISO date, e.g. 2024-01-01"},
                    "end_date":   {"type": "string", "description": "ISO date, e.g. 2024-12-31"},
                    "category":   {"type": "string", "enum": ["electronics", "clothing", "food"]},
                    "aggregate":  {"type": "string", "enum": ["daily", "weekly", "monthly"], "default": "monthly"},
                },
                "required": ["start_date", "end_date"],
            },
        ),
        types.Tool(
            name        = "get_top_products",
            description = "Return top N products by revenue for a given period",
            inputSchema = {
                "type": "object",
                "properties": {
                    "period": {"type": "string", "enum": ["last_7d", "last_30d", "last_90d", "ytd"]},
                    "top_n":  {"type": "integer", "minimum": 1, "maximum": 50, "default": 10},
                },
                "required": ["period"],
            },
        ),
    ]

# ── Implement tool logic ─────────────────────────────────────────────────────
@app.call_tool()
async def call_tool(name: str, arguments: dict) -> list[types.TextContent]:
    if name == "query_sales":
        result = await _query_sales(**arguments)
    elif name == "get_top_products":
        result = await _get_top_products(**arguments)
    else:
        result = {"error": f"Unknown tool: {name}"}

    return [types.TextContent(type="text", text=json.dumps(result, indent=2))]

async def _query_sales(start_date: str, end_date: str, category: str = None, aggregate: str = "monthly") -> dict:
    # In production: query your actual database
    return {
        "period": f"{start_date} to {end_date}",
        "category": category or "all",
        "aggregate": aggregate,
        "total_revenue": 1_250_000,
        "data": [{"period": "2024-01", "revenue": 420_000}, {"period": "2024-02", "revenue": 380_000}],
    }

async def _get_top_products(period: str, top_n: int = 10) -> dict:
    return {
        "period": period,
        "products": [
            {"rank": 1, "name": "Widget Pro",  "revenue": 89_000},
            {"rank": 2, "name": "Gadget Plus", "revenue": 72_000},
        ][:top_n],
    }

# ── Expose resources ─────────────────────────────────────────────────────────
@app.list_resources()
async def list_resources() -> list[types.Resource]:
    return [
        types.Resource(
            uri         = "analytics://schema",
            name        = "Database Schema",
            description = "Full schema of the analytics database",
            mimeType    = "text/plain",
        ),
    ]

@app.read_resource()
async def read_resource(uri: str) -> str:
    if uri == "analytics://schema":
        return "Table: sales (id, date, product_id, category, revenue)\nTable: products (id, name, category)"
    raise ValueError(f"Unknown resource: {uri}")

# ── Run the server ───────────────────────────────────────────────────────────
if __name__ == "__main__":
    import asyncio
    asyncio.run(stdio_server(app))

HTTP+SSE Server (for remote deployment)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
# server_http.py
from mcp.server.sse import SseServerTransport
from starlette.applications import Starlette
from starlette.routing import Route, Mount
import uvicorn

transport = SseServerTransport("/messages/")

async def handle_sse(request):
    async with transport.connect_sse(request.scope, request.receive, request._send) as streams:
        await app.run(streams[0], streams[1], app.create_initialization_options())

starlette_app = Starlette(routes=[
    Route("/sse",       endpoint=handle_sse),
    Mount("/messages/", app=transport.handle_post_message),
])

if __name__ == "__main__":
    uvicorn.run(starlette_app, host="0.0.0.0", port=8080)

MCP Client — Consuming the Server

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
# client.py
import asyncio
from mcp import ClientSession, StdioServerParameters
from mcp.client.stdio import stdio_client

async def main():
    server_params = StdioServerParameters(
        command = "python",
        args    = ["server.py"],
    )

    async with stdio_client(server_params) as (read, write):
        async with ClientSession(read, write) as session:
            await session.initialize()

            # List available tools
            tools = await session.list_tools()
            for tool in tools.tools:
                print(f"Tool: {tool.name}{tool.description}")

            # Call a tool
            result = await session.call_tool(
                "query_sales",
                {"start_date": "2024-01-01", "end_date": "2024-12-31", "category": "electronics"},
            )
            print(result.content[0].text)

asyncio.run(main())

MCP Config for Claude Desktop

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// ~/.config/claude/claude_desktop_config.json (Linux/Mac)
// %APPDATA%\Claude\claude_desktop_config.json (Windows)
{
  "mcpServers": {
    "analytics": {
      "command": "python",
      "args":    ["/path/to/server.py"],
      "env":     {"DATABASE_URL": "postgresql://localhost/analytics"}
    },
    "filesystem": {
      "command": "npx",
      "args":    ["-y", "@modelcontextprotocol/server-filesystem", "/home/user/documents"]
    }
  }
}

7. Function Calling vs MCP — When to Use Each

 Function CallingMCP
ScopeSingle applicationCross-application ecosystem
StandardizationProvider-specific (OpenAI schema ≠ Anthropic schema)Universal protocol
Tool discoveryHardcoded in appDynamic via list_tools()
TransportIn-process / API callstdio, HTTP, SSE
ReusabilityOne app, one integrationOne server, all clients
ResourcesNot supportedBuilt-in (files, data)
PromptsNot supportedBuilt-in templates
Best forApp-specific tools, RAG pipelinesShared infrastructure, IDE tools, multi-app

Rule: use function calling for tools specific to your application. Use MCP when you want the same tool accessible from multiple AI clients (Claude Desktop, VS Code, custom agents, etc.).


8. Tool Calling with LangChain

LangChain abstracts over provider differences and adds structured tool support:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
from langchain_openai import ChatOpenAI
from langchain_core.tools import tool
from langchain_core.messages import HumanMessage, ToolMessage
import json

# ── Define tools with @tool decorator ───────────────────────────────────────
@tool
def get_weather(city: str, units: str = "celsius") -> dict:
    """Get current weather for a city. Units can be 'celsius' or 'fahrenheit'."""
    # Mock implementation
    return {"city": city, "temperature": 18, "condition": "Partly cloudy", "units": units}

@tool
def get_forecast(city: str, days: int = 3) -> list:
    """Get weather forecast for the next N days."""
    return [{"day": i+1, "temp": 15+i, "condition": "Sunny"} for i in range(days)]

# ── Bind tools to the model ─────────────────────────────────────────────────
llm   = ChatOpenAI(model="gpt-4o", temperature=0)
tools = [get_weather, get_forecast]
llm_with_tools = llm.bind_tools(tools)

# ── Manual execution loop ────────────────────────────────────────────────────
TOOL_MAP = {t.name: t for t in tools}

def run_agent(user_input: str) -> str:
    messages = [HumanMessage(content=user_input)]

    while True:
        response = llm_with_tools.invoke(messages)
        messages.append(response)

        if not response.tool_calls:
            return response.content

        for tc in response.tool_calls:
            tool_fn = TOOL_MAP[tc["name"]]
            result  = tool_fn.invoke(tc["args"])
            messages.append(ToolMessage(
                content     = json.dumps(result),
                tool_call_id = tc["id"],
            ))

print(run_agent("What's the weather and 3-day forecast for Tokyo?"))

Using LangChain Agents (ReAct pattern)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
from langchain.agents import create_tool_calling_agent, AgentExecutor
from langchain_core.prompts import ChatPromptTemplate

prompt = ChatPromptTemplate.from_messages([
    ("system", "You are a helpful weather assistant. Use tools to answer questions accurately."),
    ("human",  "{input}"),
    ("placeholder", "{agent_scratchpad}"),
])

agent          = create_tool_calling_agent(llm, tools, prompt)
agent_executor = AgentExecutor(agent=agent, tools=tools, verbose=True, max_iterations=5)

result = agent_executor.invoke({"input": "Compare the weather in Paris and London today."})
print(result["output"])

9. Production Patterns: Error Handling and Validation

Robust Tool Executor

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
import json
import logging
from typing import Any
from pydantic import BaseModel, ValidationError

logger = logging.getLogger(__name__)

class ToolCallError(Exception):
    pass

class ToolResult(BaseModel):
    tool_name:  str
    tool_call_id: str
    success:    bool
    data:       Any = None
    error:      str = None

def safe_execute_tool(
    tool_call,
    registry: dict,
    user_context: dict,
    allowed_tools: set = None,
) -> ToolResult:
    name = tool_call.function.name

    # 1. Allowlist check
    if allowed_tools and name not in allowed_tools:
        return ToolResult(
            tool_name=name, tool_call_id=tool_call.id,
            success=False, error=f"Tool '{name}' not permitted for this user"
        )

    # 2. Registry check
    if name not in registry:
        return ToolResult(
            tool_name=name, tool_call_id=tool_call.id,
            success=False, error=f"Unknown tool: {name}"
        )

    # 3. Parse arguments
    try:
        args = json.loads(tool_call.function.arguments)
    except json.JSONDecodeError as e:
        return ToolResult(
            tool_name=name, tool_call_id=tool_call.id,
            success=False, error=f"Invalid JSON arguments: {e}"
        )

    # 4. Execute with error containment
    try:
        result = registry[name](**args)
        logger.info("Tool %s succeeded | args=%s", name, args)
        return ToolResult(tool_name=name, tool_call_id=tool_call.id, success=True, data=result)
    except TypeError as e:
        return ToolResult(tool_name=name, tool_call_id=tool_call.id, success=False, error=f"Wrong arguments: {e}")
    except Exception as e:
        logger.exception("Tool %s failed | args=%s", name, args)
        return ToolResult(tool_name=name, tool_call_id=tool_call.id, success=False, error=f"Execution error: {e}")

# Format result for LLM message
def format_tool_message(result: ToolResult) -> dict:
    if result.success:
        content = json.dumps(result.data)
    else:
        # Tell the model what went wrong so it can adapt
        content = json.dumps({"error": result.error, "tool": result.tool_name})
    return {"role": "tool", "tool_call_id": result.tool_call_id, "content": content}

Human-in-the-Loop for Destructive Actions

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
HIGH_RISK_TOOLS = {"delete_record", "send_email", "execute_payment", "reset_mfa"}

def requires_approval(tool_name: str, args: dict) -> bool:
    return tool_name in HIGH_RISK_TOOLS

def execute_with_approval(tool_call, registry: dict, approval_callback) -> ToolResult:
    name = tool_call.function.name
    args = json.loads(tool_call.function.arguments)

    if requires_approval(name, args):
        approved = approval_callback(name, args)   # returns True/False
        if not approved:
            return ToolResult(
                tool_name=name, tool_call_id=tool_call.id,
                success=False, error="Action rejected by human reviewer"
            )

    return safe_execute_tool(tool_call, registry, {})

10. Security Considerations

Prompt Injection via Tool Results

Tool outputs can themselves contain adversarial instructions:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
# Dangerous: tool result injected directly into prompt
messages.append({
    "role": "tool",
    "content": tool_result_raw,   # attacker could put "Ignore previous instructions..."
})

# Safer: mark tool results as untrusted data
messages.append({
    "role": "tool",
    "content": json.dumps({
        "source": tool_name,
        "data":   tool_result_raw,   # data stays as data, not as instructions
    }),
})

Key Security Rules

RuleImplementation
Allowlist tools per user/roleallowed_tools = get_tools_for_role(user.role)
Never trust model-generated IDsValidate employee_id, customer_id against your DB before acting
Read vs write separationSeparate tool registries for read-only and write tools
Rate limit tool executionPrevent infinite loops: max_iterations=10
Audit log every executionLog (user, tool, args, result, timestamp)
Sanitize tool resultsStrip potential instruction injections before feeding back

11. ReAct Pattern: Reasoning + Acting

ReAct (Reason + Act) is the most common pattern for multi-step tool use. The model alternates between reasoning about what to do next and taking an action:

1
2
3
4
5
6
7
8
9
10
Thought: I need to find the customer's order first.
Action: lookup_orders({"customer_id": "C-1234"})
Observation: [{"order_id": "O-789", "status": "shipped", "eta": "2024-01-15"}]

Thought: The order is shipped. Now I'll check the delivery status.
Action: track_shipment({"order_id": "O-789"})
Observation: {"carrier": "FedEx", "status": "In transit", "location": "Lyon, France"}

Thought: I have all the information needed to answer.
Final Answer: Your order O-789 is currently in transit with FedEx, located in Lyon, France, with an ETA of January 15.

This pattern emerges naturally with tool calling — the model reasons in text, then the application detects a tool call and feeds back the result. No special prompting is required with modern models; it happens by default.


12. Metrics and Monitoring

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
from dataclasses import dataclass, field
from collections import defaultdict

@dataclass
class ToolMetrics:
    calls:        int = 0
    successes:    int = 0
    failures:     int = 0
    total_latency: float = 0.0
    errors:       dict = field(default_factory=lambda: defaultdict(int))

    @property
    def success_rate(self) -> float:
        return self.successes / self.calls if self.calls else 0

    @property
    def avg_latency_ms(self) -> float:
        return (self.total_latency / self.calls * 1000) if self.calls else 0

METRICS: dict[str, ToolMetrics] = defaultdict(ToolMetrics)

import time

def tracked_execute(tool_name: str, fn, **kwargs):
    m = METRICS[tool_name]
    m.calls += 1
    t0 = time.perf_counter()
    try:
        result = fn(**kwargs)
        m.successes += 1
        return result
    except Exception as e:
        m.failures += 1
        m.errors[type(e).__name__] += 1
        raise
    finally:
        m.total_latency += time.perf_counter() - t0

# Report
def print_metrics():
    for name, m in METRICS.items():
        print(f"{name:30s}  calls={m.calls}  success={m.success_rate:.1%}  avg={m.avg_latency_ms:.0f}ms")

Key metrics to track in production:

MetricTargetAlert if
Tool selection accuracy>95%<90%
Schema-valid call rate>99%<95%
Successful execution rate>98%<95%
Unnecessary tool calls<5%>15%
Average tool latency<500ms>2s
Task completion with tools>85%<70%

13. Common Failure Modes and Fixes

FailureRoot CauseFix
Wrong tool selectedAmbiguous tool descriptionsMake description more specific; use examples
Missing required argumentModel hallucination or ambiguous schemaMark required fields; add examples in description
Model loops between toolsNo termination signalSet max_iterations; add “do not call tools again if you have the answer” in system prompt
Tool result ignoredResult format confusingReturn clean JSON; avoid nested errors
Prompt injection via toolTool output contains instructionsWrap tool results in a data envelope
Model calls non-existent toolHallucinationUse tool_choice = "auto" and validate name before execution
Argument type mismatchSchema not specific enoughAdd type constraints and enum values

Key Takeaways

  • Function calling is a contract: the model proposes, your application executes — never the other way around
  • JSON Schema quality directly affects reliability: vague descriptions and missing required fields cause most failures
  • MCP decouples tool servers from clients: build once, use from any MCP-compatible host
  • Parallel tool calls are free with modern models — execute them concurrently
  • Treat tool outputs as untrusted data, not as trusted instructions — prompt injection via tool results is a real attack vector
  • Human-in-the-loop gates are essential for irreversible or high-stakes actions
  • Instrument everything: success rate, latency, and unnecessary call frequency are the three most useful metrics

Conclusion

Tool calling, function calling, and protocol-based integration are central to advanced LLM systems because they connect probabilistic reasoning to deterministic software. The model should propose, the application should validate, and the system should measure every important step. That is how tool use becomes reliable enough for production. The key discipline is treating tool calls like any other external system call: validate inputs, enforce permissions, log all invocations, and verify outputs before acting on them. This separation between what the model can request and what the system allows to execute is the foundation of safe agentic AI. As the MCP protocol matures and tool ecosystems grow, the ability to compose, secure, and evaluate tool-using agents will define the next generation of enterprise AI systems.

Summary: Tool Calling Best Practices

PracticeWhy It Matters
Validate tool arguments before executionPrevents injection and malformed side effects
Authorize every tool call against user contextPrevents privilege escalation
Log tool call + result togetherEnables forensic debugging
Set tool_choice="required" only when necessaryReduces forced hallucination
Cap tool calls per requestPrevents infinite agentic loops
Test tool selection accuracy in eval suiteMeasures tool reasoning quality

References

This post is licensed under CC BY 4.0 by the author.