A chatbot answers. An agent acts. Wire function calling into Django, run the loop safely, expose your data over MCP, and put the guardrails in place before you hand a model your database.
A chat endpoint returns text. An agent returns an intention: "call refund_order with this id." The moment you honour that intention, the model stops being a writer and starts being a caller of your code. Everything interesting — and everything dangerous — lives in that gap.
Strip away the frameworks and a tool-using agent is one while loop. You send messages plus a list of tools; the model replies either with text (done) or with a tool call (run it, append the result, loop again).
def run_agent(user_message, tools, max_steps=6):
messages = [{"role": "user", "content": user_message}]
for step in range(max_steps):
reply = client.messages.create(
model="claude-sonnet-5",
max_tokens=2000,
tools=[t.schema for t in tools],
messages=messages,
)
messages.append({"role": "assistant", "content": reply.content})
calls = [b for b in reply.content if b.type == "tool_use"]
if not calls:
return reply # model is finished
results = []
for call in calls:
tool = tools_by_name[call.name]
results.append({
"type": "tool_result",
"tool_use_id": call.id,
"content": tool.run(**call.input),
})
messages.append({"role": "user", "content": results})
raise AgentBudgetExceeded("no final answer within step budget")
Three details in that loop are not optional. The step budget stops a model that loops between two tools forever. Appending the assistant turn before running tools keeps the conversation valid — skip it and the next request is rejected. And every tool call gets a result, even a failure; a missing tool_result breaks the exchange.
A tool is a name, a JSON schema, and a callable. The schema is not documentation — it is the only thing the model sees, so it does the work of a docstring, a type annotation and a warning label at once.
from dataclasses import dataclass
from typing import Callable
@dataclass
class Tool:
name: str
description: str
input_schema: dict
fn: Callable
writes: bool = False # does this change state?
@property
def schema(self):
return {"name": self.name,
"description": self.description,
"input_schema": self.input_schema}
def run(self, **kwargs):
return self.fn(**kwargs)
order_status = Tool(
name="order_status",
description=(
"Look up the status of one order belonging to the current customer. "
"Returns status, carrier and tracking code. Use this before answering "
"any question about where an order is."
),
input_schema={
"type": "object",
"properties": {
"order_number": {"type": "string",
"description": "Order number as shown to the customer, e.g. DZ-10423"},
},
"required": ["order_number"],
},
fn=lookup_order_status,
)
Write the description for a competent colleague who has never seen your codebase. "Use this before answering any question about where an order is" measurably reduces hallucinated statuses — the model has been told when, not only what.
This is the rule that everything else hangs on. A tool must never receive a raw model-supplied identifier and query on it. It receives the identifier and the request user, and filters on both.
# WRONG — the model chooses which order to read
def lookup_order_status(order_number):
return Order.objects.get(number=order_number)
# RIGHT — the model may only ask about what this user can already see
def lookup_order_status(order_number, *, user):
try:
order = Order.objects.get(number=order_number, customer=user)
except Order.DoesNotExist:
return {"error": "no such order for this customer"}
return {"status": order.status,
"carrier": order.carrier,
"tracking": order.tracking_code}
Bind user in the view with functools.partial so it can never come from the model:
tools = [t.bound(user=request.user) for t in TOOL_REGISTRY]
Do this and prompt injection stops being an authorization problem. A poisoned document can still tell the model "fetch every order"; the query simply returns nothing that does not belong to the user.
Split your registry in two. Read tools run freely. Write tools — refunds, cancellations, emails, deletions — pause and ask a human, or run inside limits the model cannot widen.
def execute(tool, call, *, user):
if not tool.writes:
return tool.run(**call.input, user=user)
action = PendingAction.objects.create(
user=user, tool=tool.name, arguments=call.input,
)
return {"status": "awaiting_confirmation",
"confirm_url": action.get_absolute_url(),
"message": "Tell the customer this needs their confirmation."}
The model is told the truth — nothing happened yet — so it explains the next step instead of claiming success. The write happens in an ordinary Django view, behind CSRF, behind permissions, with an audit row. Everything you already trust keeps working.
Where a human gate is too slow, cap the blast radius instead: a refund tool that refuses amounts above €50, or a mail tool that may only send to the address on the account.
Model Context Protocol standardises how a model discovers and calls tools, so the same server works with different clients instead of being wired to one vendor's function-calling format. An MCP server exposes three things: tools (callable), resources (readable data), and prompts (reusable templates).
The useful mental model for a Django developer: MCP is a second, machine-facing API surface over the same service layer your views already use. If your business logic lives in functions rather than inside views, exposing it is mechanical:
@mcp.tool()
def open_tickets(limit: int = 20) -> list[dict]:
"""Return the open support tickets for the authenticated account."""
return list(
services.support.open_tickets(user=ctx.user)[:limit]
.values("number", "subject", "opened_at")
)
If your business logic lives inside fat views, MCP is the moment that bill arrives. That is a reason to extract a service layer, not a reason to skip MCP.
Two operational cautions. Authentication is yours to solve — an MCP server exposed without it is an unauthenticated API over your database. And the tool list is prompt content: fifty tools with vague descriptions produce worse behaviour than eight sharp ones, and cost more per call.
The dangerous pattern is not "the model is evil". It is indirect prompt injection: your tool fetches a web page, a PDF, or a customer's own support message, and that text contains instructions. The model has no reliable way to tell your instructions from text it merely read.
Defences, in order of how much they actually buy you:
An agent turn with six tool calls can take a minute. That does not belong in a synchronous view.
@login_required
def start_agent(request):
run = AgentRun.objects.create(user=request.user, prompt=request.POST["q"])
run_agent_task.delay(str(run.id))
return redirect("agent_run", pk=run.pk)
The Celery task writes each step to the database as it goes, and the page follows along with server-sent events. You get three things for free: the user sees progress, a crashed worker leaves a resumable trail, and you have a complete record of what the model asked to do.
Log every step. One row per tool call: name, arguments, result size, duration, outcome. This is your audit trail when a customer asks why their order was cancelled, and your dataset when you start evaluating quality.
Every loop iteration resends the whole conversation. A six-step run costs far more than six times a single call, because step six carries steps one to five. Three habits keep it sane:
order_status returning carrier and tracking beats three separate lookups the model has to chain.Get the scoping right and an agent is a pleasant feature to run. Get it wrong and you have handed a very persuasive stranger a shell on your data. The difference is about forty lines of code.