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
| Aspect | OpenAI | Anthropic |
|---|---|---|
| Schema key | parameters | input_schema |
| Stop signal | finish_reason == "tool_calls" | stop_reason == "tool_use" |
| Tool result role | "tool" message | "user" message with tool_result block |
| Tool call ID | tool_call_id | tool_use_id |
| Parallel calls | Native (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
| Rule | Why it matters |
|---|---|
Write a precise description | The model uses it for tool selection |
Use enum for fixed choices | Prevents hallucinated values |
Mark required fields explicitly | Avoids missing argument errors |
Set additionalProperties: false | Rejects unexpected fields from the model |
Use minimum/maximum for numbers | Prevents out-of-range arguments |
| Avoid ambiguous parameter names | customer_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
| Transport | Use case | Protocol |
|---|---|---|
| stdio | Local servers, CLI tools | JSON-RPC over stdin/stdout |
| HTTP + SSE | Remote servers, cloud tools | JSON-RPC over HTTP with Server-Sent Events |
| Streamable HTTP | Modern remote servers | JSON-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 Calling | MCP | |
|---|---|---|
| Scope | Single application | Cross-application ecosystem |
| Standardization | Provider-specific (OpenAI schema ≠ Anthropic schema) | Universal protocol |
| Tool discovery | Hardcoded in app | Dynamic via list_tools() |
| Transport | In-process / API call | stdio, HTTP, SSE |
| Reusability | One app, one integration | One server, all clients |
| Resources | Not supported | Built-in (files, data) |
| Prompts | Not supported | Built-in templates |
| Best for | App-specific tools, RAG pipelines | Shared 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
| Rule | Implementation |
|---|---|
| Allowlist tools per user/role | allowed_tools = get_tools_for_role(user.role) |
| Never trust model-generated IDs | Validate employee_id, customer_id against your DB before acting |
| Read vs write separation | Separate tool registries for read-only and write tools |
| Rate limit tool execution | Prevent infinite loops: max_iterations=10 |
| Audit log every execution | Log (user, tool, args, result, timestamp) |
| Sanitize tool results | Strip 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:
| Metric | Target | Alert 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
| Failure | Root Cause | Fix |
|---|---|---|
| Wrong tool selected | Ambiguous tool descriptions | Make description more specific; use examples |
| Missing required argument | Model hallucination or ambiguous schema | Mark required fields; add examples in description |
| Model loops between tools | No termination signal | Set max_iterations; add “do not call tools again if you have the answer” in system prompt |
| Tool result ignored | Result format confusing | Return clean JSON; avoid nested errors |
| Prompt injection via tool | Tool output contains instructions | Wrap tool results in a data envelope |
| Model calls non-existent tool | Hallucination | Use tool_choice = "auto" and validate name before execution |
| Argument type mismatch | Schema not specific enough | Add 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
requiredfields 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
| Practice | Why It Matters |
|---|---|
| Validate tool arguments before execution | Prevents injection and malformed side effects |
| Authorize every tool call against user context | Prevents privilege escalation |
| Log tool call + result together | Enables forensic debugging |
Set tool_choice="required" only when necessary | Reduces forced hallucination |
| Cap tool calls per request | Prevents infinite agentic loops |
| Test tool selection accuracy in eval suite | Measures tool reasoning quality |
