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:

    SettingEffect
    mcp_expose = None (default)Expose every verb in allowed_methods
    mcp_expose = FalseHide 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 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:

    ConcernDefaultEffect
    List length50objects capped at 50; meta.truncated = true, meta.returned set
    String length~2000 charsTruncated with โ€ฆ (truncated) marker
    Error stack tracesnever returnedMapped 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 exceptionMCP 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 elseINTERNAL (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 response

    All BaseResource knobs work โ€” cache, cache_ttl, authenticated, before_cache, the lot.

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