Chapter 29. Build an Agent with Tool Use
In Chapter 23, you learned how function calling works at the API level: the model generates a structured JSON request, your code executes the function, and you send the result back. In Chapter 28, you fine-tuned a model on your own data. Now you are going to combine these ideas and build something that actually does work in the real world: an agent that can reason about a problem, decide which tools to use, call them, interpret the results, and keep going until the task is done. By the end of this chapter, you will have built a working agent from scratch, connected it to external APIs, and created your own MCP server that any AI application can use.
What Makes an Agent Different from a Chatbot?
A chatbot takes your message and produces a response. One input, one output, done. An agent takes your message and enters a loop: it thinks about what to do, takes an action (like calling a tool), observes the result, thinks again, and repeats until it has a complete answer. The difference is the loop.
This loop has a name: the ReAct pattern (Reasoning + Acting), introduced by Yao et al. in their ICLR 2023 paper (arXiv:2210.03629). The pattern interleaves three steps:
- Thought: The model reasons about what it knows and what it still needs to find out.
- Action: The model calls a tool (a function, an API, a database query).
- Observation: The tool returns a result, which the model incorporates into its reasoning.
The model repeats this cycle until it has enough information to produce a final answer. A simple chatbot does zero iterations of this loop. An agent might do one, five, or twenty, depending on the complexity of the task.
Here is a concrete example. Suppose you ask an agent: “What is the current stock price of Apple, and how does it compare to its 52-week high?”
A chatbot would either refuse (it does not have live data) or hallucinate a number. An agent would:
- Thought: “I need the current stock price of Apple. I have a
get_stock_pricetool.” - Action: Call
get_stock_price(ticker="AAPL") - Observation: The tool returns
{"price": 237.45, "currency": "USD"} - Thought: “Now I need the 52-week high. I have a
get_stock_statstool.” - Action: Call
get_stock_stats(ticker="AAPL") - Observation: The tool returns
{"high_52w": 260.10, "low_52w": 164.08} - Thought: “I have both numbers. The current price is $237.45, the 52-week high is $260.10. That is 8.7% below the high. I can answer now.”
- Final answer: “Apple (AAPL) is currently trading at $237.45, which is 8.7% below its 52-week high of $260.10.”
Two tool calls, three reasoning steps, one final answer. That is an agent.
Source: ReAct: Synergizing Reasoning and Acting in Language Models, Yao et al., arXiv:2210.03629, ICLR 2023 (confirmed from arxiv.org/abs/2210.03629, github.com/ysymyth/ReAct).
The Agent Loop: Building It from Scratch
Before using any framework, let us build the core agent loop ourselves. This makes the mechanics completely transparent. The loop is surprisingly simple: it is a while loop that keeps calling the model until the model stops requesting tool calls.
Step 1: Define the Tools
First, we define the tools our agent can use. Each tool is a Python function with a corresponding JSON schema that tells the model what the function does and what arguments it expects. This is the same tool definition format from Chapter 23.
import json
import openai
client = openai.OpenAI() # Uses OPENAI_API_KEY from environment.
# ── Tool implementations ──────────────────────────────
def get_weather(city: str) -> dict:
"""Get current weather for a city. In production, call a real API."""
# Simulated response. Replace with a real API call.
data = {
"New York": {"temp_f": 42, "condition": "cloudy", "humidity": 65},
"London": {"temp_f": 51, "condition": "rainy", "humidity": 80},
"Tokyo": {"temp_f": 58, "condition": "clear", "humidity": 45},
}
return data.get(city, {"temp_f": 0, "condition": "unknown", "humidity": 0})
def search_web(query: str) -> str:
"""Search the web. In production, call a search API."""
# Simulated response. Replace with a real search API call.
return f"Top result for '{query}': [Simulated search result]"
def calculate(expression: str) -> float:
"""Evaluate a math expression. Uses eval() with a strict character
allowlist. For production, consider ast.literal_eval or a proper
math parser like sympy.parsing."""
allowed = set("0123456789+-*/.() ")
if not all(c in allowed for c in expression):
raise ValueError(f"Invalid characters in expression: {expression}")
return float(eval(expression))
# ── Tool registry ─────────────────────────────────────
# Maps tool names to their Python functions.
TOOL_FUNCTIONS = {
"get_weather": get_weather,
"search_web": search_web,
"calculate": calculate,
}
# ── Tool schemas (JSON Schema format) ─────────────────
# These tell the model what tools are available and how to call them.
TOOLS = [
{
"type": "function",
"function": {
"name": "get_weather",
"description": "Get the current weather for a city",
"parameters": {
"type": "object",
"properties": {
"city": {
"type": "string",
"description": "City name, e.g. 'New York'",
}
},
"required": ["city"],
},
},
},
{
"type": "function",
"function": {
"name": "search_web",
"description": "Search the web for information",
"parameters": {
"type": "object",
"properties": {
"query": {
"type": "string",
"description": "The search query",
}
},
"required": ["query"],
},
},
},
{
"type": "function",
"function": {
"name": "calculate",
"description": "Evaluate a mathematical expression",
"parameters": {
"type": "object",
"properties": {
"expression": {
"type": "string",
"description": "Math expression, e.g. '(10 + 5) * 3'",
}
},
"required": ["expression"],
},
},
},
]There are three pieces for each tool:
- The Python function that actually does the work (
get_weather,search_web,calculate). - The JSON schema that describes the function to the model (name, description, parameters).
- The registry that maps tool names to functions so we can look them up at runtime.
The model never executes the Python functions directly. It generates a JSON object saying “call get_weather with city='Tokyo'”, and our code looks up get_weather in the registry and calls it.
Step 2: The Agent Loop
Here is the complete agent loop. Read it carefully; this is the core of every agent system, whether you build it yourself or use a framework.
def run_agent(user_message: str, max_iterations: int = 10) -> str:
"""
Run the agent loop: send a message, handle tool calls,
repeat until the model produces a final text response.
"""
messages = [
{
"role": "system",
"content": (
"You are a helpful assistant with access to tools. "
"Use them when you need real data. Think step by step."
),
},
{"role": "user", "content": user_message},
]
for iteration in range(max_iterations):
# ── Call the model ──
response = client.chat.completions.create(
model="gpt-4.1",
messages=messages,
tools=TOOLS,
tool_choice="auto", # Let the model decide.
)
assistant_message = response.choices[0].message
# ── Check: did the model request tool calls? ──
if not assistant_message.tool_calls:
# No tool calls: the model produced a final answer.
return assistant_message.content
# ── The model wants to call one or more tools ──
# Add the assistant's message (with tool_calls) to history.
messages.append(assistant_message)
# Execute each tool call and add results to history.
for tool_call in assistant_message.tool_calls:
name = tool_call.function.name
args = json.loads(tool_call.function.arguments)
print(f" [Tool call] {name}({args})")
# Look up and execute the function.
func = TOOL_FUNCTIONS.get(name)
if func is None:
result = f"Error: unknown tool '{name}'"
else:
try:
result = json.dumps(func(**args))
except Exception as e:
result = f"Error: {e}"
# Add the tool result to the conversation.
messages.append({
"role": "tool",
"tool_call_id": tool_call.id,
"content": result,
})
print(f" [Iteration {iteration + 1}] "
f"{len(assistant_message.tool_calls)} tool call(s) executed")
return "Error: agent reached maximum iterations without a final answer."Let us trace exactly what happens when you call this function:
- We build the initial message list: a system prompt and the user’s question.
- We call the model with the tools attached.
- The model either returns a text response (done!) or returns one or more
tool_calls. - If there are tool calls, we add the assistant’s message to the history, execute each tool, and add each result as a
toolmessage. - We loop back to step 2 and call the model again, now with the tool results in the conversation.
- The model sees the results and either calls more tools or produces a final answer.
The tool_choice="auto" parameter tells the model to decide for itself whether to call a tool or respond directly. Other options include "required" (force the model to call a tool) and "none" (prevent tool calls entirely). For agents, "auto" is almost always the right choice.
A note on the model choice: the code examples in this chapter use gpt-4.1, which is still available in the OpenAI API as of March 2026 at $2.00 per million input tokens and $8.00 per million output tokens. OpenAI retired GPT-4.1 from the ChatGPT consumer interface on February 13, 2026, but API access is unchanged. GPT-5.4, released March 5, 2026, is the current flagship model at $2.50/$15.00 per million tokens. You can swap gpt-4.1 for gpt-5.4 (or any model that supports tool use) by changing the model parameter. The agent loop itself is identical regardless of which model you use.
Source: GPT-4.1 retired from ChatGPT on February 13, 2026; API access remains unchanged (confirmed from openai.com/index/retiring-gpt-4o-and-older-models, help.openai.com/en/articles/11954883-legacy-model-access-for-team-enterprise-and-edu-users, blockchain.news). GPT-4.1 API pricing: $2.00/$8.00 per million tokens (confirmed from langcopilot.com/gpt-4.1-token-calculator, docsbot.ai/models/gpt-4-1). GPT-5.4 released March 5, 2026, $2.50/$15.00 per million tokens (confirmed from openai.com/index/introducing-gpt-5-4, laozhang.ai).
Step 3: Run It
# Example 1: Simple weather query (one tool call).
answer = run_agent("What is the weather in Tokyo?")
print(answer)
# Output:
# [Tool call] get_weather({'city': 'Tokyo'})
# [Iteration 1] 1 tool call(s) executed
# The current weather in Tokyo is 58°F (clear skies) with 45% humidity.
# Example 2: Multi-step query (multiple tool calls).
answer = run_agent(
"What is the weather in New York and London? "
"Also, what is 72 divided by the temperature difference?"
)
print(answer)
# Output:
# [Tool call] get_weather({'city': 'New York'})
# [Tool call] get_weather({'city': 'London'})
# [Iteration 1] 2 tool call(s) executed
# [Tool call] calculate({'expression': '72 / (51 - 42)'})
# [Iteration 2] 1 tool call(s) executed
# New York: 42°F, cloudy, 65% humidity.
# London: 51°F, rainy, 80% humidity.
# The temperature difference is 9°F. 72 / 9 = 8.0.In Example 2, the model made two parallel tool calls in the first iteration (getting weather for both cities simultaneously), then used the results to make a third tool call (the calculation) in the second iteration. This is parallel function calling, a capability OpenAI introduced at DevDay in November 2023. The model can request multiple tool calls in a single response when the calls are independent of each other.
Notice that the entire agent is about 40 lines of code. The while loop, the tool dispatch, and the message history management are all there is. Every agent framework (LangChain, OpenAI Agents SDK, LlamaIndex) wraps this same pattern with additional features like error handling, tracing, guardrails, and multi-agent handoffs. But the core is always this loop.
Source: OpenAI parallel function calling introduced at DevDay, November 6, 2023 (confirmed from github.com/gaborcselle parallel function calling gist, community.openai.com). OpenAI Responses API launched March 11, 2025, as the recommended foundation for agentic applications (confirmed from openai.com/index/new-tools-for-building-agents, lobehub.com).
Connecting to Real APIs
The simulated tools above are useful for understanding the pattern, but real agents need real data. Let us replace the simulated weather function with a call to a real, free API.
Example: Open-Meteo Weather API
Open-Meteo is a free, open-source weather API that requires no API key. It returns current weather data for any latitude/longitude pair.
import requests
def get_weather_real(latitude: float, longitude: float) -> dict:
"""
Get current weather from the Open-Meteo API.
Free, no API key required.
Docs: https://open-meteo.com/en/docs
"""
url = "https://api.open-meteo.com/v1/forecast"
params = {
"latitude": latitude,
"longitude": longitude,
"current": "temperature_2m,relative_humidity_2m,wind_speed_10m",
"temperature_unit": "fahrenheit",
}
resp = requests.get(url, params=params, timeout=10)
resp.raise_for_status()
data = resp.json()["current"]
return {
"temperature_f": data["temperature_2m"],
"humidity_pct": data["relative_humidity_2m"],
"wind_speed_mph": data["wind_speed_10m"],
}
# Test it.
print(get_weather_real(40.7128, -74.0060)) # New York
# {'temperature_f': 43.2, 'humidity_pct': 58, 'wind_speed_mph': 8.1}To use this in the agent, update the tool schema to accept latitude and longitude instead of a city name, and update the registry:
TOOLS_REAL = [
{
"type": "function",
"function": {
"name": "get_weather",
"description": (
"Get current weather for a location. "
"Requires latitude and longitude coordinates."
),
"parameters": {
"type": "object",
"properties": {
"latitude": {
"type": "number",
"description": "Latitude, e.g. 40.7128 for New York",
},
"longitude": {
"type": "number",
"description": "Longitude, e.g. -74.006 for New York",
},
},
"required": ["latitude", "longitude"],
},
},
},
]
TOOL_FUNCTIONS_REAL = {"get_weather": get_weather_real}The model knows the approximate coordinates of major cities from its training data, so it can fill in the latitude and longitude arguments without a separate geocoding step. For less well-known locations, you would add a geocoding tool that converts a city name to coordinates.
Example: Reading Local Files
Agents that work with code or documents need to read files. Here is a file-reading tool:
import os
def read_file(path: str) -> str:
"""Read a text file and return its contents."""
# Security: restrict to a specific directory.
allowed_dir = os.path.abspath("./workspace")
full_path = os.path.abspath(os.path.join(allowed_dir, path))
if not full_path.startswith(allowed_dir):
raise PermissionError(
f"Access denied: {path} is outside the allowed directory"
)
with open(full_path, "r", encoding="utf-8") as f:
content = f.read()
# Truncate very large files to avoid exceeding context limits.
if len(content) > 50_000:
content = content[:50_000] + "\n... [truncated]"
return contentThe security check is critical. Without it, the model could read any file on your system, including sensitive configuration files, credentials, or private data. Always restrict file access to a specific directory and validate paths before reading.
Example: Running Python Code
For agents that need to perform calculations or data analysis, a code execution tool is powerful but dangerous. Here is a sandboxed version using Python’s subprocess module:
import subprocess
import tempfile
def run_python(code: str) -> str:
"""
Execute Python code in a subprocess and return stdout.
WARNING: This is a simplified sandbox. For production use,
run code in a container (Docker) or a dedicated sandbox service.
"""
with tempfile.NamedTemporaryFile(
mode="w", suffix=".py", delete=False
) as f:
f.write(code)
f.flush()
try:
result = subprocess.run(
["python", f.name],
capture_output=True,
text=True,
timeout=30, # Kill after 30 seconds.
)
output = result.stdout
if result.stderr:
output += f"\nSTDERR:\n{result.stderr}"
return output[:10_000] # Truncate long output.
except subprocess.TimeoutExpired:
return "Error: code execution timed out after 30 seconds"
finally:
os.unlink(f.name)This runs the code in a separate process with a 30-second timeout. For production systems, you should run code inside a Docker container or a dedicated sandbox service (like E2B or Modal) to prevent the code from accessing the host filesystem, network, or other resources.
Error Handling and Safety
The basic agent loop above works for happy paths, but real agents need to handle failures gracefully. Here are the most important patterns:
Tool Errors
Tools fail. APIs time out, files do not exist, calculations produce errors. The agent needs to handle these without crashing:
def execute_tool_safely(name: str, args: dict) -> str:
"""Execute a tool with error handling."""
func = TOOL_FUNCTIONS.get(name)
if func is None:
return json.dumps({"error": f"Unknown tool: {name}"})
try:
result = func(**args)
return json.dumps(result) if not isinstance(result, str) else result
except Exception as e:
return json.dumps({"error": f"{type(e).__name__}: {str(e)}"})When a tool returns an error, the model sees the error message in the conversation and can decide what to do: retry with different arguments, try a different tool, or explain to the user that it could not complete the task. This is one of the strengths of the agent pattern: the model can recover from errors without crashing the entire system.
Infinite Loop Prevention
The max_iterations parameter in our agent loop prevents the model from calling tools forever. But you should also watch for a subtler problem: the model calling the same tool with the same arguments repeatedly. This can happen when the model gets confused or when a tool consistently returns unhelpful results.
def run_agent_safe(user_message: str, max_iterations: int = 10) -> str:
"""Agent loop with duplicate detection."""
messages = [
{"role": "system", "content": "You are a helpful assistant with tools."},
{"role": "user", "content": user_message},
]
seen_calls = set()
for iteration in range(max_iterations):
response = client.chat.completions.create(
model="gpt-4.1",
messages=messages,
tools=TOOLS,
tool_choice="auto",
)
msg = response.choices[0].message
if not msg.tool_calls:
return msg.content
messages.append(msg)
for tc in msg.tool_calls:
call_key = (tc.function.name, tc.function.arguments)
if call_key in seen_calls:
messages.append({
"role": "tool",
"tool_call_id": tc.id,
"content": json.dumps({
"error": "Duplicate call detected. "
"Try a different approach."
}),
})
else:
seen_calls.add(call_key)
result = execute_tool_safely(
tc.function.name,
json.loads(tc.function.arguments),
)
messages.append({
"role": "tool",
"tool_call_id": tc.id,
"content": result,
})
return "Error: agent reached maximum iterations."Token Budget Management
Each iteration of the agent loop adds messages to the conversation: the assistant’s tool call request, the tool results, and eventually the assistant’s next response. For complex tasks with many iterations, the conversation can grow large enough to exceed the model’s context window or become expensive.
A practical approach is to track token usage and stop or summarize when approaching the limit:
def count_tokens_approx(messages: list) -> int:
"""Rough token count: ~4 characters per token for English text."""
total_chars = sum(
len(str(m.get("content", ""))) for m in messages
if isinstance(m, dict)
)
return total_chars // 4For production systems, use the tiktoken library (Chapter 4) for exact token counts. The rough estimate above is sufficient for setting guardrails.
Using the OpenAI Agents SDK
Building the agent loop from scratch is instructive, but for production applications, you want a framework that handles the boilerplate: error recovery, tracing, guardrails, multi-agent handoffs, and conversation management. The OpenAI Agents SDK is a lightweight Python framework released on March 11, 2025, alongside the Responses API. It wraps the same agent loop we built above with production-ready features.
The SDK has three core concepts:
- Agent: An LLM configured with instructions and tools.
- Tool: A Python function decorated with
@function_toolthat the agent can call. - Runner: The engine that runs the agent loop, handling tool calls and responses automatically.
"""
A simple agent using the OpenAI Agents SDK.
Requirements:
pip install openai-agents
"""
from agents import Agent, Runner, function_tool
@function_tool
def get_weather(city: str) -> str:
"""Get the current weather for a city."""
# In production, call a real weather API.
data = {
"New York": "42°F, cloudy",
"London": "51°F, rainy",
"Tokyo": "58°F, clear",
}
return data.get(city, f"Weather data not available for {city}")
@function_tool
def calculate(expression: str) -> str:
"""Evaluate a mathematical expression."""
allowed = set("0123456789+-*/.() ")
if not all(c in allowed for c in expression):
return f"Error: invalid characters in '{expression}'"
return str(eval(expression))
# Create the agent.
agent = Agent(
name="Weather Assistant",
instructions=(
"You are a helpful assistant. Use the get_weather tool "
"for weather questions and the calculate tool for math."
),
tools=[get_weather, calculate],
)
# Run it.
result = Runner.run_sync(agent, "What is the weather in Tokyo?")
print(result.final_output)
# "The current weather in Tokyo is 58°F with clear skies."The @function_tool decorator automatically generates the JSON schema from the function’s type hints and docstring. The Runner.run_sync method runs the agent loop synchronously (there is also an async Runner.run for production use). Under the hood, the Runner does exactly what our hand-built loop does: it calls the model, checks for tool calls, executes them, and repeats.
The SDK also supports:
- Handoffs: One agent can delegate to another agent mid-conversation. For example, a triage agent can hand off to a billing agent or a technical support agent based on the user’s question.
- Guardrails: Input and output validators that check messages before they reach the model or the user. You can use these to block sensitive information, enforce output formats, or prevent prompt injection.
- Human-in-the-loop: Built-in mechanisms for pausing agent execution before critical actions, requesting human approval, and resuming from the exact same state after confirmation. This is essential for production agents that take irreversible actions (sending emails, making purchases, modifying databases).
- Sessions: Conversation history management across multiple turns, with built-in support for SQLite, SQLAlchemy, Redis, and Dapr storage backends, plus an encrypted session option for sensitive data.
- Tracing: Built-in logging of every model call, tool call, and handoff, which integrates with OpenAI’s evaluation, fine-tuning, and distillation tools. Essential for debugging and monitoring production agents.
- Realtime and voice agents: Support for building voice-enabled agents using OpenAI’s Realtime API, with configurable speech-to-text and text-to-speech pipelines.
- WebSocket transport: As of version 0.10.0, the SDK supports WebSocket connections to the Responses API. Instead of making a new HTTP request for each iteration of the agent loop, a WebSocket connection stays open, sending only incremental updates. For agents with 20 or more sequential tool calls, this reduces end-to-end latency by up to 40%. HTTP remains the default; WebSocket is opt-in.
Source: OpenAI Agents SDK released March 11, 2025, as a production-ready upgrade of the experimental Swarm framework. Latest version 0.12.5 released March 19, 2026. Features include agent loop, function tools, handoffs, guardrails, human-in-the-loop, sessions, tracing, realtime/voice agents, and WebSocket transport. Provider-agnostic, supporting OpenAI Responses and Chat Completions APIs plus 100+ other LLMs (confirmed from openai.github.io/openai-agents-python, openai.com/index/new-tools-for-building-agents, pypi.org/project/openai-agents). WebSocket transport up to 40% faster for 20+ tool calls (confirmed from apidog.com/blog/openai-websocket-api, how2shout.com/news/openai-websocket-api-agent-latency-40-percent-faster).
When to Use a Framework vs. Building from Scratch
| Scenario | Recommendation |
|---|---|
| Learning how agents work | Build from scratch (as we did above) |
| Simple single-agent, single-tool | Either works; from scratch is fine |
| Multiple tools, error handling needed | Use a framework |
| Multi-agent with handoffs | Use a framework |
| Production deployment with tracing | Use a framework |
| Need to support multiple LLM providers | Use a framework |
| Voice or real-time agents | Use a framework (Agents SDK has built-in Realtime API support) |
| High-frequency tool calls (20+) | Use a framework with WebSocket transport for up to 40% lower latency |
The from-scratch approach is about 40 lines of code and gives you complete control. The SDK approach is about 20 lines of code and gives you production features for free. For this chapter, we showed both so you understand what the framework is doing under the hood.
The OpenAI Agents SDK is not the only framework. Chapter 23 covered several alternatives: Anthropic’s Claude Agent SDK (released September 2025, rebranded from Claude Code SDK), Google’s Agent Development Kit (ADK) (released April 2025), and community frameworks like LangChain and LlamaIndex. All of them wrap the same core loop. The choice depends on which LLM provider you use most, which ecosystem you prefer, and whether you need specific features like Google ADK’s built-in A2A (Agent-to-Agent) protocol support for inter-agent communication. OpenAI also released AgentKit in March 2026, a visual drag-and-drop canvas for composing agent workflows with nodes, connecting tools, and configuring guardrails. AgentKit is built on top of the Agents SDK and targets teams that prefer a visual interface over writing code.
Source: AgentKit announced March 2026, provides visual canvas for composing agent logic with drag-and-drop nodes, preview runs, inline eval configuration, and full versioning (confirmed from openai.com/index/introducing-agentkit).
Building an MCP Server
Chapter 23 introduced the Model Context Protocol (MCP), the open standard announced by Anthropic in November 2024 that defines how AI applications discover and call external tools. MCP has become the dominant standard for tool integration in 2026: as of March 2026, there are 3,012 unique servers registered in the official MCP registry, over 13,000 MCP servers on GitHub, and the SDK has been downloaded over 97 million times. The protocol is governed by the Linux Foundation’s Agentic AI Foundation (AAIF), which was formed on December 9, 2025, with Anthropic, Block, and OpenAI as founding contributors.
The key idea behind MCP is separation of concerns: the tool provider builds an MCP server that exposes tools, and the AI application (the MCP client) connects to the server and uses those tools. The tool provider does not need to know which AI model or application will use the tools. The AI application does not need to know how the tools are implemented. MCP is the interface between them.
Think of it this way: without MCP, if you have 5 AI applications and 10 tools, you need 50 custom integrations (every app needs a custom connector for every tool). With MCP, you need 5 clients and 10 servers, and any client can connect to any server. That is 15 implementations instead of 50.
Source: MCP announced by Anthropic in November 2024 as an open standard (confirmed from anthropic.com/news/model-context-protocol, wikipedia.org/wiki/Model_Context_Protocol). 3,012 servers in the official MCP registry as of March 2026 (confirmed from nimblebrain.ai/blog/state-of-mcp-security-2026). Over 13,000 MCP servers on GitHub and 97 million+ SDK downloads (confirmed from lushbinary.com/blog/mcp-model-context-protocol-developer-guide-2026). AAIF formed December 9, 2025, under the Linux Foundation, with MCP (Anthropic), goose (Block), and AGENTS.md (OpenAI) as founding projects and eight platinum members at $350,000 each: AWS, Anthropic, Block, Bloomberg, Cloudflare, Google, Microsoft, and OpenAI. By February 24, 2026, the AAIF had added 97 new members, including Circle, JPMorgan Chase, and Huawei (confirmed from linuxfoundation.org/press/linux-foundation-announces-the-formation-of-the-agentic-ai-foundation, simonwillison.net, prnewswire.com, kucoin.com/news/flash/circle-jpmorgan-and-huawei-join-agentic-ai-governance-initiative).
MCP Architecture
An MCP system has three layers:
- Host: The AI application that the user interacts with (Claude Desktop, VS Code with Copilot, a custom chatbot).
- Client: The component inside the host that manages the MCP connection. Each client connects to one server.
- Server: The component that exposes tools, resources, and prompts to the client.
The server exposes three types of capabilities:
| Capability | What It Does | Example |
|---|---|---|
| Tools | Functions the model can call | get_weather(city), query_database(sql) |
| Resources | Data the model can read | File contents, database records, API responses |
| Prompts | Reusable prompt templates | “Summarize this document”, “Analyze this code” |
Tools are the most commonly used capability and the focus of this chapter.
Building Your First MCP Server
The MCP Python SDK (version 1.26.0 as of January 2026) includes FastMCP, a high-level framework that makes building MCP servers as simple as decorating Python functions. Here is a complete, working MCP server:
"""
A simple MCP server that exposes weather and calculation tools.
Requirements:
pip install "mcp[cli]"
Run:
python weather_server.py
# Or use the MCP development tools:
# mcp dev weather_server.py
"""
from mcp.server.fastmcp import FastMCP
# Create the server.
mcp = FastMCP("Weather Tools")
@mcp.tool()
def get_weather(city: str) -> dict:
"""Get the current weather for a city.
Args:
city: The name of the city, e.g. "New York"
Returns:
A dictionary with temperature, condition, and humidity.
"""
data = {
"New York": {"temp_f": 42, "condition": "cloudy", "humidity": 65},
"London": {"temp_f": 51, "condition": "rainy", "humidity": 80},
"Tokyo": {"temp_f": 58, "condition": "clear", "humidity": 45},
}
return data.get(
city,
{"temp_f": 0, "condition": "unknown", "humidity": 0},
)
@mcp.tool()
def calculate(expression: str) -> float:
"""Evaluate a mathematical expression.
Args:
expression: A math expression like '(10 + 5) * 3'
"""
allowed = set("0123456789+-*/.() ")
if not all(c in allowed for c in expression):
raise ValueError(f"Invalid characters in expression: {expression}")
return float(eval(expression))
@mcp.tool()
def read_file(path: str) -> str:
"""Read a text file and return its contents.
Args:
path: Path to the file, relative to the workspace directory.
"""
import os
allowed_dir = os.path.abspath("./workspace")
full_path = os.path.abspath(os.path.join(allowed_dir, path))
if not full_path.startswith(allowed_dir):
raise PermissionError("Access denied: path is outside workspace")
with open(full_path, "r", encoding="utf-8") as f:
return f.read()[:50_000]
if __name__ == "__main__":
mcp.run()That is the entire server. The @mcp.tool() decorator does three things:
- Registers the function as an MCP tool.
- Generates the JSON schema from the function’s type hints and docstring (the
Args:section becomes parameter descriptions). - Handles serialization: converts the function’s return value to the MCP response format.
When you run this server, it communicates over stdio (standard input/output) by default. The MCP client (Claude Desktop, VS Code, or your custom application) launches the server as a subprocess and sends JSON-RPC messages over stdin/stdout. For remote servers, MCP supports Streamable HTTP transport (which replaced the older SSE transport in the March 2025 spec update).
Source: MCP Python SDK version 1.26.0 released January 24, 2026, MIT license, maintained by Anthropic (confirmed from pypi.org/project/mcp). FastMCP included in the official MCP Python SDK, provides @mcp.tool() decorator for automatic schema generation (confirmed from pypi.org/project/mcp, modelcontextprotocol.github.io/python-sdk). Streamable HTTP replaced SSE as the recommended remote transport in spec version 2025-03-26 (confirmed from nerdleveltech.com/guides/model-context-protocol).
A note on FastMCP versions: the mcp.server.fastmcp module used above is the FastMCP that ships inside the official MCP Python SDK (maintained by Anthropic). There is also a standalone FastMCP project (maintained by Prefect, available at pip install fastmcp), which is a more feature-rich framework built on top of the official SDK. FastMCP 1.0 was originally a community project that proved so useful that Anthropic incorporated it into the official SDK. The standalone project continued evolving independently. FastMCP 3.0 was announced on January 20, 2026, went through two betas and two release candidates with over 100,000 pre-release installs, and reached general availability on February 18, 2026. It adds features like component versioning, granular authorization, OpenTelemetry instrumentation, provider types (FileSystemProvider, OpenAPIProvider), hot reload, and the ability to compose multiple providers into one server. FastMCP 3.1.0 (the “Code Mode” release) followed shortly after, adding servers that can discover and execute code on behalf of agents. For this chapter, we use the built-in version because it requires no additional dependencies beyond pip install "mcp[cli]". If you need the advanced features, install the standalone package with pip install fastmcp.
Source: FastMCP 1.0 incorporated into the official MCP SDK by Anthropic (confirmed from jlowin.dev/blog/fastmcp-3, gofastmcp.com/v2/getting-started/welcome). FastMCP 3.0 announced January 20, 2026, GA February 18, 2026, with versioning, authorization, OpenTelemetry, and provider types (confirmed from jlowin.dev/blog/fastmcp-3, jlowin.dev/blog/fastmcp-3-launch, sourceforge.net/projects/fastmcp.mirror/files/v3.0.0). FastMCP 3.1.0 “Code Mode” release (confirmed from sourceforge.net/projects/fastmcp.mirror/files/v3.1.0). FastMCP downloaded over a million times per day, powering approximately 70% of all MCP servers (confirmed from jlowin.dev/blog/fastmcp-3).
Connecting the MCP Server to Claude Desktop
To use your MCP server with Claude Desktop, add it to the Claude configuration file:
macOS: ~/Library/Application Support/Claude/claude_desktop_config.json
Windows: %APPDATA%\Claude\claude_desktop_config.json
Linux: ~/.config/claude-desktop/claude_desktop_config.json
{
"mcpServers": {
"weather-tools": {
"command": "python",
"args": ["/path/to/weather_server.py"]
}
}
}After restarting Claude Desktop, the tools from your server appear in the interface. When you ask Claude a question that requires weather data, it will call your get_weather tool automatically.
Testing Your MCP Server
The MCP SDK includes a development tool for testing servers without connecting them to a client:
# Install the MCP CLI tools.
pip install "mcp[cli]"
# Run the development inspector.
mcp dev weather_server.pyThis opens an interactive inspector where you can:
- See all registered tools, resources, and prompts.
- Call tools manually and inspect the results.
- Test error handling by passing invalid arguments.
For programmatic testing, you can use the MCP client SDK:
"""
Test an MCP server programmatically.
Requirements:
pip install "mcp[cli]"
"""
import asyncio
from mcp import ClientSession, StdioServerParameters
from mcp.client.stdio import stdio_client
async def test_server():
"""Connect to the MCP server and call its tools."""
server_params = StdioServerParameters(
command="python",
args=["weather_server.py"],
)
async with stdio_client(server_params) as (read, write):
async with ClientSession(read, write) as session:
# Initialize the connection.
await session.initialize()
# List available tools.
tools = await session.list_tools()
print(f"Available tools: {[t.name for t in tools.tools]}")
# Call a tool.
result = await session.call_tool(
"get_weather",
arguments={"city": "Tokyo"},
)
print(f"Weather result: {result.content}")
asyncio.run(test_server())Output:
Available tools: ['get_weather', 'calculate', 'read_file']
Weather result: [TextContent(type='text', text='{"temp_f": 58, ...}')]This test script acts as an MCP client: it launches the server, connects to it, lists the available tools, and calls one. This is exactly what Claude Desktop or any other MCP host does under the hood.
A Real MCP Server: Database Query Tool
The weather example is simple by design. Let us build something more realistic: an MCP server that lets an AI agent query a SQLite database. This is one of the most common real-world use cases for tool-using agents, because it lets non-technical users ask questions about data in natural language.
"""
MCP server that exposes a SQLite database as queryable tools.
Requirements:
pip install "mcp[cli]"
Usage:
python db_server.py
"""
import sqlite3
from mcp.server.fastmcp import FastMCP
mcp = FastMCP("Database Tools")
DB_PATH = "company.db"
def get_connection() -> sqlite3.Connection:
"""Get a read-only database connection."""
conn = sqlite3.connect(f"file:{DB_PATH}?mode=ro", uri=True)
conn.row_factory = sqlite3.Row
return conn
@mcp.tool()
def list_tables() -> list[str]:
"""List all tables in the database."""
conn = get_connection()
cursor = conn.execute(
"SELECT name FROM sqlite_master WHERE type='table' ORDER BY name"
)
tables = [row["name"] for row in cursor.fetchall()]
conn.close()
return tables
@mcp.tool()
def describe_table(table_name: str) -> list[dict]:
"""Get the schema (column names and types) for a table.
Args:
table_name: Name of the table to describe.
"""
conn = get_connection()
# Validate table name to prevent SQL injection.
tables = [
r["name"]
for r in conn.execute(
"SELECT name FROM sqlite_master WHERE type='table'"
).fetchall()
]
if table_name not in tables:
conn.close()
raise ValueError(f"Table '{table_name}' does not exist")
cursor = conn.execute(f"PRAGMA table_info({table_name})")
columns = [
{"name": row["name"], "type": row["type"], "nullable": not row["notnull"]}
for row in cursor.fetchall()
]
conn.close()
return columns
@mcp.tool()
def query_database(sql: str) -> list[dict]:
"""Execute a read-only SQL query and return the results.
Args:
sql: A SELECT query. Only SELECT statements are allowed.
"""
# Security: only allow SELECT statements.
stripped = sql.strip().upper()
if not stripped.startswith("SELECT"):
raise PermissionError("Only SELECT queries are allowed")
# Block dangerous keywords.
dangerous = ["DROP", "DELETE", "INSERT", "UPDATE", "ALTER", "CREATE"]
for keyword in dangerous:
if keyword in stripped:
raise PermissionError(f"Forbidden keyword: {keyword}")
conn = get_connection()
try:
cursor = conn.execute(sql)
rows = [dict(row) for row in cursor.fetchall()]
# Limit results to prevent overwhelming the model's context.
if len(rows) > 100:
rows = rows[:100]
rows.append({"_note": "Results truncated to 100 rows"})
return rows
finally:
conn.close()
if __name__ == "__main__":
mcp.run()This server exposes three tools:
list_tables: The agent calls this first to discover what data is available.describe_table: The agent calls this to understand the schema of a specific table.query_database: The agent writes and executes a SQL query based on the schema.
The typical agent workflow for answering a data question looks like this:
- User asks: “How many orders did we get last month?”
- Agent calls
list_tables()and sees["customers", "orders", "products"]. - Agent calls
describe_table("orders")and sees columns likeid,customer_id,amount,created_at. - Agent writes a SQL query:
SELECT COUNT(*) as order_count FROM orders WHERE created_at >= '2026-02-01' AND created_at < '2026-03-01' - Agent calls
query_database(sql)and gets[{"order_count": 1247}]. - Agent responds: “You received 1,247 orders in February 2026.”
Three tool calls, one answer. The agent figured out the table structure, wrote the correct SQL, and interpreted the result, all without the user needing to know SQL.
Security Considerations
The database server above includes several security measures, but they are not exhaustive. For production use:
- Use a read-only database connection. The
?mode=roURI parameter in SQLite prevents writes. For PostgreSQL or MySQL, use a database user with SELECT-only permissions. - Validate and sanitize all inputs. The table name validation prevents SQL injection in the
PRAGMAcall. The keyword blocklist prevents destructive queries. - Limit result sizes. The 100-row limit prevents the agent from pulling the entire database into the conversation, which would be expensive and potentially expose sensitive data.
- Restrict access to specific tables. In production, you might want to expose only certain tables (e.g., aggregate views) rather than the raw data.
- Log all queries. Every SQL query the agent executes should be logged for auditing.
Putting It All Together: A Complete Agent
Let us build a complete agent that combines everything from this chapter: the agent loop, real API calls, file reading, and database queries. This agent can answer questions that require multiple tools and multiple reasoning steps.
"""
A complete agent with multiple tools.
Requirements:
pip install openai requests
Usage:
python complete_agent.py
"""
import json
import os
import requests
import openai
client = openai.OpenAI()
# ── Tool implementations ──────────────────────────────
def get_weather(latitude: float, longitude: float) -> dict:
"""Get current weather from Open-Meteo (free, no API key)."""
resp = requests.get(
"https://api.open-meteo.com/v1/forecast",
params={
"latitude": latitude,
"longitude": longitude,
"current": "temperature_2m,relative_humidity_2m",
"temperature_unit": "fahrenheit",
},
timeout=10,
)
resp.raise_for_status()
data = resp.json()["current"]
return {
"temperature_f": data["temperature_2m"],
"humidity_pct": data["relative_humidity_2m"],
}
def read_file(path: str) -> str:
"""Read a file from the workspace directory."""
allowed_dir = os.path.abspath("./workspace")
full_path = os.path.abspath(os.path.join(allowed_dir, path))
if not full_path.startswith(allowed_dir):
raise PermissionError("Access denied")
with open(full_path, "r", encoding="utf-8") as f:
return f.read()[:50_000]
def list_files(directory: str = ".") -> list[str]:
"""List files in a workspace subdirectory."""
allowed_dir = os.path.abspath("./workspace")
target = os.path.abspath(os.path.join(allowed_dir, directory))
if not target.startswith(allowed_dir):
raise PermissionError("Access denied")
return os.listdir(target)
def calculate(expression: str) -> str:
"""Evaluate a math expression."""
allowed = set("0123456789+-*/.() ")
if not all(c in allowed for c in expression):
raise ValueError(f"Invalid characters: {expression}")
return str(eval(expression))
def search_web(query: str) -> str:
"""Search the web (simulated). Replace with a real search API."""
return f"Search results for '{query}': [simulated result]"
# ── Tool registry and schemas ─────────────────────────
TOOL_FUNCTIONS = {
"get_weather": get_weather,
"read_file": read_file,
"list_files": list_files,
"calculate": calculate,
"search_web": search_web,
}
TOOLS = [
{
"type": "function",
"function": {
"name": "get_weather",
"description": "Get current weather for coordinates",
"parameters": {
"type": "object",
"properties": {
"latitude": {"type": "number"},
"longitude": {"type": "number"},
},
"required": ["latitude", "longitude"],
},
},
},
{
"type": "function",
"function": {
"name": "read_file",
"description": "Read a text file from the workspace",
"parameters": {
"type": "object",
"properties": {
"path": {
"type": "string",
"description": "File path relative to workspace/",
}
},
"required": ["path"],
},
},
},
{
"type": "function",
"function": {
"name": "list_files",
"description": "List files in a workspace directory",
"parameters": {
"type": "object",
"properties": {
"directory": {
"type": "string",
"description": "Directory path relative to workspace/",
"default": ".",
}
},
},
},
},
{
"type": "function",
"function": {
"name": "calculate",
"description": "Evaluate a math expression",
"parameters": {
"type": "object",
"properties": {
"expression": {"type": "string"}
},
"required": ["expression"],
},
},
},
{
"type": "function",
"function": {
"name": "search_web",
"description": "Search the web for information",
"parameters": {
"type": "object",
"properties": {
"query": {"type": "string"}
},
"required": ["query"],
},
},
},
]
# ── The agent loop ────────────────────────────────────
def run_agent(user_message: str, max_iterations: int = 10) -> str:
"""Run the complete agent with all tools."""
messages = [
{
"role": "system",
"content": (
"You are a helpful assistant with access to tools for "
"weather, file reading, calculations, and web search. "
"Use tools when you need real data. Think step by step. "
"When reading files, first list the directory to see "
"what is available."
),
},
{"role": "user", "content": user_message},
]
for iteration in range(max_iterations):
response = client.chat.completions.create(
model="gpt-4.1",
messages=messages,
tools=TOOLS,
tool_choice="auto",
)
msg = response.choices[0].message
if not msg.tool_calls:
return msg.content
messages.append(msg)
for tc in msg.tool_calls:
name = tc.function.name
args = json.loads(tc.function.arguments)
print(f" [{iteration+1}] {name}({args})")
func = TOOL_FUNCTIONS.get(name)
if func is None:
result = json.dumps({"error": f"Unknown tool: {name}"})
else:
try:
raw = func(**args)
result = (
json.dumps(raw)
if not isinstance(raw, str)
else raw
)
except Exception as e:
result = json.dumps(
{"error": f"{type(e).__name__}: {e}"}
)
messages.append({
"role": "tool",
"tool_call_id": tc.id,
"content": result,
})
return "Error: reached maximum iterations."
# ── Example usage ─────────────────────────────────────
if __name__ == "__main__":
# Multi-step query: weather + calculation.
answer = run_agent(
"What is the weather in New York (40.71, -74.01) and "
"San Francisco (37.77, -122.42)? What is the temperature "
"difference between them?"
)
print(answer)This agent handles multi-step tasks naturally. For the query above, it would:
- Call
get_weatherfor New York and San Francisco in parallel (two tool calls in one iteration). - Call
calculatewith the temperature difference expression. - Produce a final answer combining all the data.
The total cost for this interaction is three API calls to the model (one per iteration) plus two HTTP requests to the weather API. At GPT-4.1 pricing ($2.00 per million input tokens, $8.00 per million output tokens), a typical three-iteration agent run with a few hundred tokens per iteration costs well under $0.01. Even with GPT-5.4 ($2.50/$15.00 per million tokens), the cost stays below a penny for simple multi-step tasks. Cost becomes a concern only with long conversations, large tool results, or dozens of iterations.
MCP vs. Direct Function Calling: When to Use Which
You now know two ways to give an agent tools: direct function calling (defining tools in the API request) and MCP (connecting to an MCP server). Here is when to use each:
| Factor | Direct Function Calling | MCP Server |
|---|---|---|
| Setup complexity | Lower (tools defined in code) | Higher (separate server process) |
| Reusability | Tools tied to one application | Any MCP client can use the server |
| Ecosystem | Custom per application | 3,012+ servers in the registry |
| Security boundary | Tools run in your process | Tools run in a separate process |
| Best for | Application-specific tools | Shared tools, third-party integrations |
Use direct function calling when your tools are specific to your application and you do not need to share them. The weather agent we built earlier is a good example: the tools are simple, application-specific, and do not need to be reused elsewhere.
Use MCP when you want to share tools across multiple applications, when you want to use tools built by others (the 3,012+ servers in the MCP registry), or when you need a security boundary between the AI application and the tool implementation. MCP is also the right choice when you are building tools that other people will use with their own AI applications.
In practice, many production systems use both: direct function calling for application-specific logic, and MCP for connecting to external services and shared tools. GPT-5.4’s tool search feature, released on March 5, 2026, takes this further: instead of loading all tool schemas upfront, the model receives a lightweight tool list and looks up full definitions on demand. On 250 MCP Atlas benchmark tasks with 36 MCP servers enabled, tool search reduced total token usage by 47% while maintaining the same accuracy.
Source: GPT-5.4 tool search reduces token usage by 47% on 250 MCP Atlas tasks with 36 MCP servers (confirmed from digitalapplied.com/blog/gpt-5-4-computer-use-tool-search-benchmarks-pricing, thenextgentechinsider.com, openai.com/index/introducing-gpt-5-4).
Common Patterns and Pitfalls
Pattern: Tool Selection via System Prompt
The system prompt is the most important lever for controlling which tools the agent uses and how it uses them. A vague system prompt leads to unnecessary tool calls; a specific one leads to efficient behavior.
Bad (vague):
You are a helpful assistant with tools.Good (specific):
You are a data analyst assistant. You have access to a company database.
When the user asks a data question:
1. First call list_tables() to see available tables.
2. Then call describe_table() for relevant tables.
3. Write a SQL query and call query_database().
4. Interpret the results in plain language.
Do NOT guess at data. Always query the database.The specific prompt tells the agent exactly how to approach data questions, reducing wasted iterations and improving accuracy.
Pattern: Structured Output from Agents
When you need the agent’s final answer in a specific format (JSON, markdown, a specific schema), combine tool use with structured output:
response = client.chat.completions.create(
model="gpt-4.1",
messages=messages,
tools=TOOLS,
tool_choice="auto",
response_format={
"type": "json_schema",
"json_schema": {
"name": "analysis_result",
"schema": {
"type": "object",
"properties": {
"summary": {"type": "string"},
"data_points": {
"type": "array",
"items": {"type": "object"},
},
"confidence": {"type": "number"},
},
"required": ["summary", "data_points", "confidence"],
},
},
},
)The response_format parameter (Structured Outputs, covered in Chapter 23) ensures the model’s final answer conforms to your schema, even after multiple tool-calling iterations.
Pitfall: Over-Tooling
Giving the agent too many tools makes it slower and less accurate. Each tool definition consumes input tokens (the model needs to read all the schemas), and more tools mean more opportunities for the model to choose the wrong one.
Guidelines:
- 5-10 tools is the sweet spot for most agents.
- 20+ tools starts to degrade performance. Consider grouping related tools into a single tool with a
actionparameter, or use tool search (GPT-5.4) to load schemas on demand. - 100+ tools requires tool search or a hierarchical approach where a routing agent selects a subset of tools before the main agent runs.
Pitfall: Not Handling Tool Errors
If a tool raises an exception and your code crashes, the agent dies mid-conversation. Always catch exceptions in the tool dispatch and return error messages as tool results. The model is surprisingly good at recovering from tool errors when it can see the error message.
Pitfall: Ignoring Cost
Each iteration of the agent loop is an API call. A 10-iteration agent run costs 10x as much as a single chatbot response. For high-volume applications, set strict max_iterations limits and monitor token usage. The token budget management pattern from earlier in this chapter is essential for production deployments.
Key Takeaways
An agent is a model in a loop. The core pattern is simple: call the model, check for tool calls, execute them, send results back, repeat. Every agent framework wraps this same loop with additional features.
The ReAct pattern (Reasoning + Acting) is the foundation. Introduced by Yao et al. (ICLR 2023), it interleaves reasoning steps with tool calls, allowing the model to plan, act, observe, and adapt. This is how agents solve multi-step problems.
Direct function calling gives you tools in about 40 lines of code. Define your tools as Python functions, create JSON schemas, and write a while loop that dispatches tool calls. No framework required for simple agents.
The OpenAI Agents SDK reduces this to about 20 lines. The
@function_tooldecorator,Agentclass, andRunnerhandle the loop, error recovery, tracing, and multi-agent handoffs automatically. Version 0.12.5 (March 2026) adds sessions, human-in-the-loop approval gates, realtime voice agents, and WebSocket transport that cuts latency by up to 40% for tool-heavy workflows. Use it for production applications.MCP is the standard for shareable tools. Announced by Anthropic in November 2024 and now governed by the Linux Foundation’s AAIF (105+ member organizations as of February 2026), MCP separates tool providers from tool consumers. As of March 2026, there are 3,012 servers in the official registry, 13,000+ on GitHub, and 97 million+ SDK downloads. Building an MCP server with FastMCP takes about 30 lines of Python.
Security is not optional. Restrict file access to specific directories. Use read-only database connections. Validate all inputs. Run code in sandboxes. Log all tool calls. An agent with unrestricted tool access is a security vulnerability.
Error handling makes or breaks an agent. Tools fail, APIs time out, models hallucinate tool names. Catch all exceptions, return error messages as tool results, detect duplicate calls, and set iteration limits. The model can recover from errors it can see.
Cost scales with iterations. Each loop iteration is an API call. A 5-iteration agent run costs 5x a single response. Set
max_iterations, monitor token usage, and use prompt caching (Chapter 19) to reduce costs on repeated tool schemas.GPT-5.4’s tool search reduces overhead for large tool sets. Instead of loading all tool schemas into every request, tool search lets the model discover tools on demand, cutting token usage by 47% on multi-tool benchmarks.
Start with direct function calling, graduate to MCP. Build your first agent with the from-scratch approach to understand the mechanics. Move to a framework and MCP when you need reusability, multi-agent coordination, or third-party tool integrations.
What’s Next
You have built an agent that can reason, call tools, and solve multi-step problems. This is the final hands-on chapter of the book. In the appendices that follow, you will find reference material to support your continued work: a full attention math derivation (Appendix A), a GPU memory calculator (Appendix B), a glossary of every term used in the book (Appendix C), a timeline of key milestones from the 2017 Transformer paper to March 2026 (Appendix D), a comparison table of every frontier model with specs and pricing (Appendix E), and a curated list of papers, blogs, and courses for further reading (Appendix F).