cd /news/ai-agents/building-an-mcp-server-for-your-djan… · home topics ai-agents article
[ARTICLE · art-135512] src=dev.to ↗ pub= topic=ai-agents verified=true sentiment=· neutral

Building an MCP Server for Your Django App: What We Learned Doing It for Real

A development team has detailed lessons from building production Model Context Protocol (MCP) servers for client Django applications, using the mcp Python SDK to expose tools like order retrieval and status updates to AI clients such as Claude and Cursor. The team emphasizes that MCP standardizes tool exposure over JSON-RPC but provides no security, access control, or rate limiting, which must still be built on top. The server runs as a standalone process that imports Django models and services rather than operating as a Django view.

by read6 min views4 publishedSep 21, 2026

MCP — the Model Context Protocol — has gone from a niche Anthropic spec to something every AI-forward team is talking about. The pitch is simple: instead of writing custom tool integrations for every agent you build, you expose your application's capabilities as an MCP server, and any MCP-compatible client (Claude, Cursor, your own agent) can use them.

We have been building MCP servers for client Django applications for a few months now. This post is what we learned — not a hello-world walkthrough, but the decisions and tradeoffs that actually matter when you're doing it in a production codebase.

Before getting into implementation, it is worth being precise about what MCP does and doesn't solve.

What it gives you: a standardised way to expose tools, resources, and prompts to AI clients over a defined protocol (JSON-RPC over stdio or HTTP/SSE). Instead of writing a custom function-calling schema for OpenAI, a different tool spec for Claude, and another for your internal agent, you write one MCP server and every compatible client can use it.

What it doesn't give you: security, access control, rate limiting, or any business logic. MCP is a transport layer. All of that still needs to be built on top.

The value is standardisation, not magic. For teams building more than one agent, or teams that want their internal tools to work with off-the-shelf AI clients, that standardisation compounds quickly.

We use the mcp Python SDK. The server lives as a standalone process that your Django application talks to — it imports Django models and services, but it is not a Django view.

import django
import os

os.environ.setdefault("DJANGO_SETTINGS_MODULE", "myproject.settings")
django.setup()

from mcp.server import Server
from mcp.server.stdio import stdio_server
from mcp.types import Tool, TextContent
import mcp.types as types
from pydantic import BaseModel

from orders.models import Order
from orders.services import get_order_summary, update_order_status

app = Server("myproject-mcp")

class GetOrderInput(BaseModel):
    order_id: str

class UpdateOrderStatusInput(BaseModel):
    order_id: str
    new_status: str
    reason: str | None = None

@app.list_tools()
async def list_tools() -> list[Tool]:
    return [
        Tool(
            name="get_order",
            description=(
                "Retrieve full details for a specific order including line items, "
                "status history, and customer information."
            ),
            inputSchema=GetOrderInput.model_json_schema(),
        ),
        Tool(
            name="update_order_status",
            description=(
                "Update the status of an order. Valid statuses: pending, processing, "
                "shipped, delivered, cancelled. Requires a reason when cancelling."
            ),
            inputSchema=UpdateOrderStatusInput.model_json_schema(),
        ),
    ]

@app.call_tool()
async def call_tool(name: str, arguments: dict) -> list[TextContent]:
    if name == "get_order":
        params = GetOrderInput(**arguments)
        try:
            order = Order.objects.select_related("customer").prefetch_related(
                "line_items"
            ).get(order_id=params.order_id)
            summary = get_order_summary(order)
            return [TextContent(type="text", text=summary)]
        except Order.DoesNotExist:
            return [TextContent(type="text", text=f"Order {params.order_id} not found.")]

    if name == "update_order_status":
        params = UpdateOrderStatusInput(**arguments)
        result = update_order_status(
            order_id=params.order_id,
            new_status=params.new_status,
            reason=params.reason,
        )
        return [TextContent(type="text", text=result.message)]

    return [TextContent(type="text", text=f"Unknown tool: {name}")]

if __name__ == "__main__":
    import asyncio
    asyncio.run(stdio_server(app))

Run it with: python mcp_server/server.py

The most important thing in an MCP server is not the code — it is the tool descriptions. This is where you communicate with the LLM that is deciding which tools to call and with what parameters.

Bad description: "Get order details"

Good description: "Retrieve full details for a specific order by its order ID (format: ORD-XXXXX). Returns customer name, email, line items with quantities and prices, current status, and status history. Use this before attempting to update an order status."

The description tells the model when to use the tool, what inputs to provide, and what it will get back. Treat it as seriously as you would a system prompt.

Every tool you expose is an action an agent can take. Start with read-only tools. Add write tools only when you have thought through what happens when the agent calls them incorrectly.

We made the mistake of exposing a send_email tool early in one project. The agent used it in a context we did not anticipate — sending a summary email to a customer before the data it was summarising was complete. The email was not wrong exactly, but it was premature and caused a support ticket.

The fix was not to remove the tool, but to add a dry_run parameter and require the agent to produce a confirmation before calling the live version.

Every call to a write tool should create a record:

from django.utils import timezone

class MCPToolCall(models.Model):
    tool_name = models.CharField(max_length=100)
    arguments = models.JSONField()
    result = models.TextField()
    caller_session = models.CharField(max_length=255, blank=True)
    called_at = models.DateTimeField(default=timezone.now)
    success = models.BooleanField(default=True)
    error = models.TextField(blank=True)

    class Meta:
        ordering = ["-called_at"]
        indexes = [
            models.Index(fields=["tool_name", "called_at"]),
        ]

Log before you execute, not after. If the tool call fails or the process dies, you want a record that the attempt was made.

The MCP SDK is async. Django's ORM is sync. You will hit SynchronousOnlyOperation errors if you call ORM queries directly from async handlers.

from asgiref.sync import sync_to_async

@app.call_tool()
async def call_tool(name: str, arguments: dict) -> list[TextContent]:
    if name == "get_order":
        params = GetOrderInput(**arguments)

        get_order = sync_to_async(
            lambda: Order.objects.select_related("customer")
                                 .prefetch_related("line_items")
                                 .get(order_id=params.order_id)
        )

        try:
            order = await get_order()
            summary = await sync_to_async(get_order_summary)(order)
            return [TextContent(type="text", text=summary)]
        except Order.DoesNotExist:
            return [TextContent(type="text", text=f"Order {params.order_id} not found.")]

Alternatively, run the server with DJANGO_ALLOW_ASYNC_UNSAFE=true during development to surface the errors clearly before you fix them. Never leave it set in production.

Stdio works well for local Claude Desktop use. For production agents that need to call your MCP server over the network, use the HTTP/SSE transport:

from mcp.server.sse import SseServerTransport
from starlette.applications import Starlette
from starlette.routing import Route, Mount

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),
    ]
)

Run with uvicorn mcp_server.wsgi_server:starlette_app. Add authentication middleware before this reaches production — the MCP spec does not include auth, so you need to handle it at the transport layer.

MCP is worth adopting if you are building more than one agent or want your Django application's tools to work with multiple AI clients without rewriting integrations. The protocol is straightforward; the complexity is in the tool design and the operational concerns around write access, audit logging, and auth.

The teams getting the most value are the ones treating their MCP server like an internal API: documented, versioned, with clear contracts on what each tool does and doesn't do. The teams running into trouble are the ones who exposed everything quickly and then found agents calling tools in combinations they did not anticipate.

Start narrow. Add tools as you understand the usage patterns. Audit everything.

Lycore builds production AI systems for businesses — MCP servers, agents, RAG pipelines, and custom LLM integrations on Django, React, Flutter, and .NET. Get in touch if you want to talk through your use case.

── more in #ai-agents 4 stories · sorted by recency
── more on @anthropic 3 stories trending now
sponsored brought to you by zahid.host 4,200+ EU-deployed projects
reading about agents? ship yours in a single git push.

Run your AI side-project on zahid.host

EU-based hosting, git-push deploys, automatic HTTPS, no cold starts. Free tier with a custom domain — perfect for shipping the agent you just read about.

$git push zahid main
Live at https://your-agent.zahid.host
Get free account → Pricing
from €0/mo · no card required
LIVE [news/building-an-mcp-serv…] indexed:0 read:6min 2026-09-21 ·