# easyapi ## 🚀 0-mcp Already on Django? One class, two lines of code, and your models become typed tools LLM agents can call — over stdio for desktop assistants, over HTTP (JSON-RPC POST) for HTTP clients. The REST API ships at the same time, from the same source. Not on Django? Point `0-mcp init` at any MySQL or Postgres database and walk away with a complete Django + 0-mcp project ten seconds later. Either way, you end up with the same battle-tested stack: async by default, Redis-native, multi-tenant aware. **Zero MCP boilerplate. Zero schema drift. One source of truth.** **Already have a database?** Skip the model and resource boilerplate entirely: ``` pip install 'django-zeromcp[gen-mysql]' 0-mcp init --db mysql://user:pass@host/dbname --output ./myproject ``` In ~10 seconds, every table is a model, every model is an MCP tool, every tool has typed schemas. [Read the full walkthrough](init.html). ## Setup (once per app) ``` from zeromcp import BaseResource from myapp.models import Space ``` ## Your entire resource — 2 lines ``` class SpaceResource(BaseResource): model = Space ``` That's it. You now have a complete **MCP server**: - `list_space` — typed input, ranked search, paginated output - `get_space`, `create_space`, `update_space`, `delete_space` - Full JSON Schema, descriptions, output shapes — all derived from your model - `stdio` transport for Claude Desktop / Cursor - `HTTP` transport (JSON-RPC over POST) for web agents And — without writing a single extra line — the **same class** also serves a REST API: - `GET /spaces` — paginated list - `GET /spaces/{id}` — detail - `POST /spaces` — create - `PATCH /spaces/{id}` — update - `DELETE /spaces/{id}` — delete - `GET /docs` — interactive OpenAPI UI - `GET /openapi.json` — full spec No second codebase. No schema drift. **Your agent surface and your API are the same source of truth.** Async dispatch, session and API-key auth, per-IP rate limit, scanner blocking, sanitized 500s, HMAC-SHA256 anti-replay tokens, multi-tenant DB routing — **all on by default. For both MCP and REST.** You did not configure them. You did not even import them. Need filters, search, ordering, write whitelists, cache, ownership scoping, Pydantic validation? Add one attribute at a time: ``` class SpaceResource(BaseResource): model = Space list_fields = ['id', 'name', 'description', 'active'] filter_fields = ['active', 'name'] search_fields = ['name', 'description'] create_fields = ['name', 'description'] update_fields = ['name', 'description', 'active'] cache = True owner_field = 'owner_id' ``` ## What you didn't have to write - MCP tool registry with stdio + HTTP transports - JSON Schema generation from Django fields and Pydantic models - Tool descriptions, summaries and bounded output shapes - Async list view with pagination, ordering, search, field selection - Filter parser that accepts URL params **and** JSON expressions - Field-level whitelists — applied to both MCP tools and REST endpoints - Session and API key authentication backed by Redis - Per-IP rate limit + abuse blocking (auto-blocks scanners on first hit) - Per-resource Redis cache with namespace invalidation that does not blow away unrelated rows - Multi-tenant database routing - Sanitized 500s in production - OpenAPI 3.0.3 spec generation - Two-column Scalar API reference, ready to share None of it is wired by hand. None of it can drift. None of it is your problem anymore. ## Built for agents first Every Django app that exists today will need an agent surface tomorrow. Claude Desktop, Cursor, custom copilots, in-house automation — they all expect typed tools with JSON Schema, descriptions and bounded outputs. Writing that by hand, alongside the API you already have, means two codebases that drift apart. 0-mcp closes the gap. The MCP server reuses the same model fields, Pydantic schemas, field whitelists, auth, rate limit and ownership scoping the REST layer uses. Same protections. Same source of truth. Turn it on and your existing Django app becomes agent-ready. **Same auth. Same rate limit. Same ownership scoping.** No DSL to learn. No second codebase to keep in sync. ## Built for the boring 90% CRUD on a Django model. List with filters. Soft auth gates. Tenant DB routing. Most of what an agent needs — and most of what your API does — is this. Writing it from scratch, twice, is the slowest, dumbest part of the job. 0-mcp is the answer for that 90%. The remaining 10% — the actual product — gets all your attention. ## Production-ready out of the box ## Who is this for - Django teams **building agents or copilots** that need to read and act on app data - Products integrating with **Claude Desktop, Cursor, or any MCP client** - SaaS apps that want **API + MCP from one definition**, not two codebases - Multi-tenant apps that need agent access **scoped per tenant**, automatically - Teams that also want a clean REST API — that comes free - Solo founders shipping fast 🚀 The first MCP tool takes five minutes. The fiftieth takes five minutes too. ## Project - Author — Stamatios Stamou Jr - GitHub — https://github.com/ssjunior/0-mcp - License — MIT ## Introduction ### Why we built it 0-mcp started life as a thin REST framework for Django — born from the boredom of writing the same list view, the same auth check, the same cache wrapper on every project. Then agents arrived. Suddenly every team wanted to expose those same resources as MCP tools, and we found ourselves writing a *second* codebase to mirror the first. So we collapsed them. The MCP server became the headline. The REST API became the bonus. Same engine, sharper purpose. ## Phase 1 — The boring 90% of every Django API We've built the same Django REST API ten times. List view, detail, create, update, delete, auth, rate limit, cache, tenant switch. Hundreds of lines per resource, copy-pasted across projects, drifting in slightly different ways every time. The original goal was simple: collapse that pattern into one class. - A list view with pagination, filters, search, ordering — written once, copy-pasted forever - A detail view nobody remembers writing - Create, update, delete handlers with the same field-whitelist code in slightly different shapes - Auth checks duplicated at the top of every handler - A rate limit decorator that someone added in 2021 and never tested - A cache wrapper that invalidates wrong half the time - A multi-tenant switch that lives in middleware, in views, and in random helpers Every one of those is a few lines. Together they are hundreds of lines per resource. Across a SaaS with fifty resources, they are tens of thousands of lines that nobody owns and everybody is afraid to touch. ## Phase 2 — Agents changed the brief When Claude Desktop, Cursor and the MCP ecosystem started showing up in production, every team had the same realization at the same time: the agent surface is just another view of the resources the REST API already exposes. Different protocol, same auth, same rate limit, same field whitelists, same ownership scoping. **Writing it twice is insane.** We tried bolting an MCP layer on top of what we had. It worked. It also doubled the surface area: tool registrations had to mirror endpoint signatures, schemas drifted between OpenAPI and JSON Schema, auth wiring had to be re-implemented at the MCP boundary. The same problem we solved for REST was now back, in a new shape, on the agent side. ## Phase 3 — MCP became the headline So we promoted MCP from a side feature to the main product. The framework's job is now: turn a Django model into an MCP server, with a REST API falling out of the same definition. Same engine. Different center of gravity. New name to match — **0-mcp**, because that's what you write to get one running. ## We tried the alternatives - **FastMCP / MCP Python SDK** — great if you're starting from a blank file. Painful when you already have a Django app: you re-implement auth, rate limit, ownership and validation that already live in your codebase. - **DRF + custom MCP layer** — two codebases, two schemas, drift on day one. - **Django Ninja + custom MCP layer** — same problem, slightly less code. - **Hand-rolled MCP server** — what every team ends up with first. And what every team regrets. - **DRF for the API alone** — powerful but huge. New devs spend days learning serializers, viewsets, routers, permissions, throttles and authentication classes before writing their first line of product code. - **FastAPI** — beautiful, but it is not Django. You give up the ORM, the admin, the migrations, the auth — half your stack — to gain pretty docs. ## Goals we set - **MCP-first.** A Django model becomes an MCP server with no extra registration. Tools, schemas, transports — generated. - **One source of truth.** REST and MCP share the same fields, schemas, auth, rate limit and ownership rules. Drift is impossible because there is no second copy. - **Convention over configuration.** A class with attributes covers the common case. No metaclasses, no descriptors, no magic. - **Async-first.** Every handler is `async`. The async ORM is used end-to-end. No `sync_to_async` shims you have to remember. - **Redis-native.** Sessions, cache, rate limit and abuse blocking share one client. Connection pool tuning is not your problem. - **Security-by-default.** Token validation, IP spoof prevention, scanner blocking, 4xx-flood detection — all on. You opt out, not in. - **Pluggable, not magical.** Every step of dispatch is an overridable hook. When the convention doesn't fit, escape hatches are obvious. ## What we are not trying to be - A general-purpose MCP framework — we wrap **Django models specifically** - A drop-in DRF replacement for projects that already use it - A serverless-only library - Database-agnostic — we trust Django's ORM - Working without Redis. Sessions, cache, rate limit and abuse all need it 🎯 0-mcp is the framework we wished we had the day Claude Desktop shipped. It is not trying to be everything. It is trying to be the right thing for Django apps that need an agent surface — and ruthlessly good at it. ### vs alternatives The MCP and REST landscapes overlap awkwardly. Some libraries do one well, none do both. Here is where 0-mcp stands compared to the names you've already heard — and where each one wins. ## TL;DR ## vs FastMCP / MCP Python SDK FastMCP and the official MCP Python SDK are excellent if you're writing an MCP server from scratch, with hand-picked data sources. They give you transports, tool registration, lifecycle hooks. They give you nothing about your data. 0-mcp makes a different bet: **most teams adopting MCP already have a Django app.** The data the agent needs to read and write is already in your ORM. Auth already works. Rate limit already works. Multi-tenant routing already works. Wiring all that into a generic MCP server means rebuilding half your app. ⚖️ Pick FastMCP when your data lives anywhere — files, APIs, custom services. Pick 0-mcp when your data lives in **Django models** and you want every existing protection to apply automatically. **FastMCP is a toolkit. 0-mcp is an opinion: your Django models are your tools.** ## vs Django REST Framework DRF is the default REST library for Django. It is mature, flexible, and **has no MCP story**. To add agent support, you write a second layer alongside it: custom dispatch, schema duplication, separate auth wiring. **You'll maintain two codebases that mean the same thing.** 0-mcp ships REST and MCP from the same class. Same auth, same fields, same validation, same ownership scoping. ⚖️ Pick DRF when you need its ecosystem (browsable API, schema generators, third-party integrations) and don't need MCP. Pick 0-mcp when an agent surface is part of the product — now or soon. ## vs FastAPI FastAPI is fantastic — if you are starting from scratch. Type-driven, fast, beautiful docs. The unsolved problem: **it is not Django.** If you already have Django models, admin, migrations, ORM, signals, management commands and an auth system, leaving Django to gain Pydantic-driven docs is a steep trade. You'd be giving up half your stack. And then you'd still have to bolt MCP on top. 0-mcp gives you Pydantic schemas, OpenAPI 3.0.3 **and** an MCP server **without giving up anything else.** Your Django app stays Django. Your admin still works. Your migrations still work. You just write less plumbing. ## vs Django Ninja Django Ninja is closer in spirit — Django + Pydantic + OpenAPI. It is excellent for function-based endpoints when each one is bespoke. It does **not** get you MCP. Add a custom MCP layer on top and you're back to two codebases. 0-mcp is class-based and bundles **more infrastructure**: - MCP server with stdio + HTTP transports — schemas generated, no manual registration - Redis-backed cache with **namespace invalidation** that won't drop unrelated rows - Per-IP rate limit + abuse detection + scanner blocking - Multi-tenant database routing - Ownership scoping (`owner_field`) to kill IDOR bugs - Sanitized 500 responses in production - SecurityMiddleware that auto-blocks scanners If your project needs any of those — and most production SaaS does, especially once agents enter the picture — you write less code starting with 0-mcp. ## vs hand-written class-based views + hand-rolled MCP This is the most honest comparison. Every Django dev has done the API side: start with a CBV, add pagination, filters, auth, rate limit, cache, copy-paste into the next resource. **Three months later you have 250 lines × 12 files**, none of which are the same anymore. Now do it again, in parallel, for the MCP server. Tool registrations. JSON Schemas. Transport plumbing. Auth glue. Drift between the two surfaces from week one. 0-mcp is what that code wants to grow up to be — both surfaces, one definition. ## When to pick what - **0-mcp** — Django app, agent surface needed (or coming soon), CRUD-shaped resources - **FastMCP / MCP SDK** — non-Django data sources, custom MCP servers - **DRF** — Django app, no agent plans, you need the DRF ecosystem - **FastAPI** — greenfield project, no Django commitment, no MCP plans - **Django Ninja** — Django app, no agent plans, you want minimal API code ## When 0-mcp is the wrong choice No library is for everyone. Don't pick 0-mcp if: - You have no Redis available — sessions, cache, rate limit and abuse all rely on it. - You need very custom auth (OAuth2 server, complex permission matrices) — DRF or a custom stack will fit better. - Your endpoints are mostly RPC, not CRUD — and you don't want them as MCP tools either. - You don't have Django models — 0-mcp wraps your ORM, not arbitrary code. - You want a huge plugin ecosystem — 0-mcp is small on purpose. 🚀 The fastest way to know is to install it. The first resource takes five minutes. If it doesn't fit your project, you'll know in twenty. ## Get started — already on Django ### Installation This page is for projects that already have Django wired up. Coming from a different stack? Jump to [MCP from your DB ⚡](init.html) — `0-mcp init` reads your existing MySQL or Postgres schema and generates the whole Django + 0-mcp project for you, no Django knowledge required. ## Requirements - Python 3.10 or newer - Django 5.2 or newer (`CompositePrimaryKey` is required by the generator) - Redis 6.2 or newer (the framework uses `GETEX` for sliding session TTLs — earlier versions return `unknown command`) - Optional: pydantic 2+ for typed schemas ## Install ``` pip install django-zeromcp ``` MCP server, REST API and OpenAPI come included — the base package wires all three. Optional extras: - `pip install 'django-zeromcp[schemas]'` — Pydantic 2+ for typed input/output schemas. Without it the framework infers schemas straight from your Django models. - `pip install 'django-zeromcp[gen-mysql]'` — `0-mcp init` for MySQL/MariaDB (introspects an existing database and generates a working project). See [MCP from your DB ⚡](init.html). - `pip install 'django-zeromcp[gen-postgres]'` — same, for Postgres. ## Settings `zeromcp` ships as a plain Python package — **do not add it to `INSTALLED_APPS`**. It registers itself through the middlewares and the `get_routes` URL helper. Add the middlewares to your Django settings: ``` MIDDLEWARE = [ # ... 'zeromcp.SecurityMiddleware', 'zeromcp.AuthMiddleware', 'zeromcp.ExceptionMiddleware', ] ``` Configure environment variables (Redis must be running locally — start it with `redis-server` or `docker run -p 6379:6379 redis:7`): ``` REDIS_SERVER=localhost REDIS_DB=0 REDIS_PREFIX=myapp # optional, isolates Redis keys ``` Configure 0-mcp-owned settings inside the project's `MCP` bag in `settings.py` — anywhere at module level alongside the other Django settings (DRF/Celery-style namespace): ``` MCP = { 'COOKIE_ID': 'sessionid', # cookie name your app uses for session id 'ALLOWED_ORIGINS': ['localhost', 'example.com'], # referer-based origin allow-list (add 'localhost' for dev) 'TRUSTED_PROXIES': ['10.0.0.0/8'], # CIDRs allowed to set X-Real-IP 'ENFORCE_TOKEN': False, # require X-Token anti-replay header # 'RATE_LIMITS': {...}, # see Rate limiting page # 'CACHE_TTL': 120, # default 120s; per-resource cache_ttl wins # 'CACHE_TTL_ENABLE': True, # global kill switch for cache=True } ``` ## Multi-tenant DB router If you want per-tenant databases: ``` DATABASE_ROUTERS = ['zeromcp.DBRouter'] ``` ### 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 ⚡](init.html) 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`** ```json { "jsonrpc": "2.0", "id": 1, "method": "tools/call", "params": { "name": "list_space", "arguments": {} } } ``` **🌐 REST** ``` curl http://localhost:8000/spaces ``` ### Create **🤖 MCP — `tools/call`** ```json { "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`** ```json { "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`** ```json { "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`** ```json { "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`** ```json { "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 `request` schema 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` (and `get_obj` / `get_objs` / `create_obj` / `update_obj` / `delete_obj`) implementations — they crash because they assume `self.model` exists. **Override any of them and they work fine without a model.** - Auto-fill (`created_by`, `owner_id`) — this only happens inside the default `create_obj` - Cache namespaces (you can still cache — set `cache: True` on the route — keys just won't be model-namespaced) - Field whitelists from the model (`fields`, `all_fields`, `m2m_fields` are 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 ### Your first resource A guided tour of the attributes you will set on a real resource. ## Anatomy ``` class UserResource(BaseResource): model = User # required for CRUD authenticated = True # default — require session/api-key # field whitelists list_fields = ['id', 'email', 'name'] # what GET /users returns edit_fields = ['id', 'email', 'name'] # what GET /users/{id} returns create_fields = ['email', 'password'] # what POST /users accepts update_fields = ['name', 'email'] # what PATCH /users/{id} accepts # query helpers filter_fields = ['active', 'role'] # whitelist for ?active=... search_fields = ['email', 'name'] # ?search=foo runs ICONTAINS on these order_fields = ['id', 'email'] # ?order_by=email or -email # relations list_related_fields = {'account': ['id', 'name']} edit_related_fields = {'account': ['id', 'name', 'plan__name']} # ownership owner_field = 'owner_id' # GET/LIST/PATCH/DELETE only on rows owned by user # cache cache = True cache_ttl = 60 # seconds ``` ## Field whitelists are not optional `create_fields` and `update_fields` are required for write methods. A POST or PATCH that touches any field outside the whitelist is rejected with 403. This is the simplest correct default for a public API. ⚠️ `list_fields` defaults to all fields when not set. `edit_fields` defaults to all model columns. Always set them explicitly when the model has sensitive columns (passwords, tokens, internal flags). ## Authentication By default `authenticated = True`. Requests must carry a valid session cookie, `X-Api-Key` header, or `Authorization: Bearer` (when `BEARER_RESOLVER` is configured). Public endpoints set `authenticated = False`. ## Custom routes Add methods that live alongside CRUD: ``` class UserResource(BaseResource): model = User routes = [ {'path': r'/me$', 'func': 'me', 'allowed_methods': ['get']}, ] async def me(self, request, match=None): return {'id': self.user['id'], 'email': self.user['email']} ``` This adds `GET /users/me` without a row id. ## Hooks Override any of these to adapt without subclassing dispatch: - `pre_process(request)` — runs before body parsing - `before_cache(request)` — runs before cache lookup - `hydrate(body)` — mutate the parsed body before validation - `dehydrate(row)` — mutate each row before response - `alter_list(results)` / `alter_detail(result)` — final shaping - `post_process(response)` — last chance before serialization ## Get started — coming from elsewhere ### MCP from your DB ⚡ Point `0-mcp init` at any MySQL or Postgres database and walk away with a complete Django + 0-mcp project: REST API, OpenAPI docs, MCP server, all wired up, all running locally in minutes. No model files to write, no resource boilerplate, no manual schema mapping. ## The promise You already have a database. It has tables, foreign keys, constraints, indexes — *real* business schema, painstakingly built. Every traditional way to expose it to an LLM agent looks the same: write a model file (manually copy each column type), write a resource (manually choose what to expose), write a tool definition (manually map parameters), write the auth wiring, write the deployment glue. **That's days of work for code an LLM should be able to consume in an hour.** `0-mcp init` collapses that to one command: ``` pip install 'django-zeromcp[gen-mysql]' # or [gen-postgres] 0-mcp init ``` Run with no arguments and the CLI walks you through it interactively: ``` 0-mcp — interactive mode (Ctrl+C to abort) Database engine (mysql/postgres) [mysql]: Host [127.0.0.1]: db.internal Port [3306]: Database name: billing User: app Password: ******** Output directory [./billing]: Let Django manage the schema (makemigrations + fake-initial on first run)? [y/N]: Generate writable resources (POST/PATCH/DELETE)? [y/N]: ⚠ read-only mode blocks every non-GET request, including the MCP JSON-RPC endpoint (POST /mcp). Agents will get 405 on every tools/call. Use --writable (or rerun and answer "y" here) if you need a working MCP server. introspected 80 tables, exposed 69, generated 99 files in billing ↳ 11 internal Django tables hidden (django_*, auth_*) — not an error. next steps: cd ./billing ./run.sh # or: python manage.py runserver # REST — list rows from `client`: curl -s http://localhost:8000/client | jq # MCP — call the matching tool: curl -s -X POST http://localhost:8000/mcp \ -H 'Content-Type: application/json' \ -d '{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"list_client","arguments":{}}}' | jq no X-Api-Key needed — DEFAULT_AUTHENTICATED=False on the demo project. ⚠ this project was generated read-only — MCP `tools/call` requests will return 405 because the endpoint is POST. Re-run with `--writable` (or drop `MCP['READ_ONLY']` in settings.py) to expose a working MCP server. see ./billing/README.md for full docs. ✨ done in 9.4s. ``` In ~10 seconds you get a Django project that: - Maps every table to a Django model. - Generates one `BaseResource` per table — REST + MCP tools out of the box. - Groups tables by name prefix into `modules//` so `contract`, `contract_item`, `contract_history` land in the same folder. - Auto-flags sensitive columns (`password`, `token`, `secret`, `api_key`, `otp`, …) into `sensitive_fields` — values are masked with `'*********'` on every response. - Hides Django system tables (`django_*`, `auth_*`) by default. - Wires `INSTALLED_APPS`, `apps.py`, `endpoints.py`, `urls.py`, `settings.py`, `manage.py`, `asgi.py` and a starter `.env` populated with the credentials you typed. - Drops a `run.sh` that handles the rest. ## First run Inside the generated project: ``` cd ./billing ./run.sh ``` 📦 `run.sh` requires [`uv`](https://github.com/astral-sh/uv) on `PATH`. Install once with `curl -LsSf https://astral.sh/uv/install.sh | sh` (or `brew install uv`). You also need a Redis server **6.2 or newer** reachable at the host/port in `.env` — the framework uses `GETEX` for sliding session TTLs. The script: 1. Creates a uv virtual environment if one doesn't exist. 2. Installs `requirements.txt`. 3. Runs `makemigrations` + `migrate --fake-initial` (only when you opted into Django-managed mode). 4. Starts `python manage.py runserver`. Open `http://localhost:8000/`. Done. ## What you get at `http://localhost:8000` - `/mcp/tools` — tool registry the agent will read, browseable. - `/mcp/tools.json` — same registry as JSON. - `/docs` — interactive REST docs (Scalar UI). - `/openapi.json` — OpenAPI 3.0.3 spec, ready for codegen. - `/mcp` — JSON-RPC 2.0 endpoint (POST). Agents call tools through here. Both surfaces share the *same* resource definitions, so what an agent sees and what a developer sees stay in sync — forever. ## Project layout ``` . ├── .env # populated with the credentials you typed ├── .env.example # placeholders, safe to commit ├── .gitignore # .env already listed ├── README.md ├── run.sh ├── manage.py / asgi.py ├── requirements.txt ├── settings/ │ ├── env.py # auto-loads .env on import │ └── settings.py # MCP dict + INSTALLED_APPS + DATABASES ├── router/ │ ├── endpoints.py # `{ClassName}Resource → URL pattern` map │ └── urls.py └── modules/ └── / ├── apps.py # AppConfig (label = prefix) ├── models.py # one Django model per table └── resources.py # one BaseResource per model ``` Tables sharing a name prefix land in the same module. `contract`, `contract_item`, `contract_history` all live in `modules/contract/`. Generated resources are intentionally minimal: ``` from zeromcp import BaseResource from .models import User class UserResource(BaseResource): model = User sensitive_fields = ['password', 'api_key'] # masked on every response ``` ## The two flags that matter ### `--writable` Default is **read-only**. The generated `settings.py` carries `MCP = {'READ_ONLY': True, ...}` — a single global gate that rejects every non-`GET` request with `405` across all resources (REST and MCP alike). An agent pointed at a fresh project can never mutate the database by accident. Pass `--writable` (or answer "y" to the interactive prompt) to drop the gate and enable POST/PATCH/DELETE. ⚠ The MCP protocol is JSON-RPC over **`POST /mcp`**, so a read-only project's MCP server returns `405` on every `tools/call`. The REST `GET` endpoints still serve agents that only read, but if you need a working MCP surface, generate the project with `--writable` (or remove `READ_ONLY` from `settings.py` later). ### `--django-managed` Default keeps the database **external**: every model has `Meta.managed = False`, Django reads but never issues DDL, your existing migration tool stays in charge. Pass `--django-managed` (or answer "y" to the prompt) to hand the schema to Django: - `Meta.managed = False` is dropped — Django owns the tables. - `run.sh` runs `makemigrations` + `migrate --fake-initial` on first boot, recording the existing schema as already migrated. - From then on, evolve the project the standard Django way: edit a model, `makemigrations`, `migrate`. ## Filtering the table set By default every business table is exposed and Django system tables are hidden. Use globs to shape the set: ``` # Only the billing-core tables 0-mcp init --db mysql://... -o ./billing --include 'client*,invoice*,contract*' # Hide audit / queue / temporary tables 0-mcp init --db mysql://... -o ./billing --exclude 'audit_*,tmp_*,*_queue' ``` ## Auto-applied opinions The generator emits the **minimum viable scaffolding** — every default it picks should be one you'd never have to undo. Concretely: - Foreign keys resolve across modules with `'app.Model'` references — Django's check framework passes on first run. - Field-name `_id` suffix stripped where Django expects it (`db_id` column → `db = ForeignKey(...)` field with `db_column='db_id'`). - Python keyword collisions (`class`, `type`, `def`, …) get a `field_` prefix; the SQL column stays exact via `db_column`. - MySQL `tinyint(1)` becomes `BooleanField`; Postgres ENUM types become `CharField(choices=...)`. - Composite primary keys emit `models.CompositePrimaryKey` (Django 5.2+ — projects pin `Django>=5.2`). - `MasterAccountaccess`, `InvoiceInvoicehistory`, `ContractContractdiscount` get cleaned up to `MasterAccountAccess`, `InvoiceHistory`, `ContractDiscount` — no Django app-prefix duplication in class names. - `sensitive_fields` filled in for columns matching `password`, `token`, `secret`, `api_key`, `otp`, `pwd`, `passwd`, `_token`, `_secret`, `_api_key`, `_apikey`, `_password`. Values come back as `'*********'` from REST and MCP — never the actual content. - `expose: false` for tables matching `django_*` and `auth_*` patterns — system tables don't show up in MCP/REST until you ask for them. - `.env` is auto-loaded by `settings/env.py` so projects run under fish/csh without a separate `source .env` step. ### Left for you (every guess here would be wrong sooner or later) - `filter_fields`, `search_fields`, `order_fields` — these depend on what your agent actually needs, not on what your DB has indexed. - `mcp_fk_expand` — only you know which FKs are small enough to expand inline (lookup tables) versus large enough to follow by reference. - Custom routes (`routes = [...]`) — anything beyond CRUD is a domain decision. - `cache_ttl`, `mcp_list_omit_null`, `mcp_edit_omit_null` — performance/serialisation tuning the agent would have to undo if we guessed wrong. ## The two-phase flow `0-mcp init` is a shortcut for three steps: ``` # 1. Read schema → JSON (Django-agnostic, diffable) 0-mcp introspect --db -o introspection.json # 2. Build a starter config you can edit 0-mcp config introspection.json -o config.yaml # 3. Render config + templates → project tree 0-mcp generate config.yaml -o ./myproject ``` The middle step is where you customise. `config.yaml` is intentionally small and human-friendly: ``` project: name: billing backend: mysql database: billing tables: client: expose: true db_table: client sensitive: [internal_notes] # masked on REST and MCP contract: expose: true db_table: contract audit_log: expose: false # opt out of MCP/REST entirely django_migrations: expose: false ``` Everything else (column types, FKs, choices) is recomputed from `introspection.json` at generate time — the config carries opinions, not state. ## Re-running the generator Schema drifted? Add a column, drop a table, add a constraint? Re-run: ``` 0-mcp introspect --db -o introspection.json 0-mcp generate config.yaml -o ./myproject ``` Existing files are overwritten. **Don't hand-edit the generated files** — keep your customisations in resource subclasses outside `modules//`, or extend the framework via hooks. Treat the generated tree as build output. ## Production checklist Generated projects are demo-friendly so the first `./run.sh` boots without ceremony. Before exposing the project to a network you don't fully control: 1. `.env` — flip `DEBUG=false`, set `ALLOWED_HOSTS=app.example.com,…`, rotate `DJANGO_SECRET_KEY`. 2. `router/urls.py` — replace the demo block with `urlpatterns = get_routes(endpoints, mcp=True, docs_public=False)` to gate `/mcp`, `/docs` and `/openapi.json` behind the same auth as the REST surface (X-Api-Key by default, Bearer if `BEARER_RESOLVER` is configured). 3. `settings/settings.py` — flip `MCP['DEFAULT_AUTHENTICATED'] = True` so every `BaseResource` starts requiring auth. 4. `TENANT_USER_API_MODEL` — point at the model that stores your API keys (column: `api_key`). Without it, MCP/REST traffic has no way to authenticate. ## When to use this ### You should run `0-mcp init` when - You have a real database with tables, FKs, indexes — and you want LLM agents to talk to it. - You're adding MCP support to a stack that doesn't have one and don't want to write 50 model files. - You're prototyping an integration. Generate, demo, iterate. - You need a quick REST + OpenAPI baseline alongside the MCP layer. ### You should not run it when - Your MySQL is **5.x** — Django 5.2 (the version generated projects pin) requires MySQL 8.0.11+. Upgrade the database first. - You want every model + resource hand-tuned. The generator emits the minimum precisely so this case is easy: subclass and extend. ## Why we built this Standing up an MCP server for an existing database is the kind of work that should be measured in coffee breaks, not sprints. Every column type your DB already declared. Every relationship the FK constraints already encode. Every label the `verbose_name` already names. None of it should need a second human pass to expose to an agent. `0-mcp init` is the inevitable shortcut. ## See also - [Quickstart](quickstart.html) — write a resource by hand, two minutes flat. - [BaseResource attributes](ref-attributes.html) — every knob the framework exposes. - [Scripts and ad-hoc ORM access](scripts.html) — `from zeromcp import orm` for cron jobs and notebooks. - [MCP server](mcp.html) — what the MCP runtime does and how tool calls dispatch. ## MCP server ### Overview Every resource you ship as a REST endpoint also becomes a typed tool that LLM agents can call — no second codebase, no schema drift, no auth duplication. Your `summary`, `description`, Pydantic schemas and field whitelists power both surfaces from the same class. **Tool calls run through the same `BaseResource.dispatch` as REST.** Auth, rate limit, security middleware, hooks — all of it, automatically. ## Why an MCP server alongside the API Two years ago "expose your API" meant a REST docs page and an SDK. Today it also means agents — Claude Desktop, Cursor, custom LangChain stacks, internal copilots — that read tool definitions and call them by themselves. **Every API will have an agent surface.** The question is whether you write it from scratch or get it for free. 0-mcp gets it for free. We already generate OpenAPI 3.0.3 from your resources. We already have Pydantic schemas, field whitelists, auth, rate limiting and security middleware. The MCP layer reuses every bit of that — **no parallel handlers, no duplicated validation, no separate rate limits to forget.** ## Install ``` # MCP server is bundled in the base install — no extras needed. ``` Brings `jsonschema` for fast-fail input validation. That's it. ## Two ways to enable ### One liner — `mcp=True` ``` from zeromcp import get_routes endpoints = {'spaces(.*)$': SpaceResource, 'users(.*)$': UserResource} urlpatterns = get_routes(endpoints, mcp=True) ``` Adds `POST /mcp` automatically. Inherits the default `MCPResource` — authenticated, no caching, full registry exposed. ### Subclass — full control ``` from zeromcp import MCPResource class MyMCP(MCPResource): endpoints = my_endpoints summary = 'agent-tools' cache = True cache_ttl = 60 # cache tools/list responses async def post_process(self, response): await audit_log(self.user, self.body, response) return response urlpatterns = [path('mcp/', MyMCP.as_view())] ``` `MCPResource` is **just another `BaseResource`**. Override `pre_process`, `post_process`, `dispatch`, set `cache`, `cache_ttl`, `authenticated` — every 0-mcp attribute and hook applies. ## Philosophy — same dispatch, different transport MCP is not a parallel codebase. It is another wire format that calls the **same** dispatch: ``` HTTP request → dispatch → response JSON-RPC msg → dispatch → response ``` Adding a field to your Pydantic schema updates REST and MCP. Adding a `dehydrate` hook updates both. Adding a `post_process` hook updates both. **Zero drift forever.** ## What's next - [Tool generation](mcp-tools.html) — naming, schemas, custom routes, registry - [Runtime behaviour](mcp-runtime.html) — permissions, rate limit, validation, output safety - [Transports](mcp-transports.html) — HTTP, stdio, authentication 🤖 Every API you ship is now also an agent API. No new code, no new auth, no new docs to maintain. Your Pydantic schema, your `description`, your rate limit, your `dehydrate` hook — they all carry over. **The agent and the human see the same source of truth.** ### Tool generation How 0-mcp turns your existing resources into agent-callable tools — names, schemas, custom routes, registry inspection. ## CRUD verb mapping For each resource, 0-mcp emits agent-callable tools that mirror the REST verbs. Tool names are verb-first, lowercase: Tool name = `_` — `summary` is slugified (whitespace and punctuation collapsed to `_`, lowercased). When `summary` is unset, falls back to a snake_case version of the class name (`SpaceResource` → `space`). ## What drives the tool ``` class SpaceResource(BaseResource): summary = 'space' # → tool name suffix description = 'Workspaces.' # → tool description create_schema = SpaceCreate # → MCP inputSchema (POST) update_schema = SpaceUpdate # → MCP inputSchema (PATCH) list_schema = SpaceOut # → MCP outputSchema filter_fields = ['active'] # → list_space input properties search_fields = ['name'] # → list_space.search order_fields = ['created_at'] # → list_space.order_by ``` No second declaration. **Every property the REST API uses, MCP uses too.** ## inputSchema How 0-mcp builds it, in priority order: 1. **Pydantic schema present** — `model.model_json_schema()` is emitted as JSON Schema directly. 2. **No Pydantic schema** — Django field introspection: emits an object schema with the columns from `create_fields` / `update_fields` / `list_fields`, types mapped from Django field types (`IntegerField` → integer, `EmailField` → string with format=email, `JSONField` → object, etc.). 3. **Resource without `model` and no schema** — open object (`{type: 'object', additionalProperties: true}`). Dispatch handles it. ### Example — generated from Django introspection ``` { "name": "create_space", "description": "Workspaces.", "inputSchema": { "type": "object", "properties": { "name": {"type": "string", "maxLength": 100}, "description": {"type": "string"} }, "required": ["name"] } } ``` ### Example — generated from a Pydantic schema ``` class SpaceCreate(BaseModel): name: str = Field(min_length=1, max_length=100) description: str = '' class SpaceResource(BaseResource): create_schema = SpaceCreate ``` Becomes: ``` { "name": "create_space", "inputSchema": { "type": "object", "properties": { "name": {"type": "string", "minLength": 1, "maxLength": 100}, "description": {"type": "string", "default": ""} }, "required": ["name"] } } ``` ## outputSchema Driven by `list_schema` when set. For list tools, wrapped in `{meta, objects}`. For detail/create/update, the schema is used directly. When neither `list_schema` nor `edit_fields` is set, falls back to model introspection. ## Custom routes Custom routes decorated with `@openapi(...)` are exposed automatically: ``` from zeromcp import openapi from pydantic import BaseModel class SearchInput(BaseModel): query: str limit: int = 10 class SearchOutput(BaseModel): results: list[dict] class UserResource(BaseResource): summary = 'user' routes = [{'path': r'/search$', 'func': 'search', 'allowed_methods': ['post']}] @openapi( summary='Semantic user search', description='Hybrid keyword + vector search. Returns ranked results.', request=SearchInput, response=SearchOutput, ) async def search(self, request, match=None, body=None): ... ``` → Tool `search_user` with description, input and output schemas straight from the decorator. ## Registry inspection — built-in routes When MCP is enabled (`get_routes(endpoints, mcp=True)`), two inspection routes are registered alongside the JSON-RPC endpoint: Both honour the same auth as `/docs`: session cookie, `X-Api-Key`, or `Authorization: Bearer` when configured. `REQUIRE_VALID_BEARER` strict mode applies here too. Pass `docs_public=True` to `get_routes` for anonymous access. ### Programmatic access ``` from zeromcp.mcp import list_tools_public for tool in list_tools_public(endpoints): print(tool['name'], '—', tool['description']) ``` Returns the same definitions the agent receives via `tools/list`, minus the internal metadata that drives dispatch. When you need the internal metadata (writing tests, custom dispatchers): ``` from zeromcp.mcp import list_tools tools = list_tools(endpoints) # [{'name': 'list_space', 'inputSchema': ..., 'mcp_internal': {...}}, ...] ``` 🪞 The registry is **derived, not maintained.** Add a field to a Pydantic schema, the tool's inputSchema updates. Add a custom route with `@openapi`, a new tool appears. Nothing to register, nothing to wire up. ### Runtime behaviour What happens when an agent calls a tool — permissions, validation, rate limiting, error mapping, output safety. Everything is the same `dispatch` REST uses, with a thin MCP layer on top. ## How a tool call runs When the agent calls `create_space({"name": "Demo"})`: 1. MCP layer fast-validates the input against the JSON Schema (Pydantic-derived or Django-introspected). 2. Bridge builds a synthetic Django `HttpRequest`: `POST /spaces`, `Content-Type: application/json`, body = the args, auth headers from context. 3. Wraps the view with the project's configured `settings.MIDDLEWARE` (async-capable only), so `SecurityMiddleware`, `AuthMiddleware`, `ExceptionMiddleware` and any custom async middleware run exactly as on a REST hit. The wrapped chain is cached per resource class. ⚠ **Sync-only middleware is skipped.** The bridge applies only middleware with `async_capable = True`. If your project has sync-only middleware critical to security or business logic, REST and MCP will diverge for that middleware. Either mark it async-capable, or accept the divergence and validate the equivalent invariant inside `dispatch` (e.g. via `pre_process`). Calls `SpaceResource.as_view()(request)` through that chain — **the same code path REST uses.** Inside dispatch: rate limit, security check, authentication, tenant switch, Pydantic validation (authoritative), `pre_process`, `create_obj` (auto-fill, m2m, custom_*, integrity), `dehydrate`, `post_process`, cache invalidation. Returned `JsonResponse` is parsed; the MCP layer truncates list lengths and long strings, wraps in the MCP envelope, returns to the agent. **There is no parallel handler.** A `dehydrate` you wrote for REST applies. A new field on the Pydantic schema applies. A new hook in `pre_process` applies. **Zero drift forever.** ## Permissions — same as REST **MCP follows the same rules as the API.** No second permission system. What the user can do via REST, the agent can do via MCP — gated by the same `authenticated`, `allowed_methods`, `owner_field`, security middleware and Pydantic validation. Per-resource controls: ### Example — read-only resource ``` class ReportResource(BaseResource): model = Report summary = 'report' mcp_expose = ['list', 'get'] # agents see reports but cannot mutate ``` ### Example — REST-only resource ``` class WebhookResource(BaseResource): model = WebhookEvent mcp_expose = False # webhooks are not agent tools ``` ⚠ `MCP['READ_ONLY'] = True` (the default for projects generated by `0-mcp init`) is a different switch — it gates **every** non-`GET` request at dispatch time, including the MCP JSON-RPC endpoint itself (`POST /mcp`). Agents get `405` on every `tools/call`, regardless of `mcp_expose`. Drop the key (or run init with `--writable`) before pointing an agent at the server. ## Rate limiting MCP calls flow through the same `dispatch` as REST, so they hit the same rate-limit buckets. There is no separate MCP-only bucket today. If you need stricter limits for agent traffic, use `mcp_expose` to narrow which verbs the agent can call (e.g. `['list', 'get']` for read-only resources). ## Validation — two layers, one source ### Layer 1 — MCP fast-fail The MCP layer validates the args against the tool's `inputSchema` using `jsonschema`. Bad input returns a `VALIDATION_ERROR` envelope **immediately** — no rate limit consumed, no DB hit, no tenant switch, no auth round-trip: ``` { "tool": "create_space", "code": "VALIDATION_ERROR", "message": "'name' is a required property", "path": ["name"] } ``` ### Layer 2 — dispatch (authoritative) Inside `_parse_body`, Pydantic re-validates (when a schema is set). Without Pydantic, the field whitelists kick in. **Dispatch is always the source of truth** — the MCP layer is a courtesy. ### Why two layers? The two layers use the same source: the Pydantic schema or the Django model + whitelist. **No drift possible.** The MCP layer exists for UX (agent gets the error in <1ms instead of going through the entire pipeline). ## Output safety LLMs choke on big payloads and stack traces. The bridge applies sane defaults to the response **before** wrapping it in the MCP envelope: These caps live in the MCP layer only — REST clients still get the full response. ## Error mapping `HTTPException` and other failures from dispatch are mapped to MCP-friendly error codes: Returned in the MCP `isError: true` envelope: ``` { "tool": "delete_space", "code": "FORBIDDEN", "message": "Only owners can delete this row" } ``` ## Customizing behaviour `MCPResource` is just a `BaseResource`. Override hooks for custom behaviour: ``` class MyMCP(MCPResource): endpoints = my_endpoints async def pre_process(self, request): # add request-id, log start, etc. ... async def post_process(self, response): # audit every tool call await audit_log(self.user, self.body, response) return response ``` All `BaseResource` knobs work — `cache`, `cache_ttl`, `authenticated`, `before_cache`, the lot. ### Transports Two ways for agents to talk to your MCP server. Both share the same `handle_rpc` dispatch underneath — only the wire is different. ## HTTP — for production and browser-launched agents ### Setup ``` # urls.py — the one-liner urlpatterns = get_routes(endpoints, mcp=True) # → POST /mcp ``` Or with custom behaviour: ``` from zeromcp import MCPResource class MyMCP(MCPResource): endpoints = my_endpoints summary = 'agent-tools' urlpatterns = [path('mcp/', MyMCP.as_view())] ``` ### Wire format Single endpoint, JSON-RPC 2.0 over HTTP POST. Each request body is one message: ``` POST /mcp HTTP/1.1 Content-Type: application/json {"jsonrpc": "2.0", "id": 1, "method": "tools/list"} ``` Response: ``` HTTP/1.1 200 OK Content-Type: application/json {"jsonrpc": "2.0", "id": 1, "result": {"tools": [...]}} ``` ### Use it for - Production deployments (same ASGI process as your API) - Browser-launched agents (cookie session works automatically) - HTTP-based copilots and integrations - Anything that already speaks HTTP ## stdio — for desktop agents ### Setup ``` MCP_API_KEY="" python manage.py mcp_serve myapp.urls.endpoints ``` The first argument is the dotted path to your endpoints registry. The command reads newline-delimited JSON-RPC from stdin, dispatches via `handle_rpc`, writes responses to stdout. ### Use it for - Claude Desktop (`mcpServers` config in `claude_desktop_config.json`) - Cursor (custom MCP servers) - Any agent that launches a subprocess and pipes JSON-RPC ### Example agent config ``` { "mcpServers": { "myapp": { "command": "python", "args": ["manage.py", "mcp_serve", "myapp.urls.endpoints"], "cwd": "/path/to/your/django/project", "env": { "MCP_API_KEY": "your-token-here", "DJANGO_SETTINGS_MODULE": "myapp.settings" } } } } ``` ## Authentication Both transports use 0-mcp's existing auth — no new code path. The MCP bridge attaches credentials to a synthetic `HttpRequest` that goes through the same `_authenticate` you use for REST. ### API key The agent attaches a `X-Api-Key` header (HTTP) or sets `MCP_API_KEY` (stdio). 0-mcp's `_authenticate` resolves the key and switches to the matching tenant DB. How keys are issued is up to your project — 0-mcp does not enforce a particular format. See [Authentication](authentication.html) for the resolution flow 0-mcp uses, and [Sessions](sessions.html) for issuing keys. ### Session cookie HTTP transport accepts the session cookie like any other request. Useful when the agent runs inside a web app that already has a session. ### Anonymous access Set `authenticated = False` on your `MCPResource` subclass for unauthenticated tool calls. Use carefully — the agent will run with no user, so anything that depends on `self.user` will need explicit handling. ## Comparison ## Direct dispatch — no HTTP loopback Even the HTTP transport does not make a real network call to itself when running a tool. The bridge builds a synthetic `HttpRequest`, wraps `view_cls.as_view()` with the project's `settings.MIDDLEWARE` (async-capable only), and calls it directly: - Zero round-trip latency - No port juggling - `SecurityMiddleware`, `AuthMiddleware`, `ExceptionMiddleware` and any custom async middleware run exactly as on a REST hit — agents go through the same gate - Sync-only middleware is skipped — mark it async-capable or enforce the equivalent invariant inside `dispatch` 🔌 HTTP and stdio share the same `handle_rpc` and `bridge.call_tool`. Adding a third transport is mostly an IO-loop change — the protocol layer stays untouched. ## Core ### BaseResource The class every resource inherits from. It is a Django `View` subclass with async dispatch, attribute-driven configuration and a long list of overridable hooks. ## Mental model A resource is a description of how a model maps to HTTP. You set attributes (what fields are exposed, what is filterable, who owns rows) and BaseResource turns that into endpoints. `model` is **not mandatory**. For RPC-style endpoints (webhooks, OAuth callbacks, search, integrations) skip `model` and define `routes` with your own handlers. Those endpoints still benefit from auth, rate limit, security middleware, OpenAPI generation — they just don't get the default CRUD handlers. ## Lifecycle Each request runs through `dispatch`. The flow is: - Resolve client IP (honors `TRUSTED_PROXIES`) - Enforce rate limit and abuse blocking - Authenticate (API key, then session cookie) - Optionally validate `X-Token` if `ENFORCE_TOKEN` - Optionally enforce `ALLOWED_ORIGINS` (Origin with Referer fallback) - Switch tenant DB if the session has an account - Resolve method (custom route via `routes` or default GET/POST/PATCH/DELETE) - Try cache (when `cache = True` and method is GET) - Run `pre_process`, parse body, validate against schema if any - For list GET: build filters, paginate, order - Run the handler - Serialize response (apply `list_schema`, run `dehydrate`, JSON-encode) ## Per-instance state Class attributes that default to `None` (lists, dicts) are normalized to per-instance objects in `__init__`. You can safely mutate `self.list_fields`, `self.queryset_filter`, etc., without leaking state across requests. ## Overriding handlers Replace any of `get`, `post`, `patch`, `delete` for full control, or override the inner pieces (`get_objs`, `get_obj`, `create_obj`, `update_obj`, `delete_obj`) to keep the surrounding plumbing. ``` class ReportResource(BaseResource): model = Report async def get_objs(self, request): # custom list logic, still benefits from pagination + filters return await super().get_objs(request) ``` ## Throwing controlled errors Raise `HTTPException(status, detail)` from anywhere. The `ExceptionMiddleware` turns it into a JSON error response. ``` from zeromcp.exception import HTTPException if not user.is_admin: raise HTTPException(403, 'Admins only') ``` ### Dispatch flow Every request goes through the same pipeline. Knowing the order makes it obvious where to plug in custom logic. ## Pipeline 1. **Client IP** — `get_client_ip(request)` reads `REMOTE_ADDR`. If the proxy is in `TRUSTED_PROXIES`, falls back to `X-Real-IP` then `X-Forwarded-For`. 2. **Rate limit / abuse** — `_enforce_rate_limit`. Blocks IPs already flagged, applies the per-route limits and triggers a 24h block on abuse. 3. **Authentication** — `_authenticate`. Tries `Authorization: Bearer` first (when `BEARER_RESOLVER` is configured), then `X-Api-Key`, then session cookie. Sets `self.user` and `self.account`. 4. **Token (optional)** — `_enforce_token`. Only when `ENFORCE_TOKEN=True`, validates `X-Token` against the session token. 5. **Origin (optional)** — `get_allowed_domain`. When `ALLOWED_ORIGINS` is set, requires the request `Origin` (with `Referer` fallback) to match. 6. **Tenant** — `aset_tenant(account_id)` switches the active database connection. 7. **Method resolution** — `get_method` looks at `routes` for a custom match; falls back to standard CRUD. 8. **Cache** — when `cache=True` and method is GET, builds key + namespace, runs `before_cache`, checks Redis. On hit, returns immediately. 9. **pre_process** — your hook to run anything before the body is parsed. 10. **Body parsing** — for POST/PATCH, parses JSON; if a Pydantic schema is set, validates and replaces `self.body` with the validated dict. 11. **Filters / pagination / ordering** — for list GET only. 12. **Handler** — the chosen method (`get`/`post`/`patch`/`delete` or custom route). 13. **Serialize** — runs `dehydrate`, applies `list_schema` if any, runs `post_process`, writes the response and saves to cache. ## Full flow (ASCII) Side-arrows ` ─►` mark **override points** — methods you can replace on your resource. The dotted box shows the methods called underneath, so you see where to plug in. ``` ┌──────────────────────────┐ │ incoming request │ └────────────┬─────────────┘ │ ▼ ┌──────────────────────────┐ │ SecurityMiddleware │ ← scanner / UA / 4xx-flood │ blocks → 403 (24h) │ instant block └────────────┬─────────────┘ │ ▼ ┌──────────────────────────┐ │ AuthMiddleware │ ← cookie → request.user │ (non-resource views) │ for templates etc. └────────────┬─────────────┘ │ ▼ ┌──────────────────────────┐ │ BaseResource.dispatch │ └────────────┬─────────────┘ │ ┌───────────────────┼───────────────────┐ ▼ ▼ ▼ [1] get_client_ip [2] _enforce_rate_limit [3] _authenticate REMOTE_ADDR abuse → 403 24h Bearer (opt-in) + X-Real-IP 429 if too fast → X-Api-Key if proxy trusted → cookie → self.user → self.account │ ▼ ┌──────────────────────────┐ │ authenticated=True │ no session → 401 │ but no session? │ └────────────┬─────────────┘ │ ▼ [4] _enforce_token (X-Token HMAC, when ENFORCE_TOKEN) [5] get_allowed_domain (Origin/Referer, when ALLOWED_ORIGINS) [6] aset_tenant(account.id) ← switch tenant DB │ ▼ ┌──────────────────────────┐ │ get_method (routes) │ → custom handler │ │ OR default get/post/... └────────────┬─────────────┘ │ ▼ ┌──────────────────────────┐ │ method allowed? │ → 405 if not └────────────┬─────────────┘ │ ▼ ┌──────────────────────────────┐ │ GET + cache=True ? │ └─┬──────────────────────────┬─┘ │ yes │ no ▼ │ ┌──────────────────────────┐ │ │ before_cache(request) │ │ │ build cache_key + ns │ │ │ Redis GET │ │ └─────┬─────────────┬──────┘ │ │ HIT │ MISS │ ▼ ▼ ▼ return cached └──────┬────────────┘ │ ▼ ┌──────────────────────────┐ │ pre_process(request) │ ← your hook └────────────┬─────────────┘ │ ▼ ┌──────────────────────────────┐ │ POST/PATCH ? │ └─┬──────────────────────────┬─┘ │ yes │ no ▼ │ ┌──────────────────────────┐ │ │ parse JSON body │ │ │ Pydantic validate │ │ │ (create/update_schema) │ │ │ → 422 on failure │ │ │ hydrate(body) hook │ │ └────────────┬─────────────┘ │ │ │ └──────────┬───────────────┘ │ ▼ ┌──────────────────────────────┐ │ GET list (no id) ? │ └─┬──────────────────────────┬─┘ │ yes │ no ▼ │ ┌──────────────────────────┐ │ │ build_filters(request) │ │ │ get_filters(request) │ ?filter= │ │ paginate(request) │ │ │ ordenate(request) │ │ └────────────┬─────────────┘ │ │ │ └──────────┬───────────────┘ │ ▼ ┌──────────────────────────┐ │ apply queryset_filter │ └────────────┬─────────────┘ │ ▼ ┌───────────────────────────────────┴───────────────────────────────────┐ │ handler runs │ │ │ │ ┌─────────────┐ ┌──────────────┐ │ │ │ GET │ ─► │ get(request) │ override to replace entirely │ │ └──────┬──────┘ └──────┬───────┘ │ │ │ │ │ │ │ ┌─────────┴───────────┐ │ │ │ ▼ ▼ │ │ │ has self.id? no id │ │ │ │ │ │ │ │ ▼ ▼ │ │ │ ┌──────────────┐ ┌─────────────┐ │ │ │ │ get_obj(id) │ ─► │ get_objs │ ─► override for │ │ │ └──────┬───────┘ │ (request) │ custom listing, │ │ │ │ └──────┬──────┘ aggregations, │ │ │ ▼ ▼ non-ORM source │ │ │ ┌──────────────┐ ┌─────────────┐ │ │ │ │ alter_detail │ ─► │alter_list │ ─► reshape result │ │ │ └──────────────┘ └─────────────┘ │ │ │ │ ┌─────────────┐ ┌──────────────┐ │ │ │ POST │ ─► │ post(request)│ │ │ └──────┬──────┘ └──────┬───────┘ │ │ │ │ │ │ │ ▼ │ │ │ ┌────────────────────┐ │ │ │ │ create_obj(req, │ ─► override for side │ │ │ │ body) │ effects (email, │ │ │ ├────────────────────┤ webhook), or replace │ │ │ │ • create_fields ✓ │ when no model exists │ │ │ │ • auto-fill │ │ │ │ │ created_by / │ │ │ │ │ updated_by / │ │ │ │ │ owner_id │ │ │ │ │ • blank/null check │ │ │ │ │ • acreate │ │ │ │ │ • custom_* save │ │ │ │ │ • m2m save │ │ │ │ │ • get_obj(new_id) │ ─► uses your get_obj │ │ │ └────────────────────┘ │ │ │ │ ┌─────────────┐ ┌──────────────┐ │ │ │ PATCH │ ─► │ patch(request)│ │ │ └──────┬──────┘ └──────┬───────┘ │ │ │ │ │ │ │ ▼ │ │ │ ┌────────────────────┐ │ │ │ │ update_obj(id,body)│ ─► override to enforce │ │ │ ├────────────────────┤ field-level rules, │ │ │ │ • update_fields ✓ │ or replace entirely │ │ │ │ • aget(pk=id) │ │ │ │ │ • self.diff[k] = │ │ │ │ │ {old, new} │ │ │ │ │ • aupdate(...) │ │ │ │ │ • get_obj(id) │ │ │ │ └────────────────────┘ │ │ │ │ ┌─────────────┐ ┌──────────────┐ │ │ │ DELETE │ ─► │delete(request)│ │ │ └──────┬──────┘ └──────┬───────┘ │ │ │ │ │ │ │ ▼ │ │ │ ┌────────────────────┐ │ │ │ │ delete_obj(id) │ ─► override for │ │ │ ├────────────────────┤ soft-delete, cascade, │ │ │ │ • _ownership_filter│ audit │ │ │ │ • adelete │ │ │ │ └────────────────────┘ │ └───────────────────────────────────┬───────────────────────────────────┘ │ ▼ exception? ──yes──▶ HTTPException → render JSON │ any other → log + │ sanitized 500 │ (DEBUG=False) ▼ ┌──────────────────────────────────────────────────────────────────────┐ │ serialize(result) │ │ │ │ for each row in result: │ │ ┌──────────────────┐ │ │ │ dehydrate(row) │ ─► override to strip secrets, add computed │ │ └──────────────────┘ fields, format dates │ │ │ │ ┌──────────────────┐ │ │ │ list_schema │ ─► Pydantic-shape every row (drops extras, │ │ │ (if set) │ coerces types) — set list_schema attribute │ │ └──────────────────┘ │ │ │ │ ┌──────────────────┐ │ │ │ post_process │ ─► override for last-chance modification │ │ │ (response) │ (audit logs from self.diff, etc.) │ │ └──────────────────┘ │ │ │ │ ┌──────────────────┐ │ │ │ save_cache │ ─► only when cache=True and method=GET │ │ │ (if cache=True) │ override to change TTL / namespace │ │ └──────────────────┘ │ │ │ │ JsonResponse(... encoder=CustomJSONEncoder ...) │ └────────────────────────────────┬──────────────────────────────────────┘ │ ▼ ┌──────────────────────────┐ │ invalidate_cache │ ← writes only │ (POST/PATCH/DELETE) │ by namespace └────────────┬─────────────┘ │ ▼ ┌──────────────────────────┐ │ ExceptionMiddleware │ ← if HTTPException │ renders JSON │ escaped └────────────┬─────────────┘ │ ▼ ┌──────────────────────────┐ │ JSON response │ └──────────────────────────┘ ``` ## Where to hook ## Sanitized 500 In production (`DEBUG=False`), unhandled exceptions inside the handler are caught and turned into a JSON 500 with no stack trace. `HTTPException` is always re-raised because it is the controlled-error mechanism. ### Authentication Two built-in mechanisms (cookie + `X-Api-Key`) plus an opt-in Bearer module. They share the same downstream context (`self.user`, `self.account`, tenant DB) — once authenticated, the rest of dispatch does not care which one was used. Cookie sessions and resolved `X-Api-Key` sessions are Redis-backed by the framework; Bearer delegates storage/validation to your resolver (DB, JWT, Redis, introspection — your call). Bearer is described separately on [Bearer tokens](bearer-auth.html). ## Decision tree When a request arrives, dispatch picks an authentication source in this order: 1. `Authorization: Bearer ` present **and** `MCP['BEARER_RESOLVER']` configured → Bearer flow ([details](bearer-auth.html)) 2. Otherwise, `X-Api-Key` header present → API key flow 3. Otherwise, session cookie (named after `COOKIE_ID`) → session flow 4. Otherwise, no session When `BEARER_RESOLVER` is configured and `REQUIRE_VALID_BEARER=True`, Bearer becomes the *only* accepted credential for authenticated routes — `X-Api-Key` and cookie return 401. See [Bearer tokens](bearer-auth.html) for the full matrix. If `authenticated = True` on the resource (default) and no session was resolved, dispatch raises `HTTPException(401, 'Not authorized')` before any handler runs. ## Session flow ### 1. Cookie validation The session cookie value is checked against a strict regex (`SESSION_KEY_PATTERN = ^[a-zA-Z0-9_\-:]{5,100}$`) before any Redis lookup. Malformed cookies return `None` immediately — they never reach the cache or the DB. ### 2. Redis lookup Sessions live under `sessions:` (with `REDIS_PREFIX` if configured). Missing key → no session. ### 3. Activation The JSON value is loaded and the resource activates it: - `self.user = session['user']` - `self.tz = ZoneInfo(user.timezone or 'UTC')` and Django timezone is activated - `self.account = session['account']` - `self.account_db` is switched via `aset_tenant(account.id)` ### Session shape ``` { "user": { "id": 42, "email": "user@example.com", "name": "Jane", "timezone": "America/Sao_Paulo", "token": "...", "is_admin": false, "is_owner": false, "locale": "en", "preferences": {} }, "account": { "id": 7, "name": "Acme" } } ``` How sessions get created is up to your project — typically a `/login` endpoint validates credentials, builds the dict, writes it to Redis with a TTL, and sets the cookie. ## API key flow API keys are designed for server-to-server traffic and trusted scripts. **The key format is your project's choice** — 0-mcp only requires that you can resolve a given key string to a session payload. The default resolver shipped with 0-mcp expects a self-describing four-segment format with embedded tenant id and integrity check; if that doesn't fit, drop in your own resolver via settings. ### Resolution flow 1. Look up `api_session:` in Redis. 2. On hit, return the cached session. 3. On miss, call the configured resolver — `MCP['API_KEY_RESOLVER']` if set, otherwise `zeromcp.tenant.tenant._default_resolve_api_key`. 4. The resolver returns a session dict (with `user`/`account`) or `None`. 5. Cache the result under `api_session:` for `API_SESSION_TTL` seconds (default 300). This means an API request hits the underlying storage at most once per `API_SESSION_TTL` window. Subsequent requests within the window are served from Redis. ### Default resolver — legacy four-segment format Without `MCP['API_KEY_RESOLVER']` set, the default resolver expects the historical format `...`, where `hash = sha256(account_id + uuid + salt)[:HASH_LENGTH]`. It validates the hash, switches to the tenant DB, finds the matching `UserApi` row, builds the session. This is what every existing 0-mcp project uses today. It still works without any changes. ### Custom resolver When you want opaque tokens, JWTs, externally-issued keys or anything else, point at your own callable: ``` # settings.py MCP = { 'API_KEY_RESOLVER': 'myapp.api_keys.resolve', } # myapp/api_keys.py async def resolve(api_key): """Take a raw key string. Return a session dict or None.""" user = await UserApi.objects.filter(api_key=api_key).afirst() if not user: return None return { 'user': { 'id': user.id, 'email': user.email, 'timezone': user.timezone, }, 'account': {'id': user.account_id}, } ``` The resolver gets the raw key — do whatever validation you want (length checks, JWT signature, deny-list, DB lookup). Return the session payload that `request.user` and `request.account` will be populated from. The Redis cache is applied automatically by `get_api_session`. ### Required settings ``` TENANT_USER_API_MODEL = 'myapp.UserApi' # the model that holds api_key rows MCP = { 'API_SESSION_TTL': 300, # cache TTL in seconds (optional) } ``` ### UserApi model shape The minimum: ``` class UserApi(models.Model): api_key = models.CharField(max_length=200, unique=True) email = models.EmailField() name = models.CharField(max_length=100, default='') avatar = models.CharField(max_length=200, null=True) is_admin = models.BooleanField(default=False) is_owner = models.BooleanField(default=False) locale = models.CharField(max_length=10, default='en') preferences = models.JSONField(default=dict) timezone = models.CharField(max_length=50, default='UTC') ``` ### Issuing a key How keys are generated is your project's responsibility. Three common approaches: - **Opaque random tokens** — `secrets.token_urlsafe(32)`. Simplest. The key is just a lookup index in your DB. - **Self-describing with integrity check** — embed the tenant id + a hash. Lets you reject malformed keys without touching the DB. 0-mcp's default `get_api_session` expects this. - **JWTs / signed tokens** — when you need stateless validation. Trade-off: revocation requires a deny-list. Whichever approach you pick, store the issued string in `UserApi.api_key` and hand it to the customer once. ### Revocation Two ways: - Delete the `UserApi` row → next miss after the cache expires returns `None`. Worst-case window is `API_SESSION_TTL`. - For instant revocation, also delete `api_session:` from Redis. ## Headers and HTTP semantics Precedence when more than one is present: **Bearer** (if configured) > `X-Api-Key` > cookie. See [Bearer tokens](bearer-auth.html) for the strict-mode matrix. ## Public endpoints Set `authenticated = False` on the resource. Authentication is skipped, but rate limit, abuse blocking and security middleware still apply. Useful for `/login`, `/signup`, `/health` and similar. ``` class HealthResource(BaseResource): authenticated = False allowed_methods = ['get'] async def get(self, request): return {'ok': True} ``` ## Token anti-replay (optional, HMAC-SHA256) When `ENFORCE_TOKEN=True`, every authenticated request (except `/login` and `/user/me`) must carry an `X-Token` header. The token format is: ``` X-Token: .. ``` Where `hmac_sha256_hex` is `HMAC-SHA256(session_token, ":")`. The `session_token` is the secret you stored under `user.token` when you built the session. ### Server-side validation 0-mcp verifies on every request: 1. Token format (3 dot-separated parts). 2. Nonce charset/length — must match `^[A-Za-z0-9_\-]{1,64}$`. 3. Timestamp drift — must be within `max_drift_ms` (30 seconds by default; configurable via `MCP['TOKEN_MAX_DRIFT_MS']`). 4. HMAC matches — using `hmac.compare_digest` to avoid timing attacks. 5. Nonce uniqueness — reserved in Redis with `SET NX PX` (TTL = `2 * max_drift_ms`). A replayed nonce inside the window raises `HTTPException(403, 'Not allowed, replayed token')`. Any failure raises `HTTPException(403)`. Clients must generate a fresh nonce per request — `crypto.randomUUID()` or `secrets.token_urlsafe(16)` are good defaults. ### Minting tokens client-side Use the helper `make_token` (also exported from `0-mcp`) on the client side, or inline the same algorithm in JavaScript: ``` from zeromcp import make_token token = make_token(session_token, nonce='abc-123') # → '1730000000000.abc-123.deadbeef...' ``` In JavaScript: ``` async function makeToken(sessionToken) { const ts = Date.now(); const nonce = crypto.randomUUID(); const payload = new TextEncoder().encode(`${ts}:${nonce}`); const key = await crypto.subtle.importKey( 'raw', new TextEncoder().encode(sessionToken), { name: 'HMAC', hash: 'SHA-256' }, false, ['sign'], ); const sig = await crypto.subtle.sign('HMAC', key, payload); const hex = [...new Uint8Array(sig)] .map(b => b.toString(16).padStart(2, '0')).join(''); return `${ts}.${nonce}.${hex}`; } ``` 🔐 `ENFORCE_TOKEN` is opt-in and off by default. Most projects authenticate with the session cookie alone. Turn it on when you need defense-in-depth against replayed cookies (e.g. a leaked HAR file, network logger, malicious browser extension). ## Trusted proxies When the app is behind a load balancer, raw `REMOTE_ADDR` is the proxy. Set `TRUSTED_PROXIES` to a list of CIDRs, and the library will trust `X-Real-IP` (and `X-Forwarded-For`) only when the request comes from one of those CIDRs. ``` MCP = { 'TRUSTED_PROXIES': ['10.0.0.0/8', '172.16.0.0/12'], } ``` This stops anyone on the public internet from spoofing their IP through a header — which would otherwise bypass rate limit and abuse blocking. ## Trying it ### With cookie ``` curl -b "sessionid=YOUR_KEY" https://api.example.com/users/me ``` ### With API key ``` curl -H "X-Api-Key: " https://api.example.com/users ``` In the Scalar UI, click **Authorize** to set either credential before hitting "Send" on a request. 🔑 API keys are scoped to a tenant via the `UserApi` row that stores them. 0-mcp switches to that tenant's DB before any request runs, so a key from tenant A cannot read tenant B's data. ### Sessions (login flow) 0-mcp does not ship a `/login` endpoint — your project decides how users authenticate. But the session payload it expects is opinionated. Get it right and everything (auth, multi-tenant, timezone, ownership) just works. Get it wrong and you'll spend an hour debugging a 401 you don't understand. ## The contract A session is a JSON document stored in Redis under `sessions:`. 0-mcp reads it, parses it, and uses the fields below directly. ``` { "user": { "id": 42, // required "email": "user@example.com", // recommended "name": "Jane", // optional "timezone": "America/Sao_Paulo", // recommended (default 'UTC') "is_admin": false, "is_owner": false, "locale": "en", "preferences": {}, "token": "..." // required if MCP['ENFORCE_TOKEN'] is True }, "account": { "id": 7, // required for multi-tenant "name": "Acme" } } ``` ## Field-by-field reference ⚠️ A missing `account.id` does **not** raise — it just skips the tenant switch. If your project relies on multi-tenant, the request will silently query the master DB instead. ## Writing a /login endpoint A typical login flow: ``` import json import secrets from django.http import JsonResponse from zeromcp import BaseResource, get_master_user from zeromcp.redis_config import get_redis, KEY_PREFIX from zeromcp.settings_helper import get_cookie_id COOKIE_ID = get_cookie_id() SESSION_TTL_SECONDS = 7 * 24 * 3600 # one week class LoginResource(BaseResource): authenticated = False allowed_methods = ['post'] async def post(self, request): body = json.loads(request.body) user = await get_master_user(body['email'], body['password']) # build the session payload — must match the contract session = { 'user': { 'id': user.id, 'email': user.email, 'name': user.name, 'timezone': user.timezone or 'UTC', 'is_admin': user.is_admin, 'is_owner': user.is_owner, 'locale': user.locale, 'preferences': user.preferences, 'token': secrets.token_urlsafe(32), # signing secret for X-Token }, 'account': { 'id': user.account.id, 'name': user.account.name, }, } session_key = secrets.token_urlsafe(24) redis = get_redis() await redis.setex( f'{KEY_PREFIX}sessions:{session_key}', SESSION_TTL_SECONDS, json.dumps(session), ) response = JsonResponse({'success': True, 'user': session['user']}) response.set_cookie( COOKIE_ID, session_key, max_age=SESSION_TTL_SECONDS, httponly=True, secure=True, samesite='Lax', ) return response ``` ## Cookie requirements The session cookie value is validated against `^[a-zA-Z0-9_\-:]{5,100}$` before any Redis lookup. Stick to URL-safe characters. `secrets.token_urlsafe()` is a safe default. ## Logout ``` async def logout(self, request, match=None): session_key = request.COOKIES.get(COOKIE_ID) if session_key: redis = get_redis() await redis.delete(f'{KEY_PREFIX}sessions:{session_key}') response = JsonResponse({'success': True}) response.delete_cookie(COOKIE_ID) return response ``` ## Refreshing the session Two options: - **Sliding window** — cookie sessions already bump their TTL automatically on every authenticated request via `GETEX`. API-key session caches also slide automatically. - **Hard expiry** — the cookie/Redis TTL match. Users log in again when it expires. Sliding is the default UX for browser apps in 0-mcp today. Hard expiry is still fine when your login flow chooses not to refresh the session blob itself. ## Updating user data after login When the user changes their name, plan, role — anything inside the session — re-write the session blob: ``` async def update_session(session_key, mutate): redis = get_redis() key = f'{KEY_PREFIX}sessions:{session_key}' raw = await redis.get(key) if not raw: return session = json.loads(raw) mutate(session) ttl = await redis.ttl(key) or SESSION_TTL_SECONDS await redis.setex(key, ttl, json.dumps(session)) ``` Or invalidate the session and force re-login. Pick your trade-off. ## Common mistakes - **No `user.id`** — `owner_field` and audit hooks crash with `KeyError`. - **No `account.id` in multi-tenant** — queries silently hit the master DB. - **`user.timezone` set to a non-IANA string** — date filters break. - **Cookie not `httponly`** — XSS can steal session keys. - **Cookie value with characters outside `[a-zA-Z0-9_\-:]`** — `validate_session_key` returns `None`, request becomes anonymous. - **Cookie TTL outlives the Redis TTL** — user sees random 401s. - **`MCP['ENFORCE_TOKEN']` is `True` but `user.token` missing** — every `X-Token` validation fails. - **Resource declares `cache_scope_fields` but the session payload omits the field** — framework logs a `WARNING` and disables cache for that request (no read, no write). Treat scope fields as part of the session contract and watch for these warnings during rollout — endpoints that should be cached but aren't are a sign that the session payload needs backfill. 🔑 Build one helper that issues sessions and call it from /login, /signup, /password-reset, /sso-callback. Centralizing the session payload is the single biggest win for a long-lived project. ## Production ### Multi-tenant One database per tenant, switched per request based on the active session. Your model code does not change. Queries cannot accidentally cross tenants. ## How it works When a session has an `account.id`, dispatch (or `AuthMiddleware`) calls `aset_tenant(account_id)` before running the handler: - The function looks up the tenant DB connection details (cached in Redis, built from your `Account` / `Db` models on miss). - Builds and registers a Django connection for that tenant if not already present. - Stores the active connection name (`_`) in a `ContextVar` (`db_state`). `DBRouter` reads that ContextVar on every ORM query and routes to the matching connection. Models declared as "always master" stay on the default DB. ## Setup Add the router and tenant settings: ``` DATABASE_ROUTERS = ['zeromcp.DBRouter'] DEFAULT_DATABASE = DATABASES['default'] TENANT_ACCOUNT_MODEL = 'core.Account' TENANT_USER_MODEL = 'core.User' TENANT_USER_API_MODEL = 'core.UserApi' TENANT_DB_PREFIX = 'tenant' HASH_LENGTH = 32 ``` Provide a model that holds tenant connection details. Minimum shape: ``` class Account(models.Model): name = models.CharField(max_length=100) status_id = models.IntegerField(default=1) class Db(models.Model): host = models.CharField(max_length=200) user = models.CharField(max_length=100) password = models.CharField(max_length=200) account = models.OneToOneField(Account, on_delete=models.CASCADE, related_name='db', null=True) ``` ## API All exported from the top-level `zeromcp` package — `from zeromcp import set_tenant, aset_tenant, ...`. ## Usage ### In a resource Nothing to do. Dispatch handles it automatically when the session has an account. ### In a Django management command ``` from zeromcp import set_tenant class Command(BaseCommand): def handle(self, *args, account_id, **opts): set_tenant(account_id) # any ORM query from here on routes to the tenant DB User.objects.update(...) ``` ### In a Celery task Celery tasks are typically sync — use `set_tenant`: ``` from zeromcp import set_tenant @shared_task def export_report(account_id): set_tenant(account_id) # any ORM query from here on routes to the tenant DB rows = list(Report.objects.all()) ... ``` For async tasks (`@shared_task` with `async def`), use `await aset_tenant(account_id)`. ### Long-running scripts iterating over tenants ``` # script-only — mutates the global default connection. # Do not call from inside an ASGI request. from zeromcp.tenant.tenant import set_default, unset_default for account_id in tenant_ids: await set_default(account_id) try: # everything inside treats this tenant as the default DB await run_migration_step() finally: await unset_default(account_id) ``` ## Master DB Models that should always live on the master DB (users, accounts, billing) are configured in the router. The router uses an allow-list pattern for "always master" — typically your auth and billing apps. ## Connection caching Tenant connection dictionaries are cached in Redis under `:connections:`. The first request after a deploy hits the master DB to build it; subsequent requests read from Redis. Updating the `Db` row for a tenant requires invalidating that cache key. 🏢 Multi-tenant routing is invisible to your model code. You write `User.objects.all()` — the router decides which DB. Your business logic does not know there are multiple databases. Your queries cannot cross tenants because the connection is gone. ### Cache Opt-in Redis cache per resource, with namespace-based invalidation that does not blow away unrelated rows. ## Enable on a resource ``` class SpaceResource(BaseResource): model = Space cache = True cache_ttl = 600 # optional; framework default is 120s ``` Now `GET /spaces` and `GET /spaces/{id}` are cached. POST/PATCH/DELETE invalidate automatically. ## Project-level TTL controls Two keys in the project's `MCP` bag override the framework default without touching resource code: ``` MCP = { 'CACHE_TTL': 300, # default 120s; framework default for resources 'CACHE_TTL_ENABLE': True, # default True; flip to False to kill cache globally } ``` - `CACHE_TTL` only changes the default — resources that declare `cache_ttl = N` keep their explicit value. - `CACHE_TTL_ENABLE = False` is a master kill switch: every `cache = True` is treated as `cache = False` at runtime, no Redis read, no Redis write. Useful for incident response without redeploy. ## Cache key Built from the request path and a hash of the querystring: ``` cache:/spaces: cache:/spaces/42 ``` When `session_cache = True`, the session id is folded into the key — useful for endpoints whose response depends on the user. ## Namespaces and invalidation Every cached entry is also added to a Redis set keyed by namespace: - `list:myapp.space` — all list responses for the model - `detail:myapp.space:42` — the detail page for row 42 Writes invalidate by namespace: - `POST /spaces` → invalidates `list:myapp.space` - `PATCH /spaces/42` → invalidates `list:myapp.space` + `detail:myapp.space:42` - `DELETE /spaces/42` → same as PATCH ✨ Editing row 42 does **not** drop the cache for row 7. Lists are dropped because their contents may shift, but per-row pages stay warm. ## Tenant isolation (automatic) Multi-tenant deployments share Redis. `_build_cache_key` folds `self.account_id` into the key whenever it is set — two tenants hitting the same path get different keys without any configuration. The fold uses `account_id is not None`, so an explicit `account_id = 0` is treated as a real value (folded as `a=0`), not as absence. ``` # base prefix : [session_cache] : [a=] : [scope=] : path : md5(qs) cache:a=42:/clients cache:sid-abc:a=42:/me ``` 🛡 No need to declare `cache_scope_fields = [('account', 'id')]` — the framework already isolates per tenant. `cache_scope_fields` is for dimensions **inside** the same tenant (role, space, plan). Single-tenant deployments that want to keep the legacy key shape (no `a=` segment) can opt out via `MCP = {'AUTO_SCOPE_CACHE_BY_ACCOUNT': False}` in `settings.py`. Anonymous endpoints skip the fold automatically. 🪝 If your project overrides `_build_cache_key`, call `self._account_cache_segment()` and append the result to the key so the override inherits the tenant isolation. Otherwise the override silently re-introduces the cross-tenant cache leak. ## Per-scope caching — `cache_scope_fields` When the response depends on a dimension carried by the session (user role, space, plan, country), declare it with `cache_scope_fields`. Users sharing the same scope share the cache; different scopes get isolated keys; the namespace-based invalidation is unchanged. ``` class TaskResource(BaseResource): model = Task cache = True # Strings are shorthand for ('user', field). Tuples select the # source explicitly: 'user' or 'account'. cache_scope_fields = ['space_id', ('account', 'plan_id')] ``` The framework folds the configured fields into a 16-char MD5 hash and prepends it to the request path inside the cache key. Anonymous requests (`self.user is None and self.account is None`) skip the fold silently. 🛡 **Fail-safe.** When auth context exists but a configured scope field is missing from the session payload, the framework logs a `WARNING` (logger `zeromcp.base`) and disables cache for that request — the response is neither read from nor written to Redis. Sharing a key across users when the scope can't be resolved would silently leak data. Anonymous requests skip the fold cleanly. `None`, `0` and `''` count as present; only true absence triggers the warning. 🪝 Coexists with `session_cache = True`. Both fold into the key without conflict — `session_cache` gives full per-session isolation (lowest hit rate), `cache_scope_fields` keeps users on the same scope sharing cache (best hit rate when the dimension count is small). ## before_cache hook Escape hatch for the rare case that needs context outside `self.user` / `self.account` — feature flags, request headers, time-of-day. Override `before_cache(request)`: ``` async def before_cache(self, request): flag = await get_feature_flag(self.user) self.cache_key += f':flag={flag}' ``` For routine user/account-derived dimensions, prefer `cache_scope_fields` — it is declarative and centrally validated. ## Custom routes Custom routes opt in via the `cache` flag in their route definition: ``` routes = [ {'path': r'/me$', 'func': 'me', 'allowed_methods': ['get'], 'cache': True}, ] ``` ## Hit ratio Every request increments hit/miss counters in Redis: - `cache_stats:hits` and `cache_stats:misses` — global counters (`INCR`) - `cache_stats:by_model` — Redis hash with fields `hits: