{"slug": "building-an-mcp-server-for-your-django-app-what-we-learned-doing-it-for-real", "title": "Building an MCP Server for Your Django App: What We Learned Doing It for Real", "summary": "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.", "body_md": "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.\n\nWe 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.\n\nBefore getting into implementation, it is worth being precise about what MCP does and doesn't solve.\n\n**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.\n\n**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.\n\nThe 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.\n\nWe 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.\n\n``` python\n# mcp_server/server.py\nimport django\nimport os\n\nos.environ.setdefault(\"DJANGO_SETTINGS_MODULE\", \"myproject.settings\")\ndjango.setup()\n\nfrom mcp.server import Server\nfrom mcp.server.stdio import stdio_server\nfrom mcp.types import Tool, TextContent\nimport mcp.types as types\nfrom pydantic import BaseModel\n\nfrom orders.models import Order\nfrom orders.services import get_order_summary, update_order_status\n\napp = Server(\"myproject-mcp\")\n\nclass GetOrderInput(BaseModel):\n    order_id: str\n\nclass UpdateOrderStatusInput(BaseModel):\n    order_id: str\n    new_status: str\n    reason: str | None = None\n\n@app.list_tools()\nasync def list_tools() -> list[Tool]:\n    return [\n        Tool(\n            name=\"get_order\",\n            description=(\n                \"Retrieve full details for a specific order including line items, \"\n                \"status history, and customer information.\"\n            ),\n            inputSchema=GetOrderInput.model_json_schema(),\n        ),\n        Tool(\n            name=\"update_order_status\",\n            description=(\n                \"Update the status of an order. Valid statuses: pending, processing, \"\n                \"shipped, delivered, cancelled. Requires a reason when cancelling.\"\n            ),\n            inputSchema=UpdateOrderStatusInput.model_json_schema(),\n        ),\n    ]\n\n@app.call_tool()\nasync def call_tool(name: str, arguments: dict) -> list[TextContent]:\n    if name == \"get_order\":\n        params = GetOrderInput(**arguments)\n        try:\n            order = Order.objects.select_related(\"customer\").prefetch_related(\n                \"line_items\"\n            ).get(order_id=params.order_id)\n            summary = get_order_summary(order)\n            return [TextContent(type=\"text\", text=summary)]\n        except Order.DoesNotExist:\n            return [TextContent(type=\"text\", text=f\"Order {params.order_id} not found.\")]\n\n    if name == \"update_order_status\":\n        params = UpdateOrderStatusInput(**arguments)\n        result = update_order_status(\n            order_id=params.order_id,\n            new_status=params.new_status,\n            reason=params.reason,\n        )\n        return [TextContent(type=\"text\", text=result.message)]\n\n    return [TextContent(type=\"text\", text=f\"Unknown tool: {name}\")]\n\nif __name__ == \"__main__\":\n    import asyncio\n    asyncio.run(stdio_server(app))\n```\n\nRun it with: `python mcp_server/server.py`\n\nThe 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.\n\nBad description: `\"Get order details\"`\n\nGood 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.\"`\n\nThe 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.\n\nEvery 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.\n\nWe 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.\n\nThe 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.\n\nEvery call to a write tool should create a record:\n\n``` python\nfrom django.utils import timezone\n\nclass MCPToolCall(models.Model):\n    tool_name = models.CharField(max_length=100)\n    arguments = models.JSONField()\n    result = models.TextField()\n    caller_session = models.CharField(max_length=255, blank=True)\n    called_at = models.DateTimeField(default=timezone.now)\n    success = models.BooleanField(default=True)\n    error = models.TextField(blank=True)\n\n    class Meta:\n        ordering = [\"-called_at\"]\n        indexes = [\n            models.Index(fields=[\"tool_name\", \"called_at\"]),\n        ]\n```\n\nLog before you execute, not after. If the tool call fails or the process dies, you want a record that the attempt was made.\n\nThe MCP SDK is async. Django's ORM is sync. You will hit `SynchronousOnlyOperation` errors if you call ORM queries directly from async handlers.\n\n``` python\nfrom asgiref.sync import sync_to_async\n\n@app.call_tool()\nasync def call_tool(name: str, arguments: dict) -> list[TextContent]:\n    if name == \"get_order\":\n        params = GetOrderInput(**arguments)\n\n        get_order = sync_to_async(\n            lambda: Order.objects.select_related(\"customer\")\n                                 .prefetch_related(\"line_items\")\n                                 .get(order_id=params.order_id)\n        )\n\n        try:\n            order = await get_order()\n            summary = await sync_to_async(get_order_summary)(order)\n            return [TextContent(type=\"text\", text=summary)]\n        except Order.DoesNotExist:\n            return [TextContent(type=\"text\", text=f\"Order {params.order_id} not found.\")]\n```\n\nAlternatively, 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.\n\nStdio 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:\n\n``` python\n# mcp_server/wsgi_server.py\nfrom mcp.server.sse import SseServerTransport\nfrom starlette.applications import Starlette\nfrom starlette.routing import Route, Mount\n\ntransport = SseServerTransport(\"/messages/\")\n\nasync def handle_sse(request):\n    async with transport.connect_sse(\n        request.scope, request.receive, request._send\n    ) as streams:\n        await app.run(streams[0], streams[1], app.create_initialization_options())\n\nstarlette_app = Starlette(\n    routes=[\n        Route(\"/sse\", endpoint=handle_sse),\n        Mount(\"/messages/\", app=transport.handle_post_message),\n    ]\n)\n```\n\nRun 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.\n\nMCP 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.\n\nThe 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.\n\nStart narrow. Add tools as you understand the usage patterns. Audit everything.\n\n[Lycore builds production AI systems](https://www.lycore.com/ai-development-services/) for businesses — MCP servers, agents, RAG pipelines, and custom LLM integrations on Django, React, Flutter, and .NET. [Get in touch](https://www.lycore.com/contact-us/) if you want to talk through your use case.", "url": "https://wpnews.pro/news/building-an-mcp-server-for-your-django-app-what-we-learned-doing-it-for-real", "canonical_source": "https://dev.to/lycore/building-an-mcp-server-for-your-django-app-what-we-learned-doing-it-for-real-2idb", "published_at": "2026-09-21 03:51:58+00:00", "updated_at": "2026-09-21 04:23:14.717321+00:00", "lang": "en", "topics": ["ai-agents", "agent-protocols", "developer-tools", "ai-tools", "ai-infrastructure"], "entities": ["Anthropic", "Model Context Protocol", "Django", "Claude", "Cursor", "OpenAI", "Python"], "alternates": {"html": "https://wpnews.pro/news/building-an-mcp-server-for-your-django-app-what-we-learned-doing-it-for-real", "markdown": "https://wpnews.pro/news/building-an-mcp-server-for-your-django-app-what-we-learned-doing-it-for-real.md", "text": "https://wpnews.pro/news/building-an-mcp-server-for-your-django-app-what-we-learned-doing-it-for-real.txt", "jsonld": "https://wpnews.pro/news/building-an-mcp-server-for-your-django-app-what-we-learned-doing-it-for-real.jsonld"}}