Quickstart
From a fresh Django project to a working MCP server (with REST and OpenAPI as a bonus) in five minutes โ then a fully-loaded resource showing every attribute you can use. Already have a database but no Django? MCP from your DB โก generates the whole project for you in one command.
Want your AI assistant to install 0-mcp for you? Paste this prompt into Claude / Cursor / Copilot:
Read https://0-mcp.com/docs/llms-full.txt and follow the Installation and Quickstart sections to add 0-mcp to my Django project.
The llms-full.txt file is the full documentation in LLM-friendly markdown โ every page, in reading order, with source URLs for citation.
Minimal โ three steps, two lines of code
1. A Django model
# myapp/models.py
from django.db import models
class Space(models.Model):
name = models.CharField(max_length=100)
description = models.TextField(blank=True)
active = models.BooleanField(default=True)
created_at = models.DateTimeField(auto_now_add=True)2. A resource
Imports โ once per app:
# myapp/resources.py from zeromcp import BaseResource from myapp.models import Space
The resource โ 2 lines:
class SpaceResource(BaseResource):
model = Space
authenticated = False # demo only โ drop this line for production (default is True)3. URLs
# urls.py
from zeromcp import get_routes
from myapp.resources import SpaceResource
endpoints = {'spaces(.*)$': SpaceResource}
urlpatterns = get_routes(endpoints, mcp=True)
# โ REST: /spaces, /spaces/{id}
# โ MCP: POST /mcp (JSON-RPC for the tools/call payloads below)4. Run
python manage.py migrate python manage.py runserver
Try it
Same operations, two surfaces โ pick whichever you prefer.
List
๐ค MCP โ tools/call
{
"jsonrpc": "2.0",
"id": 1,
"method": "tools/call",
"params": {
"name": "list_space",
"arguments": {}
}
}๐ REST
curl http://localhost:8000/spaces
Create
๐ค MCP โ tools/call
{
"jsonrpc": "2.0",
"id": 2,
"method": "tools/call",
"params": {
"name": "create_space",
"arguments": {"name": "Demo", "description": "Hi"}
}
}๐ REST
curl -X POST http://localhost:8000/spaces \
-H 'Content-Type: application/json' \
-d '{"name": "Demo", "description": "Hi"}'Detail
๐ค MCP โ tools/call
{
"jsonrpc": "2.0",
"id": 3,
"method": "tools/call",
"params": {
"name": "get_space",
"arguments": {"id": 1}
}
}๐ REST
curl http://localhost:8000/spaces/1
Update
๐ค MCP โ tools/call
{
"jsonrpc": "2.0",
"id": 4,
"method": "tools/call",
"params": {
"name": "update_space",
"arguments": {"id": 1, "active": false}
}
}๐ REST
curl -X PATCH http://localhost:8000/spaces/1 \
-H 'Content-Type: application/json' \
-d '{"active": false}'Delete
๐ค MCP โ tools/call
{
"jsonrpc": "2.0",
"id": 5,
"method": "tools/call",
"params": {
"name": "delete_space",
"arguments": {"id": 1}
}
}๐ REST
curl -X DELETE http://localhost:8000/spaces/1
Search + filter + pagination
๐ค MCP โ tools/call
{
"jsonrpc": "2.0",
"id": 6,
"method": "tools/call",
"params": {
"name": "list_space",
"arguments": {
"search": "demo",
"filter": {"active": true},
"order_by": "-created_at",
"page": 1,
"limit": 20
}
}
}๐ REST
curl 'http://localhost:8000/spaces?search=demo&active=true&order_by=-created_at&page=1&limit=20'
Interactive UI
open http://localhost:8000/docs
Resources without a model
model is only required for CRUD endpoints. For RPC-style endpoints โ login, webhooks, health checks, search, integrations, anything that does not map to "row by id" โ leave model unset and define your own routes:
from zeromcp import BaseResource
class WebhookResource(BaseResource):
authenticated = False
allowed_methods = ['post']
routes = [
{'path': r'/stripe$', 'func': 'stripe', 'allowed_methods': ['post']},
{'path': r'/github$', 'func': 'github', 'allowed_methods': ['post']},
]
async def stripe(self, request, match=None, body=None):
# body is the parsed JSON
...
return {'received': True}
async def github(self, request, match=None, body=None):
...
return {'received': True}These resources still get every framework benefit:
- Async dispatch
- Per-IP rate limit + abuse blocking
- SecurityMiddleware (scanner blocking, 4xx flood detection)
- Authentication when
authenticated = True - Pydantic validation when you set a
requestschema on the route handler via@openapi(...) - OpenAPI spec entries for every custom route
What you lose without a model:
- The default
get/post/patch/delete(andget_obj/get_objs/create_obj/update_obj/delete_obj) implementations โ they crash because they assumeself.modelexists. Override any of them and they work fine without a model. - Auto-fill (
created_by,owner_id) โ this only happens inside the defaultcreate_obj - Cache namespaces (you can still cache โ set
cache: Trueon the route โ keys just won't be model-namespaced) - Field whitelists from the model (
fields,all_fields,m2m_fieldsare not auto-populated)
Overriding the standard verbs without a model
You can still use get / post / patch / delete (instead of, or in addition to, routes) โ just override them with your own logic:
class SearchResource(BaseResource):
authenticated = True
async def get(self, request):
q = request.GET.get('q', '')
results = await search_engine.query(q)
return await self.serialize({'results': results})
async def post(self, request):
# self.body is already parsed (and Pydantic-validated if create_schema is set)
job = await enqueue_job(self.body)
return await self.serialize({'job_id': job.id})You can also override the inner pieces โ get_obj, create_obj, etc. โ and have them backed by something other than the ORM:
class CartResource(BaseResource):
async def get_obj(self, id):
# id comes from the URL โ fetch from Redis instead of a Django model
return await redis.hgetall(f'cart:{id}')
async def create_obj(self, request, body):
new_id = uuid4().hex
await redis.hset(f'cart:{new_id}', mapping=body)
return {'id': new_id, **body}Cache invalidation, response wrapping, dehydrate, post_process and the rest of dispatch all keep working โ they don't care where the data comes from.
Use model-less resources for everything that is not CRUD. Webhooks, OAuth callbacks, search-across-models, dashboards, integrations, batch jobs โ they all share the same security, rate limit and OpenAPI infrastructure as your CRUD resources, just without the model attribute.
Full example โ every attribute explained
Drop this into a real project as a reference. Every field is optional unless marked required.
from pydantic import BaseModel, EmailStr, Field
from zeromcp import BaseResource, openapi
from myapp.models import User
# โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
# Optional Pydantic schemas (install 0-mcp[schemas])
# โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
class UserCreate(BaseModel):
email: EmailStr
password: str = Field(min_length=8)
name: str
class UserUpdate(BaseModel):
email: EmailStr | None = None
name: str | None = None
class UserOut(BaseModel):
id: int
email: EmailStr
name: str
# โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
# The resource
# โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
class UserResource(BaseResource):
# โโ Routing โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
model = User # required only for CRUD; omit for custom-route-only resources
authenticated = True # default; False = public
summary = 'User' # OpenAPI / MCP label
description = 'End-user accounts.' # OpenAPI / MCP description
allowed_methods = ['get', 'post', 'patch', 'delete']
# โโ Custom routes (alongside CRUD) โโโโโโโโโโโโโโโโโโโโโโโโโโโโ
routes = [
{'path': r'/me$', 'func': 'me', 'allowed_methods': ['get'], 'cache': True},
{'path': r'/login$', 'func': 'login', 'allowed_methods': ['post']},
{'path': r'/(\d+)/promote$','func': 'promote', 'allowed_methods': ['patch']},
]
# โโ Read whitelists โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
list_fields = ['id', 'email', 'name', 'is_admin']
list_exclude_fields = ['internal_token'] # subtract from list_fields
edit_fields = ['id', 'email', 'name', 'preferences']
edit_exclude_fields = ['_state', 'password'] # subtract from edit_fields
# โโ Write whitelists โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
create_fields = ['email', 'password', 'name'] # required for POST
update_fields = ['email', 'name', 'preferences'] # required for PATCH
# โโ Pydantic schemas (override the *_fields whitelists) โโโโโโโ
create_schema = UserCreate
update_schema = UserUpdate
list_schema = UserOut
# โโ Filtering โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
filter_fields = ['is_admin', 'email', 'created_at']
queryset_filter = {'deleted': False} # always applied
filters = None # extra Q objects, optional
# โโ Search โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
search_fields = ['email', 'name']
search_operator = 'icontains' # default
# โโ Ordering โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
order_fields = ['id', 'email', 'created_at']
order_by = '-created_at' # default ordering
# โโ Pagination โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
limit = 25 # 0 = unlimited
page = 1
# โโ Relations โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
list_related_fields = {'account': ['id', 'name']} # select_related on list
edit_related_fields = {'account': ['id', 'name', 'plan__tier']}# select_related on detail
list_prefetch_related = {'orders': ['id', 'total']} # prefetch on list
edit_prefetch_related = {'orders': ['id', 'total', 'created']} # prefetch on detail
# โโ Ownership (scopes GET/LIST/PATCH/DELETE to rows owned by user) โโโโโ
owner_field = 'owner_id'
# โโ Cache (Redis-backed, namespace invalidation) โโโโโโโโโโโโโโ
cache = True
cache_ttl = 600 # seconds
session_cache = False # fold session id into key
# โโ Response shape โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
normalize_list = False # True โ {id: {...}} dict
normalize_obj = False # True โ {id: {...}} dict on detail
# โโ Hooks (override as needed) โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
async def pre_process(self, request):
"""Runs after auth, before body parsing. Set queryset_filter, fetch context."""
async def before_cache(self, request):
"""Runs before the Redis cache lookup. Mutate self.cache_key to vary on extras."""
role = self.user.get('role') if self.user else 'anon'
self.cache_key += f':role={role}'
async def hydrate(self, body):
"""Mutate the parsed JSON body before validation/handler."""
if 'email' in body:
body['email'] = body['email'].lower().strip()
async def dehydrate(self, row):
"""Per-row transform before response. Strip secrets, format fields."""
row.pop('password', None)
async def alter_list(self, results):
"""Reshape the entire list before the meta envelope."""
return results
async def alter_detail(self, result):
"""Reshape the detail object before serialization."""
return result
async def post_process(self, response):
"""Last chance before JSON encoding + cache save."""
return response
async def add_m2m(self, result):
"""Hook for many-to-many relationships."""
# โโ CRUD pieces (override + super to keep the surrounding plumbing) โโ
async def get_objs(self, request):
"""List GET. Receives `self.queryset` already filtered/paginated/ordered.
Default returns a list of dicts. Override to add computed columns,
aggregate, or replace the data source entirely."""
rows = await super().get_objs(request)
for row in rows:
row['display_name'] = row.get('name', '').title()
return rows
async def get_obj(self, id):
"""Detail GET. Default fetches by pk with select_related/prefetch.
Override to load extra context for a single row."""
result = await super().get_obj(id)
result['extra'] = await fetch_extra(self.obj)
return result
async def create_obj(self, request, body):
"""POST handler body. Default validates, auto-fills created_by/owner,
handles m2m + custom_*. Override to add side effects (email, webhook)."""
result = await super().create_obj(request, body)
await send_welcome_email(self.obj)
return result
async def update_obj(self, id, body):
"""PATCH handler body. Default validates, applies diff to self.diff,
single SQL UPDATE. Override to enforce business rules per field."""
if 'is_admin' in body and not self.user.get('is_owner'):
raise HTTPException(403, 'Only owners can promote admins')
return await super().update_obj(id, body)
async def delete_obj(self, id):
"""DELETE handler body. Default applies _ownership_filter and deletes.
Override for soft-delete or cascade rules."""
obj = await self.queryset.aget(pk=id)
obj.deleted = True
await obj.asave()
return {'success': True, 'id': id, 'message': 'Soft-deleted'}
# โโ Listing mechanics (rarely overridden) โโโโโโโโโโโโโโโโโโโโโ
async def build_filters(self, request):
"""Apply filter_fields, search, queryset_filter to self.queryset."""
await super().build_filters(request)
def paginate(self, request):
"""Read ?page= and ?limit= from the querystring."""
super().paginate(request)
def ordenate(self, request):
"""Read ?order_by= and validate against order_fields."""
super().ordenate(request)
# โโ Cache pieces โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
async def save_cache(self, content):
"""Default: writes the response to Redis with TTL + namespace tracking."""
await super().save_cache(content)
async def invalidate_cache(self, namespaces):
"""Drop every key under the given namespaces. Default called by writes."""
await super().invalidate_cache(namespaces)
# โโ HTTP handlers (replace the entire flow) โโโโโโโโโโโโโโโโโโโ
async def get(self, request):
"""Full GET handler. self.id tells list vs detail.
Override only when you need to bypass the standard pipeline."""
return await super().get(request)
async def post(self, request):
"""Full POST handler."""
return await super().post(request)
async def patch(self, request):
"""Full PATCH handler."""
return await super().patch(request)
async def delete(self, request):
"""Full DELETE handler."""
return await super().delete(request)
# โโ Custom route handlers โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
@openapi(summary='Current user', response=UserOut)
async def me(self, request, match=None):
return {'id': self.user['id'], 'email': self.user['email']}
@openapi(summary='Login', request=UserCreate)
async def login(self, request, match=None, body=None):
# body is already validated against the request schema
...
async def promote(self, request, match=None, body=None):
user_id = match['user_id'] # named groups in the regex are passed in match
...Three layers of override: Hooks (pre_process, dehydrate, post_process) when you want to nudge. CRUD pieces (get_objs, update_obj, โฆ) when you want to keep dispatch but change one operation. HTTP handlers (get, post, โฆ) when you need full control. Always prefer the highest layer that gets the job done.
What you didn't have to write
Look at the resource above. Roughly 80 lines, fully commented. Everything else โ async dispatch, pagination, search parser, cache key building, namespace invalidation on writes, rate limit, scanner blocking, sanitized 500s, OpenAPI spec generation, Scalar UI โ is the library doing its job.
Querystrings cheat sheet
| Param | Effect |
|---|---|
?count=true | Return only {count: N} |
?search=value | OR-ICONTAINS across search_fields |
?field=value / ?field__gte=... | Filter on whitelisted fields |
?fields=a,b | Restrict returned columns (must be in list_fields) |
?filter=<json> | Boolean filter tree |
?segment_id=N | Apply a saved segment |
?page=N&limit=M&order_by=field | Pagination + order |
?normalize=true | Return list as {id: {...}} dict |
0-mcp by Stamatios Stamou Jr โ github.com/ssjunior/0-mcp