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:

What you lose without a model:

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

ParamEffect
?count=trueReturn only {count: N}
?search=valueOR-ICONTAINS across search_fields
?field=value / ?field__gte=...Filter on whitelisted fields
?fields=a,bRestrict returned columns (must be in list_fields)
?filter=<json>Boolean filter tree
?segment_id=NApply a saved segment
?page=N&limit=M&order_by=fieldPagination + order
?normalize=trueReturn list as {id: {...}} dict

0-mcp by Stamatios Stamou Jr โ€” github.com/ssjunior/0-mcp