Lesson 2 — Tools, Actions and MCP
Module 3, Unit 3 | Lesson 2 of 3
By the end of this lesson, you will be able to:
- Explain how function calling works and write a valid tool schema (K9, S27)
- Implement a Python function that an LLM can call as a tool (K9)
- Describe what the Model Context Protocol (MCP) is and why it matters for building interoperable AI systems (K9, K25)
- Apply least-privilege scoping, allow-lists and human-approval gates so a tool-using agent is safe by design (K9, S27)
From asking to doing
In the previous lesson, the LLM answered a question. In this lesson, it takes action.
Tool calling is the mechanism that transforms an LLM from a text generator into a system that can do things — look up a database, call an internal API, run a calculation, write a file. The LLM does not execute the code; it decides when to call a function and with what parameters. Your code executes the function and hands the result back.
This is the architecture underlying almost every production AI system that does anything useful.
How function calling works
The flow has four steps:
- You define a tool — a JSON schema describing a function: its name, what it does, and what parameters it accepts.
- You send the tool definition alongside the prompt — the LLM knows it has access to this function.
- The LLM responds with a tool call — instead of (or in addition to) a text response, it returns a structured instruction: "call this function with these arguments."
- Your code runs the function and sends the result back to the LLM, which uses it to generate a final response.
The LLM never directly touches your systems. It only asks — your code decides whether and how to act.
🔑 Key term — Tool schema: A JSON object that describes a callable function to an LLM — its name, description, and parameter types. The description is the most important field: it is how the model decides when to use the tool.
A working example
from openai import OpenAI
import json
import os
from dotenv import load_dotenv
load_dotenv()
client = OpenAI(api_key=os.getenv("OPENAI_API_KEY"))
# The function your code will actually run
def get_order_status(order_id: str) -> dict:
# In production this would query your database
mock_orders = {
"ORD-001": {"status": "shipped", "eta": "2024-01-15"},
"ORD-002": {"status": "processing", "eta": "2024-01-17"},
}
return mock_orders.get(order_id, {"status": "not found"})
# The schema you give to the LLM
tools = [
{
"type": "function",
"function": {
"name": "get_order_status",
"description": "Retrieve the current status and estimated delivery date for a customer order. Use this when the user asks about an order.",
"parameters": {
"type": "object",
"properties": {
"order_id": {
"type": "string",
"description": "The order ID in the format ORD-XXX"
}
},
"required": ["order_id"]
}
}
}
]
# First API call — model may request a tool call
response = client.chat.completions.create(
model="gpt-4o-mini",
messages=[{"role": "user", "content": "What is the status of order ORD-001?"}],
tools=tools,
tool_choice="auto"
)
message = response.choices[0].message
# Handle the tool call if the model requested one
if message.tool_calls:
tool_call = message.tool_calls[0]
args = json.loads(tool_call.function.arguments)
result = get_order_status(args["order_id"])
# Second API call — send the function result back
final_response = client.chat.completions.create(
model="gpt-4o-mini",
messages=[
{"role": "user", "content": "What is the status of order ORD-001?"},
message,
{
"role": "tool",
"tool_call_id": tool_call.id,
"content": json.dumps(result)
}
],
tools=tools
)
print(final_response.choices[0].message.content)
# If the model chose not to use a tool, it may respond directly:
if not message.tool_calls:
print(message.content)
In production, set tool_choice='none' on the second call to prevent the model from requesting another tool call in the same turn.
Run this. Then modify get_order_status to return different data and observe what changes in the output.
What is MCP?
The Model Context Protocol (MCP) is an open standard — developed by Anthropic and adopted across the industry — that defines how AI models connect to tools, data sources and external systems in a consistent, interoperable way.
Without a standard, every AI tool integration is bespoke: a different connection pattern for every system. MCP replaces this with a common protocol. An MCP server exposes capabilities (tools, data, prompts) in a standardised format. Any MCP-compatible client — including Claude, many coding assistants and agent frameworks — can connect to it.
Why this matters for your work:
- When your AI system needs to connect to a CRM, a document store, a calendar, or an internal database, MCP gives you a connection pattern that will work across providers — not just one.
- The growing ecosystem of pre-built MCP servers means you can integrate many enterprise systems without writing custom connectors.
- Understanding MCP positions you to build AI systems that are modular and maintainable rather than tightly coupled to a single provider.
🔑 Key term — Model Context Protocol (MCP): An open standard protocol that enables AI models to connect to external tools and data sources in a consistent, provider-agnostic way. Defined by Anthropic; supported by a growing range of clients and servers.
Coach Cora
The tool description is where most developers go wrong. The LLM reads the description to decide whether to use the tool — not the parameter names, not the function name. Write it as if you are explaining to a capable colleague: what the tool does and specifically when to use it.
Scoping what a tool can do: least privilege
The moment you give an LLM a tool, you have given it a way to act on your systems — and the model deciding when to act is non-deterministic and can be manipulated (you saw prompt injection as a threat earlier in the programme). The single most important safety principle for tool-using systems is least privilege: a tool, and the credentials behind it, should grant only the access the task genuinely needs — nothing more.
The reason is blunt: an attacker who compromises the agent inherits its access. If your get_order_status tool holds a database credential that can also delete orders, then a successful prompt injection — or simply a model that misfires — can delete orders. If it holds read-only access to two specific fields, the blast radius of the same failure is a harmless read. You control that blast radius at design time, in the credential and the code, not in the prompt.
Four controls turn the mechanics above into a safe design:
- Least-privilege, scoped credentials. Give the tool a credential scoped to exactly the operation it needs — read-only where the task only reads, limited to specific tables/fields/endpoints. Never a shared admin key "for convenience". The example's
get_order_statusshould authenticate with a read-only credential, not one that can write. - A positive allow-list, not a deny-list. Decide explicitly what the agent is permitted to do and refuse everything else by default. Enumerating what to forbid always misses a case; enumerating what to allow fails safe.
- Human-approval gates for irreversible or high-impact actions. Reads can run autonomously; actions that spend money, send external messages, delete data, or change a person's record should require an explicit human confirmation step before your code executes them. Build the gate into the code path, not into the tool description — the model must not be able to talk its way past it.
- Never embed secrets in prompts or system prompts. Credentials, keys and tokens live in your execution environment (as in the
os.getenvpattern above), never in text the model can be induced to reveal via injection.
# Least privilege in practice: the tool authenticates with a read-only,
# narrowly-scoped credential — not a general admin key.
db = connect(credential=os.getenv("ORDERS_READONLY_DSN")) # read-only, orders table only
def cancel_order(order_id: str) -> dict:
# Irreversible action -> require an explicit human approval gate in code,
# BEFORE the effect happens. The model cannot bypass this.
if not human_has_approved(order_id):
return {"status": "pending_approval", "order_id": order_id}
return orders_service.cancel(order_id)
🔗 Two halves of one problem. Least-privilege scoping (this lesson) and prompt-injection defence go together: scoping limits what damage an injection can do, while input/output controls limit whether an injection lands. The prompt-injection defences — separating instructions from input, output validation, adversarial testing, plus the hands-on
validator.pyand OWASP LLM Top-10 — are covered in Unit 4, Lesson 2 (Trade-Offs and Risk Controls). Design for both; neither alone is sufficient. The full safe-agent design treatment for both routes lands in Module 5.
Activity — Tool calling
- Run the order status example above until you understand each step
- Write a new tool relevant to your workplace project. Ideas: look up a product, check a date, retrieve a category label, call a mock internal API
- Define the schema, implement the function, and build the complete two-call loop
- Add at least one error case: what happens if the order ID does not exist? Handle it gracefully.
- Scope it for least privilege: state, in a comment, the narrowest credential/permission your tool needs (e.g. read-only, single table). If your tool performs any irreversible action, add a human-approval gate in the code path before the effect happens.
- Commit your working code to GitHub
Add to your Commit Log: the name and purpose of the tool you built, a screenshot of the working output, the least-privilege scope you chose (and why), and a one-paragraph note on how this pattern could be applied in your organisation.
Checklist
- I can explain in my own words what happens in each of the four steps of a tool call
- I have written a working tool schema with a clear description
- My tool is implemented, handles at least one error case, and is committed to GitHub
- My Commit Log includes a note on how this pattern applies to my organisation
- I can explain what MCP is and describe one scenario in my organisation where an MCP server would be preferable to building a custom integration.
- My tool uses a least-privilege scope, and any irreversible action is behind a human-approval gate
KSB evidence focus
-
K9 — AI and automation concepts, models and limitations. Tool calling is one of the most important architectural concepts in practical AI development. Understanding how it works — and where it can fail — is core K9 knowledge.
-
S27 — The tool you built for your workplace use case is a direct application of S27: translating a business need into a technical capability. Your Commit Log note on organisational application is your evidence.
-
K25 — MCP is a current and rapidly evolving standard. Understanding it now, before it becomes ubiquitous, is the kind of current-awareness K25 asks for.
Up next: Lesson 3 addresses what happens when your architecture scales — managing token costs, rate limits and real-time constraints.