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"}):
- MCP layer fast-validates the input against the JSON Schema (Pydantic-derived or Django-introspected).
- Bridge builds a synthetic Django
HttpRequest:POST /spaces,Content-Type: application/json, body = the args, auth headers from context. - Wraps the view with the project's configured
settings.MIDDLEWARE(async-capable only), soSecurityMiddleware,AuthMiddleware,ExceptionMiddlewareand 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).
SpaceResource.as_view()(request) through that chain โ the same code path REST uses.pre_process, create_obj (auto-fill, m2m, custom_*, integrity), dehydrate, post_process, cache invalidation.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:
| Setting | Effect |
|---|---|
mcp_expose = None (default) | Expose every verb in allowed_methods |
mcp_expose = False | Hide the resource entirely from MCP |
mcp_expose = ['list', 'get'] | Read-only โ explicit allow-list of verbs |
Example โ read-only resource
class ReportResource(BaseResource):
model = Report
summary = 'report'
mcp_expose = ['list', 'get'] # agents see reports but cannot mutateExample โ REST-only resource
class WebhookResource(BaseResource):
model = WebhookEvent
mcp_expose = False # webhooks are not agent toolsMCP['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:
| Concern | Default | Effect |
|---|---|---|
| List length | 50 | objects capped at 50; meta.truncated = true, meta.returned set |
| String length | ~2000 chars | Truncated with โฆ (truncated) marker |
| Error stack traces | never returned | Mapped to MCP error code (see below) |
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:
| 0-mcp exception | MCP code |
|---|---|
HTTPException(401, ...) | UNAUTHORIZED |
HTTPException(403, ...) | FORBIDDEN |
HTTPException(404, ...) | NOT_FOUND |
HTTPException(400|422, ...) | VALIDATION_ERROR |
HTTPException(405, ...) | METHOD_NOT_ALLOWED |
HTTPException(429, ...) | RATE_LIMITED |
| Anything else | INTERNAL (sanitized โ no stack trace) |
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 responseAll BaseResource knobs work โ cache, cache_ttl, authenticated, before_cache, the lot.
0-mcp by Stamatios Stamou Jr โ github.com/ssjunior/0-mcp