AI agents (MCP)¶
Run an MCP (Model Context Protocol) server that turns your viewsets into tools. AI clients like Claude Desktop or VS Code can then call your CRUD endpoints and custom actions.
Set up the server¶
Install the extra¶
pip install "django-ninja-aio-crud[mcp]"
Add the app¶
Add "ninja_aio" to INSTALLED_APPS so Django finds the mcp_server
command:
INSTALLED_APPS = [
# ...
"ninja_aio",
"blog",
]
Point it at your API¶
Set the dotted path to your NinjaAIO instance:
NINJA_AIO_MCP_API = "blog.api.api"
Run it¶
python manage.py mcp_server
The server talks over stdio. Your MCP client starts it, so you don't keep it running yourself.
Command options¶
python manage.py mcp_server blog.api.api --name blog-api
| Option | What it does |
|---|---|
api |
Dotted path to the NinjaAIO instance. Defaults to NINJA_AIO_MCP_API |
--name |
The server name shown to clients. Defaults to django-ninja-aio-crud |
Without the path and without the setting, the command stops with an error.
Know which tools you get¶
Every viewset registered with @api.viewset, or on a NinjaAIORouter added
with api.add_router(...), becomes a set of tools. Nested routers count too.
Tool names are the model class name in lower case, plus the operation:
| Tool | Input |
|---|---|
article_list |
The viewset query_params filters, plus the pagination parameters (page, page_size) |
article_create |
The create fields |
article_retrieve |
pk |
article_update |
pk and the update fields |
article_delete |
pk |
article_bulk_create |
items: a list of create objects |
article_bulk_update |
items: a list of update objects, each with its id |
article_bulk_delete |
ids: a list of primary keys |
article_<method> |
Custom actions. pk for detail actions, plus the method parameters |
A tool exists only when its endpoint exists. disable, missing schemas and
bulk_operations apply to tools too. Actions with include_in_schema=False
are skipped. See Viewsets.
When two viewsets serve the same model, their tool names start with the URL
prefix: viewsets on sync/articles and async/articles give
sync_articles_article_list and async_articles_article_list. Two viewsets
with the same model and the same prefix raise ImproperlyConfigured.
@api.viewset(model=Article, prefix="articles")
class ArticleViewSet(APIViewSet):
bulk_operations = ["create", "delete"]
@on("publish")
async def publish(self, request, obj):
obj.is_published = True
await obj.asave()
return {"published": obj.pk}
This gives article_list, article_create, article_retrieve,
article_update, article_delete, article_bulk_create,
article_bulk_delete and article_publish. An @on action takes only
pk.
The tool description of an action is its description, or its summary.
See Custom actions.
Endpoints of an APIView become tools too, named after the view class, the
function or action name, and the HTTP method: reportsview_totals_get.
Endpoints with include_in_schema=False are skipped.
api.registered_viewsets() and api.registered_views() return everything the
server exposes, including what is registered on routers.
Choose what to expose¶
Write a small script and pass the viewsets and views you want:
import asyncio
import os
import django
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "mysite.settings")
django.setup()
from ninja_aio.mcp import run_mcp_server
from blog.api import api, ArticleViewSet
if __name__ == "__main__":
asyncio.run(run_mcp_server(api, viewsets=[ArticleViewSet], views=[]))
python mcp_server.py
| Argument | What it does |
|---|---|
viewsets |
The viewsets to expose. Defaults to api.registered_viewsets() |
views |
The views to expose. Defaults to api.registered_views() |
name |
The server name shown to clients |
request_factory |
A function that builds the request passed to each tool call |
This script also works without "ninja_aio" in INSTALLED_APPS.
Handle authentication¶
Tool calls skip the auth of your API and viewsets. Each call gets a request
with an anonymous request.user and no request.auth.
Your viewset hooks and permission checks still run. Use request_factory to
give tool calls an identity your checks accept:
from django.contrib.auth import get_user_model
from django.test.client import AsyncRequestFactory
agent = get_user_model().objects.get(username="mcp-agent")
def mcp_request():
request = AsyncRequestFactory().get("/mcp/")
request.user = agent
request.auth = agent
return request
if __name__ == "__main__":
asyncio.run(run_mcp_server(api, request_factory=mcp_request))
The factory is a plain function called for each tool call. Load the user before the server starts, not inside the factory.
With PermissionViewSetMixin, a denied check returns a 403 error to the
client. See Permissions.
Warning
Anyone who can start the MCP server can do what your hooks allow. Protect it like a Django shell.
Connect a client¶
Use absolute paths, because the client starts the server from its own folder.
{
"mcpServers": {
"blog": {
"command": "/path/to/project/.venv/bin/python",
"args": ["/path/to/project/manage.py", "mcp_server"]
}
}
}
{
"servers": {
"blog": {
"type": "stdio",
"command": "/path/to/project/.venv/bin/python",
"args": ["/path/to/project/manage.py", "mcp_server"]
}
}
}
Errors like a missing object or invalid input come back to the client as a tool error with the same message as the HTTP API.