Skip to content

Concepts

A short tour of every moving part. Read this once; the rest of the docs assume you have these in your head.

ServiceSpec is the unit of registration

The MCP server does not wrap, walk, or otherwise reach into DRF viewsets, routers, or views. Consumers register ServiceSpec instances directly. Same value object as the HTTP transport — same callable can serve both at once.

from rest_framework_mcp import SelectorKind, SelectorSpec, ServiceSpec  # re-exported for ergonomics

spec = ServiceSpec(
    service=create_invoice,
    input_serializer=InvoiceInputSerializer,
    output_selector_spec=SelectorSpec(   # nested spec for the post-call
        kind=SelectorKind.RETRIEVE,      # render pipeline (RETRIEVE → many=False,
        output_serializer=InvoiceOutputSerializer,  # LIST → many=True)
        selector=None,                   # optional post-call re-fetch callable
    ),
    atomic=True,            # wrap dispatch in transaction.atomic()
    success_status=None,    # ignored by MCP — used by HTTP
    kwargs=None,            # optional per-spec kwargs provider; see below
)

This means a project that uses neither ServiceViewSet nor DRF routers can still expose its services over MCP. The HTTP and MCP transports are siblings, not layers — neither owns the other.

What ServiceSpec / SelectorSpec carries through to MCP

The MCP layer honors the same spec fields as the HTTP transport — register a spec once and both surfaces get the same shape:

  • permission_classes — DRF BasePermission classes. Auto-wrapped with DRFPermissionAdapter and prepended to the per-binding permissions tuple, so spec-declared permissions run before any tool-level MCPPermission you add at the MCP call site.
  • SelectorSpec queryset shapingselect_related, prefetch_related, annotations, and extend_queryset are applied before the FilterSet / ordering / pagination pipeline. Non-queryset returns (lists, scalars) pass through unchanged.
  • Serializer context — every serializer the MCP transport builds carries DRF's baseline context (request / format / view, from the synthesised pair), exactly as get_serializer_context() supplies it behind a view — so a serializer reading self.context["request"] unguarded renders the same over both surfaces. On top of that, input_serializer_context / output_serializer_context (on ServiceSpec) and output_serializer_context (on SelectorSpec) are merged over the baseline and forwarded as context= to the serializer constructor, on both sync and async dispatch paths. Providers are invoked through the keyword pool — each receives the subset of view / request / the resolved-data extra (result / instance / page) it declares by name, or the whole pool if it takes **kwargs — which is how drf-services invokes them on the HTTP path, so one provider serves both. Requires djangorestframework-services>=0.29.0.
  • SelectorSpec.kind — required SelectorKind discriminator (LIST or RETRIEVE). It drives the many= flag on the output serializer and gates which post-fetch knobs the registration accepts (a RETRIEVE spec rejects the collection-only ordering_fields / paginate, but filter_set is allowed — it is shaped + applied before the single-instance .first()). SelectorKind is re-exported from rest_framework_mcp for convenience.
  • ServiceSpec.output_selector_spec — a nested SelectorSpec | None describing the post-call render pipeline (optional re-fetch via its selector, then output_serializer with many= driven by its kind). The decorator forms (@server.service_tool, etc.) accept flat output_serializer= / output_selector= kwargs and build the nested spec internally; direct ServiceSpec(...) construction uses the nested shape.

Per-spec kwargs providers

ServiceSpec.kwargs (and SelectorSpec.kwargs) is a callable that returns extra kwargs to merge into the dispatch pool — useful for plumbing per-tenant context, signed lookups, etc. without scattering request.user.* reads across services.

from rest_framework_services import OfflineServiceView, ServiceSpec


def with_tenant(view: OfflineServiceView, request) -> dict:
    return {"tenant_id": request.user.tenant_id}


server.register_service_tool(
    name="invoices.create",
    spec=ServiceSpec(
        service=create_invoice,
        input_serializer=InvoiceInputSerializer,
        output_selector_spec=SelectorSpec(
            kind=SelectorKind.RETRIEVE,
            output_serializer=InvoiceOutputSerializer,
        ),
        kwargs=with_tenant,
    ),
)

The provider receives an OfflineServiceView (synthesised by the sister repo's build_offline_context because MCP has no DRF view) — view.action is the binding name, and on resource reads view.kwargs carries the URI-template variables. Same wire shape as the HTTP transport's ServiceView, so providers can be shared between transports.

Like every provider in the framework, it is invoked through the keyword pool: it receives view / request by name, so it declares only what it needs (def with_tenant(request): ... is as valid as the two-parameter form, and **kwargs takes the whole pool). Declaring a parameter the pool doesn't carry is the error — not declaring one it does.

URL kwargs — route values a provider reads off view.kwargs

On a tool call, view.kwargs is empty by default (a tool has no URL). So a provider shared with an HTTP view that scopes by a route capture — e.g. view.kwargs["project_pk"] behind a tenant/role lookup — would return its fallback (usually None) over MCP and mis-scope for every caller. Register a UrlKwarg so the model can supply that route value: it is advertised as a tool argument, popped at dispatch, and seeded into the off-HTTP view.kwargs — from where drf-services spreads it into the dispatch pools (authoritative over the spec params, below the spec.kwargs provider).

from rest_framework_mcp import UrlKwarg
from rest_framework_services import ServiceSpec


def scope_by_project(view, request) -> dict:
    # Over HTTP this reads a URL capture; over MCP it reads the UrlKwarg the
    # model supplied — same code, both transports.
    return {"role": role_in_project(request.user, view.kwargs.get("project_pk"))}


server.register_service_tool(
    name="policies.update",
    spec=ServiceSpec(service=update_policy, kwargs=scope_by_project),
    url_kwargs=(UrlKwarg("project_pk", type="integer", description="owning project"),),
)

Because a URL kwarg is popped before the spec sees the arguments, it never counts as an unknown argument (the REJECT policy ignores it) and never lands in the service's validated payload — it routes only through view.kwargs. A name can't collide with a reserved transport key (ordering / page / limit, or the request / user / data / instance / serializer / collection pool seeds); colliding with an ordinary spec input is allowed and is the intended way to route a route-capture the spec also reads directly.

A capture the spec genuinely cannot run without takes required=True:

UrlKwarg("project_pk", type="integer", required=True)

The name joins the tool's inputSchema required list, so the model is told up front — and, because a schema hint is only a hint, a call that omits it comes back as an isError validation result naming the missing argument rather than failing somewhere less legible. required can't be combined with a default (a default always satisfies the argument, so requiring it would be a no-op); that raises at registration.

A reflected **extras key is not a route capture

A selector typed def list_widgets(user, **extras: Unpack[WidgetExtras]) that reads extras["project_pk"] already has that key reflected into the tool's inputSchema by drf-services (0.26+) — no UrlKwarg needed for the selector itself, which receives it through the spec params. Marking it InputRequired (drf-services 0.28+) makes the model supply it; that is a schema statement and changes nothing about where the value lands.

The two declarations answer different questions, and only one of them puts a value on the request:

reflected **extras key (± InputRequired) registered UrlKwarg
In the inputSchema yes yes
Can be required yes (InputRequired) yes (required=True, plus an isError result when omitted)
Reaches the selector yes, as a spec param yes, via the view.kwargs spread
Reaches view.kwargs no yes
Ranks above caller-supplied params no — it is caller input yes

So anything that reads request state rather than its own arguments — a spec.kwargs provider, extend_queryset, a permission class, an output_serializer_context provider — sees nothing for a reflected-only key. A scoping provider doing view.kwargs.get("project_pk") returns None and mis-scopes every call instead of failing: the failure mode worth naming here is that it is silent.

Register the UrlKwarg as well when the value is scope. It is a strict superset — the selector still receives it in **extras, and the schema keeps one property and one required entry (an explicit UrlKwarg wins the merge over a reflected key of the same name).

That split mirrors HTTP, where a route capture arrives in the URL and never in the body — which is what makes it unspoofable. Over MCP the arguments are whatever the model chose; a UrlKwarg value outranks them. If a provider scopes by it, it has to come through the channel that carries that precedence.

UrlKwarg is drf-services' type, re-exported here — the declaration is the same whichever transport carries it, and the two adapters that each had a copy had already drifted apart on which names they reserved. from rest_framework_mcp import UrlKwarg keeps working. Requires djangorestframework-services>=0.28.1.

SelectorSpec for resources

register_resource(selector=...) requires a SelectorSpec, mirroring register_service_tool(spec=ServiceSpec(...)). The unit of registration is a spec on both surfaces.

  • .kind is the required SelectorKind discriminator (LIST or RETRIEVE); it drives the many= flag on the output serializer at dispatch. RETRIEVE is the typical choice for a single-object URI-template lookup.
  • .selector is the callable that gets dispatched (must be set; specs with selector=None are rejected).
  • .output_serializer fills in when the caller didn't pass one explicitly.
  • .kwargs becomes the binding's per-request kwargs provider.
from rest_framework_mcp import SelectorKind, SelectorSpec

server.register_resource(
    name="invoice",
    uri_template="invoices://{pk}",
    selector=SelectorSpec(
        kind=SelectorKind.RETRIEVE,
        selector=get_invoice,
        output_serializer=InvoiceOutputSerializer,
        kwargs=with_tenant,
    ),
)

Bare callables are rejected with TypeError — this is intentional: keeping the imperative surface symmetric with register_service_tool makes the spec the single point where output serializers and kwargs providers attach. Use the @server.resource(uri_template=...) decorator if you'd rather skip the boilerplate; it wraps the function in a SelectorSpec for you.

Per-tool registration kwargs

Beyond permissions=, output_format=, and include_structured_content=, register_service_tool / register_selector_tool (and their decorator forms) accept three behavior knobs:

  • argument_binding= — how the validated arguments flow into the callable's kwarg pool. The enum is re-exported from djangorestframework-services (the transport-neutral dispatch_spec owns these policies).
  • ArgumentBinding.BUNDLE (default for service tools) — only data=<validated> enters the pool.
  • ArgumentBinding.SPREAD_AUTHOR_WINS (default for selector tools) — every key from the validated arguments is spread into the pool as a top-level kwarg, so selectors can declare individual parameters (def list_drafts(*, project_id, page=1)). spec.kwargs(...) wins on conflict so author-declared invariants beat client input.
  • ArgumentBinding.SPREAD_CALLER_WINS — like SPREAD_AUTHOR_WINS but the spread wins on conflict, so spec.kwargs(...) supplies client-overridable defaults.
  • ArgumentBinding.AUTO — resolve per spec type (service → BUNDLE, selector → SPREAD_AUTHOR_WINS).

Reserved transport-pool seeds (request / user / data / instance / serializer) and the selector pipeline keys (ordering / page / limit) are stripped from the spread regardless of mode so clients can't poison transport-controlled state.

  • unknown_arguments= — how arguments keys outside the binding's declared field set are handled.
  • UnknownArguments.REJECT (default) — the validator rejects unknown keys with -32602, and the outer inputSchema advertises "additionalProperties": false. This holds only when the binding has an input_serializer to validate against: a serializer-less binding has no declared field set, so REJECT can't fire and its schema stays open ("additionalProperties": true) to match the runtime.
  • UnknownArguments.PASSTHROUGH"additionalProperties": true; unknown keys survive validation and are merged onto the validated payload before binding.
  • UnknownArguments.IGNORE"additionalProperties": true; unknown keys are silently dropped (the historic DRF default).

Selector tools' pipeline-reserved keys are always treated as "known", so the policy doesn't fight the post-fetch pipeline.

  • always_listed= — when REST_FRAMEWORK_MCP["FILTER_LISTINGS_BY_PERMISSIONS"] is enabled, bindings are dropped from tools/list / resources/list / prompts/list when their permissions deny the current caller. Setting always_listed=True keeps the binding visible as a discovery aid; the permission still gates the actual invocation.

Tool annotations

Every tool advertises the MCP-standard ToolAnnotations hints, derived from what the server already knows about the tool's mutation profile — so downstream clients get correct readOnlyHint / destructiveHint without a hand-set flag:

  • Selector tools are reads → {"readOnlyHint": true}.
  • Service tools are mutations → {"readOnlyHint": false, "destructiveHint": true}.
  • Chain tools are read-only only when every step is a selector; any service step makes the whole chain a mutation.

destructiveHint / idempotentHint are spec-meaningful only when readOnlyHint is false, so a read-only tool emits neither. Pass annotations= at registration to override or extend the derived hints — the explicit values win:

server.register_service_tool(
    name="invoices.mark_paid",
    spec=mark_paid_spec,
    # An idempotent, non-destructive mutation:
    annotations={"destructiveHint": False, "idempotentHint": True},
)

The merged bundle lands on binding.annotations and on the tools/list wire payload.

Generic _meta

Separate from annotations — which is a closed, spec-defined set of client hints — the base protocol gives most wire objects a free-form _meta object. It is the extension namespace: each protocol extension owns a top-level key inside it, and a server may add its own.

Pass meta= at any registration surface (register_service_tool, register_selector_tool, register_chain_tool, register_resource, register_prompt, the matching decorators, or a ToolDefinition / SelectorDefaults / ServiceDefaults):

server.register_selector_tool(
    name="invoices.list",
    spec=list_invoices_spec,
    meta={"example.com/panel": {"href": "panel://invoices"}},
)

The bundle lands on binding.meta and is emitted verbatim under the "_meta" key of the binding's listing entry — tools/list, resources/list, resources/templates/list, prompts/list — and, for a resource, on the contents block resources/read returns. It is omitted entirely when empty.

Nothing here validates, reserves, or rewrites a key: the whole point of _meta is that its contents are opaque to the transport. On the tools/call result envelope _meta is per-call rather than per-binding, so it is a build_tool_result(..., meta=...) argument instead of something sourced from the binding.

Bulk registration

For projects that register many tools in one place, the register_tools(server, definitions, *, selector_defaults=None, service_defaults=None) entry point collapses the boilerplate. Pass a list of ToolDefinition.service(...) / ToolDefinition.selector(...) instances plus per-kind defaults that fill in fields each definition leaves as None. The function loops over the existing per-tool registration methods, so every guarantee and bug fix applies automatically.

from rest_framework_mcp import (
    ServiceDefaults,
    SelectorDefaults,
    ToolDefinition,
    register_tools,
)

register_tools(
    server,
    [
        ToolDefinition.service(name="invoices.create", spec=create_spec),
        ToolDefinition.service(name="invoices.update", spec=update_spec),
        ToolDefinition.selector(name="invoices.list", spec=list_spec),
    ],
    service_defaults=ServiceDefaults(permissions=[ScopeRequired(["invoices:write"])]),
    selector_defaults=SelectorDefaults(permissions=[ScopeRequired(["invoices:read"])]),
)

Per-definition kwargs win over defaults on conflict; None is the "no override" sentinel across both layers.

Transport-neutral invocation: call_tool

server.call_tool(name, arguments, *, user, request=None) invokes a registered spec-backed tool off the HTTP / JSON-RPC path and returns the same ToolResult the wire handlers build. An in-process consumer — a bridge, a Pydantic-AI toolset, a management command — uses it instead of re-implementing dispatch:

result = server.call_tool("invoices.create", {"number": "A-1"}, user=request.user)
result.structured_content  # the rendered payload

It is built on djangorestframework-services' transport-neutral dispatch_spec / render_spec_output / enforce_permissions, so the spec-execution core (instance resolution, input validation, the service / selector run, the output-selector re-fetch, queryset shaping including filter_set, and the retrieve nullability contract) is shared with the HTTP transport rather than reproduced.

It honours the binding's argument_binding / unknown_arguments policies (mapped onto dispatch_spec's) and the spec's permission_classes in two layers: an upfront enforce_permissions call for the class-level has_permission check, plus the on_target_resolved=enforce_permissions hook for object-level checks on the resolved target. It does not layer on the read-shaped transport extras (pagination, ordering, a selector binding's MCP-only input_serializer); those stay with the wire handlers, as do the transport-level MCP permissions / rate limits. Chain tools are unsupported — they orchestrate several specs and raise TypeError. A service raising ServiceValidationError / ServiceError and a missing required instance come back as isError results; a denied permission or malformed input raises, for the caller to map.

Full in-process transport: acall_tool / list_tools

call_tool is the spec core. When an in-process consumer needs the whole transport — exactly what a remote MCP client sees — MCPServer exposes two async-friendly siblings that route through the same wire handlers:

page = server.list_tools(user=request.user, request=request)   # one tools/list page
page["tools"]        # merged inputSchema per tool
page["nextCursor"]   # pass back to list_tools(cursor, ...) to paginate

result = await server.acall_tool("invoices.list", {"ordering": "-amount", "page": 1},
                                 user=request.user, request=request)
result["structuredContent"]   # the wire's result payload (dict, not ToolResult)
  • list_tools(cursor=None, *, user, request=None) returns one page of the tool catalog with the same merged inputSchema (serializer fields plus a selector tool's filter / ordering / pagination arguments and the additionalProperties policy), the same FILTER_LISTINGS_BY_PERMISSIONS per-caller filter, and the same opaque-cursor pagination the HTTP transport uses.
  • acall_tool(name, arguments=None, *, user, request=None) invokes a tool with the full transport applied: the transport-level MCP permissions and rate limits, the selector post-fetch pipeline (filter / order / paginate), a selector binding's MCP-only input_serializer, chain tools, and the output format — everything call_tool deliberately omits. It returns the wire's dict payload (content / structuredContent / isError) or a JsonRpcError for a protocol fault (unknown tool, malformed arguments shape, denied permission).

Both build the call context internally from user + request (a minimal request is synthesised when request is None). JsonRpcError and JsonRpcErrorCode are re-exported from the package root so a consumer can branch on faults. This is the surface the django-ag-ui bridge consumes to run drf-mcp tools in-process with HTTP-equivalent semantics.

Documenting tools

A tool's description and its argument descriptions are the entire contract a model has to work from. Both have a channel; neither is filled in for you.

The tool description

description= on any register_* call. There is no docstring fallback for spec registration — a docstring is written for the next developer, not for a model choosing between tools, so promoting one silently would ship prose nobody reviewed for that audience.

Registering without one emits UndescribedToolWarning, and REST_FRAMEWORK_MCP["REQUIRE_TOOL_DESCRIPTIONS"] = True turns that into an ImproperlyConfigured. This mirrors REQUIRE_TOOL_PERMISSIONS: two properties are equally required for a tool to be usable — something must gate the call, and something must say what the call does.

Per-argument descriptions

Do not restate argument meaning in the tool description. Three channels feed inputSchema.properties.*.description directly:

# 1. Serializer fields — `help_text` becomes the property description.
class ArchiveWidgetInput(serializers.Serializer):
    widget_id = serializers.IntegerField(
        help_text="Primary key of the widget. Not the public slug.",
    )

# 2. URL kwargs — `UrlKwarg` takes a description of its own.
server.register_selector_tool(
    name="list_loan_documents",
    spec=documents_spec,
    description="List the documents filed against a loan.",
    url_kwargs=[
        UrlKwarg(
            name="loan_pk",
            type="string",
            required=True,
            description="Primary key of the *loan*, not the borrower.",
        )
    ],
)

The third is drf-services' Annotated marker vocabulary on an Unpack[TypedDict] extras key, which today carries InputRequired and NotClientInput but no description — a key documented only that way has to fall back to the tool description until that gap is closed upstream.

Duplicated prose is where descriptions get longest and go stale, so an argument whose name doesn't match the entity it identifies belongs in help_text or UrlKwarg(description=…), written once.

Tools vs resources

Tools Resources
MCP capability tools resources
Mutation? Yes (services) No (selectors)
Addressable? By name (invoices.create) By URI (invoices://42)
Dispatched via tools/call resources/read
Backed by ServiceSpec SelectorSpec
Schema advertised inputSchema + optional outputSchema mimeType

Tools are imperative (the client decides when to call them and supplies arguments). Resources are read-only and addressable by URI; they have a stable identifier and the client can rely on the same URI returning a consistent shape over time.

URI templates

Resource URIs follow a small subset of RFC 6570. Each {var} placeholder becomes a kwarg in the selector's signature:

server.register_resource(
    name="invoice",
    uri_template="invoices://{pk}",
    selector=SelectorSpec(
        kind=SelectorKind.RETRIEVE,
        selector=get_invoice,             # def get_invoice(*, pk): ...
        output_serializer=InvoiceOutputSerializer,
    ),
)

Concrete URIs (no placeholders) appear in resources/list; templated ones appear in resources/templates/list so clients can fill them in.

Resource body encoding

A resource advertises a mime_type and returns a body. Those are two separate decisions, so encoding= is declared rather than inferred from the mime type — sniffing would silently change the body for anyone already advertising something other than JSON.

server.register_resource(
    name="changelog",
    uri_template="docs://changelog",
    selector=SelectorSpec(kind=SelectorKind.RETRIEVE, selector=read_changelog),
    mime_type="text/markdown",
    encoding=ResourceEncoding.TEXT,
)

ResourceEncoding.JSON (the default) pretty-prints the selector's return value. ResourceEncoding.TEXT returns it verbatim, which is what Markdown, CSV, plain text and HTML need — under JSON the document would come back wrapped in a quoted string literal instead of as itself. A TEXT resource's selector must return a str; anything else is reported as a JSON-RPC error on the read rather than raising through the transport.

Interactive views (MCP Apps)

A tool can declare an HTML view that an MCP host renders inline in the chat, under the MCP Apps extension. It layers over the base protocol this package already speaks, so there is no protocol bump and no transport change.

The host/server split is the whole shape of it. This package declares:

server.register_ui_resource(
    name="invoices_table",
    uri="ui://invoices/table.html",
    template_name="mcp/invoices_table.html",
    ui=UIResourceMeta(
        csp=UICsp(connect_domains=["https://api.example.com"]),
        prefers_border=True,
    ),
)

The host renders: it builds the sandboxed iframe, constructs and enforces the CSP from what you declared, and runs the ui/* postMessage bridge. None of that is implemented here, and none of it should be.

A view is an ordinary resource — one URI namespace with your data resources, listed in resources/list, and guardable with permissions= — with three things fixed for you: the text/html;profile=mcp-app mime type, TEXT body encoding, and a _meta bundle under the extension's key. Give it exactly one content source: template_name= (a Django template, the idiomatic choice), html= (a literal document), or selector= (a zero-argument callable).

Views are unguarded by default. The MCP session is already authenticated, a view is a static asset rather than tenant data, and hosts may prefetch one before any tool call. Pass permissions= if your project wants otherwise.

Keep tenant data out of the view

Hosts may prefetch and cache a view, so it is a shell that hydrates itself at runtime from tool results — which is why the template renders with no context. This is a house rule, not a spec rule, and it is the one thing a Django author's instinct gets wrong: rendering the queryset into the template is normally the right answer, and here it would leak data across the host's cache.

A tool then points at the view, and the host renders that tool's result inside it instead of showing raw JSON:

server.register_selector_tool(
    name="list_invoices",
    spec=list_invoices_spec,
    ui=UIToolMeta(resource_uri="ui://invoices/table.html"),
)

The render payload is the structuredContent the tool already emits — no second serialisation path — and a tools/call the view makes comes back through the ordinary endpoint, inheriting your auth, MCPPermissions and rate limits unchanged.

Three ways a link can be wrong all fail the same way at runtime — a view that silently never renders — so all three are refused at registration:

Mistake Why it's caught
resource_uri names no view on this server The host resolves it against the same server, so a typo reaches it as a dangling reference. Register the view before the tool that links to it.
The tool has include_structured_content=False That is the render payload; the view would come up blank. Checked against the effective value, so a project that turned it off globally is caught too.
Both ui= and a "ui" key in meta= Both write the same _meta key, so one would quietly overwrite the other.

visibility declares who may call the tool — UIVisibility.MODEL, APP, or both. It is host-enforced: a host is required not to offer the model a tool whose visibility omits MODEL, which makes an APP-only tool a useful shape for a fine-grained operation that exists to serve the view rather than the conversation. This server declares the field and does not filter tools/list on it — a client that doesn't implement the extension wouldn't honour the rule anyway.

A client advertises Apps support as capabilities.extensions on initialize, which is parsed onto ClientCapabilities.extensions. Advertisement is one-directional, client → server — the spec defines no matching server capability, so nothing is sent back, and _meta.ui is emitted unconditionally. Unknown _meta keys are ignorable by design, so a client that doesn't implement Apps is unaffected.

Dispatch flow

The MCP package owns its own dispatch flow. It does not import _execute_mutation or anything under rest_framework_services.viewsets.

tools/call:

  1. Look up the ToolBinding by name; reject unknown.
  2. Evaluate per-binding MCPPermission classes (AND-combined). Denial → 403 with WWW-Authenticate carrying any required scopes.
  3. If spec.instance_selector_spec is set (sister-repo 0.16), resolve the mutation target first: the nested RETRIEVE selector runs against {request, user} + the raw arguments (the MCP analogue of URL kwargs)
  4. the nested spec's own kwargs provider; queryset shaping applies and a QuerySet return is materialized via .first(). A missing row short-circuits to an isError: true tool result (type: "not_found").
  5. Validate arguments via spec.input_serializer (DRF Serializer, bare @dataclass auto-wrapped in DataclassSerializer, or None). spec.partial=True validates partially (and drops required from the advertised inputSchema); the resolved instance is threaded into the serializer DRF-style so instance-dependent validate() sees self.instance.
  6. Build a kwarg pool: {request, user, data} plus — when present — the resolved instance and the bound, validated serializer (both reserved seeds clients cannot poison; services opt in by declaring the parameter, e.g. to call serializer.save()).
  7. resolve_callable_kwargs(spec.service, pool)run_service(spec.service, kwargs, atomic=spec.atomic).
  8. Map failures along the MCP protocol-vs-tool boundary. The serializer rejecting the arguments shape stays a JSON-RPC -32602. A service raising on well-shaped input — ServiceValidationError or ServiceError — returns an isError: true tool result the model can read and self-correct from, with a JSON {"error": {"type": "validation_error" | "service_error", "message": ..., "detail": ...}} payload in content[0] (and no structuredContent, which is tied to the success schema). Chain steps add failedStep. Setting REST_FRAMEWORK_MCP["INCLUDE_VALIDATION_VALUE"] = True additionally echoes the offending arguments dict back under value — handy for debugging schema mismatches against opaque client SDKs, off by default because the dict can carry sensitive payloads.
  9. If spec.output_selector_spec is set, run its post-call pipeline: optionally re-fetch via output_selector_spec.selector (same kwarg-pool dispatch), then render through output_selector_spec.output_serializer with many= driven by output_selector_spec.kind. If output_selector_spec is None, the service's return value is passed through unchanged.
  10. Wrap as a ToolResult with OutputFormat-driven encoding for the human- readable content[0] block. structuredContent is always JSON.

RETRIEVE selector tools mirror the sister repo's read semantics: a QuerySet return is materialized via .first(), and a missing row is a not_found isError result — unless the spec sets allow_none=True (the nullable-resource contract), which renders a successful null result instead. LIST tools advertise a kind-aware outputSchema: a bare array schema unpaginated, the {items, page, totalPages, hasNext} envelope with paginate=True (enable pagination for a fully spec-compliant object-shaped structuredContent).

resources/read:

  1. Resolve URI through ResourceRegistry (returns binding + URI-template variables).
  2. Permission check.
  3. Build kwarg pool: {request, user, **uri_vars}.
  4. resolve_callable_kwargs(selector, pool)run_selector(...).
  5. Render through binding.output_serializer if set, then JSON-encode.

Sessions, headers, origins

The MCP 2025-11-25 transport requires:

  • MCP-Protocol-Version — the version the client speaks. Validated against REST_FRAMEWORK_MCP["PROTOCOL_VERSIONS"]. Missing → 400 except on initialize, which is allowed to omit it for the initial handshake. Some clients omit the header on every request; set REST_FRAMEWORK_MCP["REQUIRE_PROTOCOL_VERSION_HEADER"] = False to accept those by falling back to the first supported version. A present-but- unsupported version is still rejected either way.
  • MCP-Session-Id — issued by the server in the response to initialize. Required on every subsequent call. Unknown id → 404 (forces the client to re-initialize). Since 0.7 every session is bound to the authenticated principal that initialized it: a session presented by a different principal renders the same 404 as an unknown id (deliberately indistinguishable, so ownership cannot be probed). Sessions are stored in a pluggable SessionStore — by default the Django cache.
  • Origin — strict allowlist. Empty allowlist means "no cross-origin requests"; an empty Origin header is treated as same-origin and allowed. Configure via REST_FRAMEWORK_MCP["ALLOWED_ORIGINS"]. Use ["*"] only for dev.

All three verbs authenticate through the configured MCPAuthBackend before any session lookup, so an unauthenticated caller always sees 401 — session validity is never revealed without a credential.

DELETE /mcp/ with a session id terminates that session immediately — only for the principal that owns it. GET /mcp/ opens a server-initiated SSE stream for the caller's own session — available on async_urls only (WSGI's server.urls returns 405 on GET because SSE requires the event loop). See Async deployment for the wire details and MCPServer.notify(...) for pushing frames.

Output formats

Per the MCP tools spec, a tool result has both a content block list and an optional structuredContent:

  • structuredContent is always JSON-shaped — clients parse it directly.
  • content[0] is a text block whose payload is encoded per OutputFormat.
from rest_framework_mcp import OutputFormat

server.register_service_tool(
    name="invoices.list",
    spec=ServiceSpec(
        service=list_invoices,
        output_selector_spec=SelectorSpec(
            kind=SelectorKind.LIST,
            output_serializer=InvoiceOutputSerializer,
        ),
    ),
    output_format=OutputFormat.AUTO,   # JSON, TOON, or AUTO
)

AUTO picks per-payload — TOON for uniform list-of-objects, JSON otherwise. TOON is wrapped in a fenced ```toon block with a leading # format: toon marker so clients that don't parse it natively can still render it.

If TOON is requested but the optional extra is missing, the encoder falls back to JSON with a warnings.warn — a tool call never fails because an optional extra is absent.

Omitting structuredContent and outputSchema

structuredContent and outputSchema are independently toggleable. The MCP spec (2025-06-18, SEP-1624) imposes one asymmetric rule: a tool that advertises outputSchema must return conforming structuredContent. The reverse — emitting structuredContent without an outputSchema — is allowed.

Two server-wide settings, both default True:

  • REST_FRAMEWORK_MCP["INCLUDE_STRUCTURED_CONTENT"] — gates the structuredContent field on tools/call results.
  • REST_FRAMEWORK_MCP["INCLUDE_OUTPUT_SCHEMA"] — gates the outputSchema field on tools/list entries.

Per-tool overrides mirror them: include_structured_content and include_output_schema on register_service_tool, register_selector_tool, or their decorator forms. Each is tri-state — None (default) inherits the global, True/False force the behaviour regardless of the setting.

Common patterns:

  • Default: both True. Tools advertise their schema and return matching structured content. Spec-compliant and easiest for typed clients.
  • Drop only outputSchema: useful when the schema bloats tools/list responses but you still want machine-parsable results. Set INCLUDE_OUTPUT_SCHEMA=False; leave INCLUDE_STRUCTURED_CONTENT=True.
  • Drop both: useful when a downstream client echoes both fields back to the LLM (doubling token usage) or chokes on structuredContent. Set both to False. The text payload in content[0] still carries the full result (JSON-encoded by default, or TOON when requested).

The fourth combination — advertising outputSchema while suppressing structuredContent — violates the spec. It is rejected with ImproperlyConfigured at construction time (for explicit per-binding conflicts) or at request time (for setting-level conflicts), so the misconfig surfaces immediately rather than producing a non-compliant response.

Auth model

Two pluggable surfaces:

  • Backend (MCPAuthBackend Protocol). Authenticates a request and produces a TokenInfo. The transport calls authenticate(request) on every call; returning None produces a spec-mandated 401 with a WWW-Authenticate header built from www_authenticate_challenge(...). The /.well-known/oauth-protected-resource view delegates its payload to the backend's protected_resource_metadata().
  • Permissions (MCPPermission Protocol). DRF-style classes attached to a binding (permissions=[ScopeRequired(["invoices:write"])]). Evaluated after authentication; AND-combined; required scopes from any denying class are surfaced in WWW-Authenticate.

Authentication walks through the full picture, including the django-oauth-toolkit recipe and audience binding.