Building an MCP Server for Your Django App: What We Learned Doing It for Real
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. What MCP actually gives you 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. The basic structure of a Django MCP server 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. # mcp_server/server.py 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 decisions that actually matter Tool descriptions are your prompt engineering surface 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. Scope your tools narrowly 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. Add an audit log for every tool call 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. Handle Django's sync/async boundary explicitly 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. Serving over HTTP instead of stdio 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: # mcp_server/wsgi_server.py 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. The honest summary 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. Top comments (0)
Comments
No comments yet. Start the discussion.