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 django_service_specs import SelectorKind
from rest_framework_services import SelectorSpec, ServiceSpec

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 shaping — select_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 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.

Project-wide seeds: pool_seeds=

A spec's kwargs provider is per spec. What every spec on a mount may read — a tenant, a locale, a clock — is a seed, registered once on the server as a drf-services PoolSeeds, the same registry dispatch_spec(pool_seeds=) takes:

from django_service_specs import DEFAULT_POOL_SEEDS


def tenant_of(*, user):
    # An anonymous caller has no tenant, and resolves to none rather than raising.
    return getattr(user, "tenant", None)


server = MCPServer(
    name="billing",
    pool_seeds=DEFAULT_POOL_SEEDS.extend(tenant=tenant_of),
)


def list_invoices(*, tenant):  # receives the resolved tenant on every call
    return Invoice.objects.filter(tenant=tenant)

A resolver is called through the keyword pool like every other provider, so it declares the entries it needs (user, request) and is resolved per call against the caller. The default is drf-services' own DEFAULT_POOL_SEEDS, which registers nothing, so a server that passes none behaves exactly as before.

A resolver must answer for every caller the mount admits, an anonymous one included whenever the auth backend lets one through, because the listing reads it too. Once any tool declares an operation condition, tools/list resolves the seeds for its caller (see the second bullet below), so a resolver that raises for a caller fails that caller's whole listing, not only its calls. lambda *, user: user.tenant is the form that does: an AnonymousUser has no tenant.

  • Every spec the server runs receives them. A service or selector tool over either transport and either era, call_tool / acall_tool, a task the worker runs, each step of a chain, and a resource's selector on resources/read.
  • The availability check asks with them too. tools/list, list_tools and unavailable_tools resolve the seeds into the pool they ask an operation condition against, so when=lambda *, tenant: tenant.can_close_books is asked the same question at the listing as at the call (A tool that cannot run now is not listed).
  • A registered name is reserved. A client argument of the same name is stripped rather than spread, so a caller cannot outrank the project's value — on a selector, where no validator stands in front of the spread, that is the difference between a seed and a client-controlled input. A chain step's seeds are laid over whatever its inputs returned, for the same reason.
  • A registered name is not asked for. A selector parameter named after a seed, on a selector tool or a service tool's target lookup, is left out of the tool's inputSchema and never required (Which selector parameters a client is asked for).
  • Registration agrees with dispatch. A callable declaring a seed with no default registers, since the seed is always supplied; a UrlKwarg, QueryParam or URI-template variable named after one is refused, since dispatch would strip it on every call.
  • One set per mount, with no per-call or per-tool override. What varies per call belongs in the resolver; a mount needing different seeds is a second MCPServer.

Prompt rendering and argument completion call bare callables rather than specs, and do not receive the seeds.

Which selector parameters a client is asked for

A selector tool advertises its selector's parameters, and a service tool its target lookup's (Dispatch flow, step 3). A signature read on its own cannot say which of them the client sends and which the server fills: get_invoice(*, pk) and outstanding(*, tenant) look alike, yet a client must send pk while tenant is a seed. So drf-services' reflection (0.55+) is told which names this server fills, from the sources the selector's pool is built from:

  • the server's pool_seeds and drf-services' own seeds (request, user, progress, ...);
  • a UrlKwarg that declares a default, which fills the parameter whenever the client leaves it out (one without a default is still the client's to send);
  • the keys a kwargs= provider returns, when its return annotation is a TypedDict (NotRequired keys included, since the provider owns them). A target lookup is read with its own provider, the one that resolves the target;
  • on a selector tool, the names spec_kwargs_provides= declares, and the fields its input_serializer fills when the client sends nothing (a writable field with a default, a HiddenField included), because the validated values are laid over the selector's params. A dataclass input (a bare @dataclass or a DataclassSerializer) validates into an instance, which is laid over them field by field as a dict is, so a dataclass field declaring a default or a default_factory fills its name too: the instance is built with it. call_tool does not run a selector tool's input_serializer, so there such a name is the caller's to send.

A filled name is not advertised. Every other parameter without a default is required, so get_invoice(*, pk) asks for pk with no marker; a default keeps a parameter optional, and an InputRequired marker still makes one required.

A key the provider may decline is not counted as filled. A TypedDict key whose value admits UnsetType (tenant: str | UnsetType) is one the provider may return as UNSET, which drf-services drops from the pool, so the caller's value is the one the selector reads. It stays advertised and is not required for lacking a default; an InputRequired marker on the parameter still makes it required. This server does not refuse a call that leaves it out, because only the assembled pool can say whether it arrived. drf-services can, once the provider has run: when the provider declines the key and the caller sent none, dispatch refuses the call before the selector runs, as a validation_error result keyed by the missing name (drf-services' InvalidArguments), where the selector used to raise TypeError:

{"error": {"type": "validation_error", "message": "Invalid arguments",
           "detail": {"tenant": ["This field is required."]}}}

The provider's keys are read by drf-services' provider_keys, the static half of the kwargs= contract whose runtime half its dispatch owns. Only a union's alternatives are read for UnsetType: a key whose value holds it inside a container, such as regions: list[str | UnsetType], always comes back as a list, so it is filled and hidden. A generic TypedDict is read with whatever binds its parameters, so -> Scope[str | UnsetType] may decline Scope's tenant: T, as the same key written out does.

A provider's value always outranks the client's, so no binding advertises a provider key the server fills: drf-services 0.58.0 retired ArgumentBinding.SPREAD_CALLER_WINS, the one binding under which the client's value won, and registering a tool with it raises ImproperlyConfigured. A seed is hidden under every binding, because drf-services strips a seed from the client's arguments whatever the binding. A service tool's target lookup is read author-wins under every binding, because drf-services lays the lookup's provider over the arguments last whatever argument_binding says, so its keys stay hidden.

A service tool runs one target lookup: the collection_selector_spec or the instance_selector_spec its spec declares, and that lookup's parameters are advertised. drf-services refuses, when the spec is built, an instance lookup beside a collection lookup and either beside many=True, because dispatch would never call it. So a many=True service declares no lookup, and an item of its list offers only the input serializer's fields.

The Pydantic-AI SpecToolset reads seeds, UrlKwarg defaults, provider keys and target lookups by the same rules, so one spec is asked for the same arguments on both routes. spec_kwargs_provides=, a selector tool's input_serializer and argument_binding= are registration options here with no counterpart in the toolset.

from typing_extensions import TypedDict


class Scope(TypedDict):
    tenant: str


def scope(*, request) -> Scope:  # annotated, so `tenant` is known to be filled
    return {"tenant": request.user.tenant}


def get_invoice(*, pk: int, tenant: str):  # inputSchema: {"pk"}, required ["pk"]
    return Invoice.objects.filter(pk=pk, tenant=tenant)

A provider whose return is not a TypedDict may fill any parameter, a plain -> dict or a lambda included, so beside one nothing is required for lacking a default: every parameter stays advertised and optional, and an InputRequired marker still makes one required. Annotate the provider to have the rest inferred.

Each annotation that does not resolve costs only what it says. A type imported only under TYPE_CHECKING is the usual cause: annotations are resolved at runtime, where that name does not exist. A return annotation that does not resolve leaves the provider untyped, read as though it returned a plain dict. A parameter's annotation is not read, so a type there costs nothing. A value of the returned TypedDict that does not resolve makes only its own key one the provider may decline, offered and not required, while the other keys stay filled and hidden.

A call that leaves out a required parameter is refused before the selector runs, with a validation_error result naming what is missing, keyed in detail the way a serializer keys a missing field, on every route (both transports and eras, call_tool and acall_tool):

{"error": {"type": "validation_error", "message": "Missing required argument(s): `pk`.",
           "detail": {"pk": ["This field is required."]}}}

The message is the server's conventions.missing_arguments, and the same one the Pydantic-AI toolset gives the same call. An input serializer's own refusal, a missing field of its own included, keeps "Invalid arguments".

It used to reach the selector as a TypeError, which answered HTTP 500 with JSON-RPC -32603 "Internal error" on the wire and raised from call_tool. The names checked are the ones the inputSchema requires of the selectors, read from the same reflection, so what a call is refused for is what the client was told. A service tool's own input serializer still answers for its fields, and a many=True service declares no target lookup to ask for anything.

The check runs after the permission and rate-limit answers (and, on a selector tool, after its input_serializer), as every other argument check does. So does the one a missing UrlKwarg(required=True) takes, which on a selector tool comes before its input_serializer runs: a call missing both is told about the URL kwarg. A caller tools/list hides a tool from is refused for the permission first, and never learns from a missing-argument answer that the tool exists.

A seed or a provider key the client sends anyway is still accepted by the unknown-argument check, even under UnknownArguments.REJECT. A seed's value outranks the client's under every binding, as the seeds section describes. On a selector tool a provider key's value outranks it under SPREAD_AUTHOR_WINS, the selector tool default. A service tool's target lookup reads its provider's value under every binding.

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. Declare a UrlKwarg in the spec's reads 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). Every registrar reads the spec's reads, so the declaration is made once for every transport.

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,
        reads=[UrlKwarg("project_pk", type="integer", description="owning project")],
    ),
)

The registrar's own url_kwargs= argument still works and is deprecated: it declares at the mount what the spec declares itself, and warns naming reads=[UrlKwarg(...)]. When the mount already knows the value, answer it with server_arguments instead of asking the model.

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, and registration refuses one that does: page / limit; the kernel's pool seeds user / data / instance / collection / result / queryset / progress; the names drf-services' DRF edge fills itself on every call, request / view / serializer (TOOL_EDGE_SEEDS); and any seed the server registers in pool_seeds. The same names are refused for a QueryParam.

Sharing a name with an input is how a route capture reaches a callable that takes it as a parameter, where that callable is handed view.kwargs: a selector tool's selector, and a service tool's target lookup and kwargs= provider. A service's own parameters are never handed the route (a service naming view is handed the view), so on a service tool registration refuses a UrlKwarg named like one of its inputs that would never receive the value: a spread service's own parameter, which was answered "Missing required argument(s)" on every call, or ran on its default, and an input_serializer field, which validates the arguments left once the URL kwarg is split out. A parameter the service and its target lookup both take is refused too: the lookup resolves the row the call named, and the service is still handed nothing under that name. A parameter the service's own typed kwargs= provider fills is exempt, because the provider reads view.kwargs; that is the first remedy the refusal offers, before taking the value as a parameter of the target lookup, and dropping the UrlKwarg.

On a selector tool that holds for its input_serializer as well. The validated values are laid back over the arguments, and a field bound with source="project_pk" would put a project_pk back, so every name a UrlKwarg declares is dropped from them again, whatever wrote it: a source=, a source="*" field, validate, or the field of the kwarg's own name coercing it. The route reaches the selector as sent, uncoerced, under every argument_binding, and a field of the same name supplies only its default, for a kwarg the call leaves out.

A selector is handed a capture only under a name it declares. Since drf-services 0.58.0 a bare or Any-annotated **kwargs receives the declared arguments and the seeds alone, so registration refuses a UrlKwarg on a selector with such a catch-all that names it nowhere: not as a parameter, not as a key of a typed **extras: Unpack[TypedDict], and not as a key its kwargs= provider fills or spec_kwargs_provides= claims. Declare the parameter, or take view and read view.kwargs. A selector with no catch-all at all is not refused, since the capture is then there for what reads view.kwargs, such as a permission class; dropping the **kwargs is the remedy when that is all it is for.

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: "Missing required argument(s): `project_pk`." with {"project_pk": ["This field is required."]} under detail, the answer a missing selector parameter gets, after the permission answer on every route. 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, where the selector names it (a parameter or a typed **extras key); a bare **kwargs is not handed it, and registration refuses that
Reaches view.kwargs no yes
Ranks above caller-supplied params no — it is caller input yes
Source of a required selector parameter at registration not needed — **extras names no parameter yes, when required=True or it declares a default

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. Beside a reflected key it adds the route and takes nothing away: the selector still receives the value in its typed **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 holds because the key is declared. A selector taking an untyped **kwargs names no key, and since drf-services 0.58.0 a catch-all is handed the declared arguments and the seeds only, so a UrlKwarg value would never reach it, and registration refuses the UrlKwarg: declare the key, or read view.kwargs.

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.

Query params: read-shaping values the serializer reads

UrlKwarg's sibling. A QueryParam is a model-supplied argument that lands in request.query_params instead of view.kwargs — the channel a serializer reads when it branches on the query string (django-restql field selection, a custom serializer keyed on ?expand=):

from rest_framework_mcp import QueryParam

server.register_selector_tool(
    name="invoices.list",
    spec=SelectorSpec(
        kind=SelectorKind.LIST,
        selector=list_invoices,
        output_serializer=InvoiceSerializer,
        reads=[QueryParam("query", description="django-restql fieldset, e.g. {id,number}")],
    ),
    paginate=True,
)

As with UrlKwarg, the registrar's query_params= argument is deprecated in favour of the spec's reads.

The value is advertised in the tool's inputSchema, popped from the arguments, and handed to build_offline_context(query_params=…). It never reaches the spec as an input, so the unknown-argument policy never sees it.

Three rules worth knowing:

  • Never required. A read-shaping param the spec runs fine without cannot be required, which is why QueryParam has no such flag — unlike UrlKwarg, where a missing route capture means the spec cannot run at all.
  • One name, one channel. Declaring the same name as both a QueryParam and a UrlKwarg raises at registration: a value is popped from the arguments once and routes to one place.
  • Not a name the tool offers as the call's own input. The value is popped before the spec runs, so an input of the same name never receives it: a required one is answered as missing for an argument the call carried, which a model resends until it runs out of retries, and a defaulted one runs on its default. So registration refuses a QueryParam named after any input the tool's inputSchema offers the caller, read from the reader that builds that schema: on a selector tool, a selector parameter or a filter_set field (which would silently stop filtering); on a service tool, an input_serializer field, one of the service's own parameters where a spreading binding with no serializer advertises them, or a parameter of its target lookup. The refusal says which of them takes the name, and offers three remedies in order: fill the parameter from request.query_params with a kwargs= provider whose TypedDict declares it, read the value there in the callable and drop the input, or drop the QueryParam.

A name the schema does not offer as the caller's, or whose reader receives the caller's value another way, is exempt:

  • on a selector tool, a QueryParam the spec itself declares in reads. The spec says where that parameter comes from, so it is not a shadow: the name is advertised once, as the selector's parameter, it is not popped, and the value reaches both the parameter and request.query_params, on every registration route. Only a query param declared at the mount (query_params=, or an agent_contract= carrying one) is refused under this rule;
  • a name a kwargs= provider declares it fills, by returning a TypedDict whose key cannot be UNSET, or that spec_kwargs_provides= claims. A provider reading request.query_params["status"] into the selector's status, beside QueryParam("status"), is how to route the value there, and the caller's value is served. Only for the callable that provider feeds: a service's provider fills its own parameters, never its input_serializer's fields or its target lookup's parameters, which a lookup's own kwargs= fills;
  • a key the server keeps from the call, one a callable in it marks NotClientInput, which no caller's value reaches;
  • on a selector tool, a field its input_serializer lays back: the serializer validates the arguments before the QueryParam takes its value, and the validated values are laid back over the popped arguments, so the selector receives the caller's value too. A dataclass input is laid back field by field as a dict is, so its fields exempt their names alike; a read_only field, or one whose source= names another attribute, is not laid back under its name and exempts nothing. call_tool does not run a selector tool's input_serializer, so there the value reaches only request.query_params. A service tool's serializer validates the arguments the QueryParam has already taken from, so its fields are never exempt.

A key the provider may decline with UNSET, and any key of a provider whose annotation does not say which keys it returns, is still refused: on a call where the provider does not fill it, the caller's value is the only one, and the QueryParam has taken it. A UrlKwarg may share a selector parameter's name, since its value reaches the selector through view.kwargs.

The MCP endpoint's query string is no longer a channel

Every dispatch path wraps the real Django POST to the MCP endpoint, so until 0.23.0 whatever query string a client appended to that URL — POST /mcp/?fields=all — showed up in request.query_params for every call on that connection. It was undeclared, client-controlled, identical for every call, and invisible to the model.

Every call site now passes query_params= explicitly, so the synthetic request's GET is always a value this package chose: the registered params for a tool that declares them, and empty everywhere else. If you were relying on that passthrough, declare a QueryParam for each value instead — it then arrives per call, advertised to the model, and validated at registration.

Resources and prompts get the closing and no registration knob: a resource URI is a locator, so per-call read-shaping belongs in its URI template, whose variables already route to view.kwargs.

QueryParam is drf-services' type, re-exported here for the same reason as UrlKwarg — one declaration, whichever transport carries it.

On a paged tool it shapes each item, never the page

With paginate=True the tool serves the {items, page, totalPages, hasNext} envelope, and the serializer never sees it: the envelope is built around the rows the serializer rendered, so the param reaches only a row. A selection therefore names the fields of one item, never the envelope's: fields=id,number rather than fields=items, or in django-restql's grammar {id, number} rather than {items{id, number}}. That is the easy mistake to make, because the outputSchema a model reads describes the envelope. So on a paged tool, each query param's description in the inputSchema ends with:

On a paged result it applies to each item in items, never to the page envelope (items, page, totalPages, hasNext).

A service tool and an unpaginated list have no envelope, and get no sentence. The words are the server's conventions.query_param_on_pages, which can change them or drop them; whether the sentence appears stays the server's call.

The package does not rewrite a selection written the other way. QueryParam is opaque on purpose — query, fields=id,name and expand=true are the same declaration to it — so correcting one would mean knowing every grammar a serializer might parse. What it controls is what the caller hears when the serializer refuses a value, and that depends on how the serializer selects:

  • Strict selection, a serializer that raises a ValidationError for a name it does not know (django-restql's default, for one). That comes back as an isError: true tool result of type validation_error, the same shape a service's ServiceValidationError produces (Dispatch flow), and its message names the argument, then gives the serializer's own words: "fields was rejected while rendering the result: Unknown field items." The transport adds nothing about any one library's grammar. On a paged tool the per-item sentence follows it. The detail is keyed under the argument's name; when a call supplied several read-shaping values it sits under non_field_errors and the message names them all, because nothing says which one the serializer refused. A model reads the result and retries with a corrected selection.
  • Tolerant selection, a serializer that drops the names it does not know, answers {items{id, number}} with a page of {}: one empty object per row, a well-formed result no transport can tell from a real one. It opts out of the error signal entirely, so tools an agent calls should select strictly — that is the setting in which the model can correct itself.

The tool result is reserved for the failures the caller caused. A render-time ValidationError (or drf-services' ServiceValidationError) becomes one only when the call supplied a non-null value for at least one declared QueryParam. A value seeded from QueryParam.default because the caller omitted the argument does not count: a bad default is a configuration bug. With nothing supplied the serializer refused on its own, no retry could change the outcome, and it stays a server error — over HTTP a JSON-RPC -32603 reading Internal error under a 500, with the exception in the log; from acall_tool, the exception itself. Any other exception raised while rendering is a server error whatever the caller sent: an AttributeError in a serializer is a bug, not a bad argument.

Server-supplied arguments: server_arguments=

A UrlKwarg asks the model for a route value. When the mount already knows it - a server scoped to one project, a deployment that always reads one tenant - asking is both wasted tokens and a door left open: the model can send another project's id. server_arguments= answers the argument for the caller instead. It takes a ServerArguments from django_service_specs, on register_service_tool, register_selector_tool, the service_tool and selector_tool decorators, as a register_specs override and on a ToolDefinition handed to register_tools. Each route checks it the same way:

from django_service_specs import ServerArguments

server.register_selector_tool(
    name="list_document_vendors",
    spec=vendors_spec,  # reads=[UrlKwarg("project_pk"), UrlKwarg("draw_request_pk")]
    server_arguments=ServerArguments(
        values={"project_pk": 7},  # this server's project, always
        withheld={"draw_request_pk"},  # never the caller's; the default applies
    ),
)
  • The names leave the tool's inputSchema and its required list, on tools/list and wherever else the schema is built, so the model is never asked for them. A bound value can fill a read the spec marks required=True, or a selector parameter with no default.
  • A caller's value for one is dropped before anything reads it: the permission classes, the strict check of a required UrlKwarg, the missing-argument check and the spec all see the server's value. A value an elicitation answer carries is dropped the same way. The server logs the tool's name and the dropped argument names at WARNING, never the values; a caller restating the bound value is not logged.
  • A bound value reaches every channel the name is declared on: a bound UrlKwarg lands in view.kwargs, so a provider or permission scoping by it reads the server's value. A withheld name is absent, so the callable runs on its own default.
  • It is checked at registration. A name the spec does not declare, a value its declared type refuses, a name the server fills as a pool seed, and a withheld name whose callable has no default each raise ImproperlyConfigured naming the tool. A binding built by hand and passed to server.tools.register is checked there too.

The values are static for the life of the binding. A value computed per request - the project the signed-in user belongs to - is not something server_arguments carries: compute it in a kwargs= provider, or refuse the call in a permission.

Only withheld takes an argument away from the caller. The deprecated mount knobs (url_kwargs=, query_params=, agent_contract=) replace what a tool advertises for their kind, but dispatch still reads the spec's own reads, so a read the knob leaves out is unadvertised and still delivered when a caller sends it.

A list payload: many=True service tools

A ServiceSpec with many=True validates its input as a list and hands the service that list as data. Over HTTP the list is the request body. A tools/call's arguments is always a JSON object, so here the list travels under one argument, named by the spec's many_argument and items unless the spec says otherwise:

def create_invoices(*, data):
    return [create_invoice(**item) for item in data]


server.register_service_tool(
    name="invoices.bulk_create",
    spec=ServiceSpec(
        service=create_invoices,
        input_serializer=InvoiceSerializer,  # describes one item
        many=True,
        many_argument="invoices",  # optional; the default is "items"
    ),
)

Every service dispatch, tools/call on both transports and call_tool alike, passes drf-services many_as_argument=True, which reads the list out of that argument. For a spec without many the flag does nothing. The tool advertises the argument as an array of the item schema, taken from drf-services' spec_to_json_schema, so any minItems or maxItems the list serializer declares are advertised too:

{
  "type": "object",
  "properties": {
    "invoices": {"type": "array", "items": {"type": "object", "properties": {"number": {"type": "string"}}}}
  },
  "required": ["invoices"],
  "additionalProperties": false
}

What a caller should know:

  • Nothing travels beside the list. Any other argument is refused, whatever the tool's unknown_arguments says, with {"non_field_errors": ["Unexpected argument(s): 'note'."]}. That is why the arguments object is advertised closed on every such tool. URL kwargs and query params are the exception: they are taken out of the arguments before dispatch, and advertised beside the list.
  • unknown_arguments applies inside each item. Each item is checked as a single-item tool's arguments are, and each item's own additionalProperties says whether an undeclared key in it is refused.
  • Errors are keyed under the argument, and item errors by index. An invalid second item is a validation_error result whose error.detail is {"invoices": {"1": {"amount_cents": ["Ensure this value is greater than or equal to 0."]}}}. JSON object keys are strings, so the index arrives as "1"; with INCLUDE_VALIDATION_VALUE on, error.value["invoices"][1] is the item it names. drf-services gives item errors this shape on every Django REST framework version, and an undeclared key refused inside an item is keyed by its index the same way. A call with no list is refused as {"invoices": ["This field is required."]}.
  • The result is the service's list. It is rendered through the output serializer as a list, no output re-fetch runs, and the outputSchema is an array of the item schema. structuredContent is that bare array, as for every unpaginated list result this server returns, not an object wrapping it.
  • A service cannot ask the user a question. An elicitation answer returns as an argument, which would sit beside the list and be refused, so a many=True service raising AdditionalInputRequired can never receive its answer.

Registration refuses two declarations that would make every call fail: a UrlKwarg or QueryParam named as the list's argument, and a SPREAD_* argument_binding. drf-services refuses a third, a target lookup beside many=True, when the spec is built. A chain refuses to inherit a many=True first step's input_serializer as its own. Troubleshooting has each message.

A tool that needs arguments beside the list can still declare the list as a named field of its input serializer, items = InvoiceSerializer(many=True), and loop over data["items"] in the service. Its item errors come back keyed by index too from Django REST framework 3.18; below 3.18 they are a list holding an empty object for each valid item, {"items": [{}, {"amount_cents": [...]}]}.

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 django_service_specs import SelectorKind
from rest_framework_services import 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.

A URI-template variable named after a value the read seeds itself is refused at registration with ImproperlyConfigured: one of the kernel's reserved pool seeds (user, data, instance, ...), request, which resources/read seeds beside them, or a name the server's pool_seeds= registers. The read lays those values over the template's variables, so the selector would never receive the segment the URI carried.

What a resource cannot carry, and why registration refuses it

Only those five fields are carried. resources/read dispatches the bare selector callable through run_selector: a ResourceBinding holds the callable that was lifted out of the spec, not the spec itself, so there is no spec left to dispatch and nothing to apply the rest against.

A SelectorSpec that sets any of the following eleven is refused at registration, with a ValueError naming the ones it set:

Field What would not happen on a read
preconditions The state and database rules never run. The read proceeds.
filter_set The queryset is not filtered. Every row is returned.
select_related No join is planned; the read runs the extra queries.
prefetch_related Same, for the multi-valued relations.
annotations The .annotate(**...) call is skipped, so annotated names are missing from every row.
extend_queryset The dynamic shaping hook is never invoked.
allow_none The RETRIEVE nullability contract is not applied to a None result.
output_serializer_context The provider is not resolved, so a serializer reading that context sees only the baseline.
progress_reporter Nothing is reported; a read is a single response with nowhere to stream to.
metadata The consumer-owned mapping is not attached, so a permission or downstream reader looking for it finds nothing.
affordances No row carries its answers. The renderer that adds them is not the one a read renders through, so a client is never told what it can do to a row.

Refusing is the deliberate choice over the two alternatives. Carrying them is not available — the read path has no spec to carry them on. That leaves dropping them silently, and a gate that does not run on one transport while holding on every other is worse than either: the same filter_set that scopes a tenant over HTTP would return every row over resources/read, and nothing in the response says so. A ValueError at registration is loud, early, and lands on the person who can act on it.

Two ways forward, both of which keep the behaviour:

  • Register the spec as a selector tool instead. Selector tools dispatch through dispatch_spec, which honours all eleven. This is the default answer, and it costs the URI addressing rather than the behaviour.
  • Fold the behaviour into the callable. A selector that does its own filtering and its own precondition checks carries them wherever it is dispatched, resource reads included.

Worth knowing while you are choosing a shape rather than after: in a codebase whose specs routinely declare shaping or gates, this means most specs can only ever be tools. The resource surface fits the plain, addressable read — a single object by URI, no filtering, no gate beyond permission_classes — and the richer the spec, the more likely a tool is the honest home for it.

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 a service's pool. Without an input_serializer nothing does, so a service requiring data is refused at registration. A selector is never handed data or serializer, under any binding: drf-services' selector dispatch seeds neither and strips both from the spread. So a selector tool declaring an input_serializer under BUNDLE is refused at registration, because the validated payload has no way to reach the selector, and so is a selector requiring data or serializer under any binding. A selector takes its validated fields as parameters of their own, under a spreading binding.
  • 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, status="draft")). spec.kwargs(...) wins on conflict so author-declared invariants beat client input.
  • ArgumentBinding.SPREAD_CALLER_WINS — retired in drf-services 0.58.0, and refused at registration with ImproperlyConfigured: a caller cannot override what the server's spec declares, because a call has one pool in which an author's value always outranks the caller's. Use SPREAD_AUTHOR_WINS for a service whose own parameters are the caller's input, or BUNDLE for one validated data payload; where the caller's value should win for a key, leave that key out of spec.kwargs(...).
  • ArgumentBinding.AUTO — resolve per spec type (service → BUNDLE, selector → SPREAD_AUTHOR_WINS).

Reserved transport-pool seeds (the kernel's user / data / instance / collection / result / queryset / progress, and any seed the server registers), the request, view and serializer drf-services' DRF edge fills itself, and, on a LIST selector tool, the pipeline keys (page / limit) are stripped from the spread regardless of mode so clients can't poison transport-controlled state. So trust mode (no input_serializer under a spreading binding) does not count a reserved seed as one the caller supplies: a callable requiring a seed that nothing on the transport fills is refused at registration. For the same reason, registration refuses a LIST selector's parameter named page or limit, and a selector parameter named by one of the tool's QueryParams, unless the tool's input_serializer lays it back: a field of that name that is not read_only and is bound to its own name (no source elsewhere), on a plain Serializer, a DataclassSerializer or a bare @dataclass alike, whose validated value is laid back over the stripped arguments. A required positional-only parameter (def by_status(status, /)) is refused at registration as well, for service and selector alike: dispatch passes every argument by keyword, so nothing can fill it. A RETRIEVE selector tool cannot paginate, so nothing takes page or limit from its selector, which may declare either and receives the caller's value, as on the Pydantic-AI route.

For the same reason registration refuses an input_serializer field named after one of those seeds (RESERVED_POOL_SEEDS, TOOL_EDGE_SEEDS or a seed the server registers) wherever the fields are spread: on a selector tool under any binding, and on a service tool under SPREAD_AUTHOR_WINS. The seed keeps its own value in the spread, so the field was advertised and its value never reached a parameter of its name. The refusal names the tool and the field. Rename the field, or keep its name on the wire and bind it with source= to a parameter no seed takes; a service can also take it inside data under BUNDLE (or AUTO, which bundles a service). A read_only field and a HiddenField take nothing from the caller and register. In trust mode a required parameter named result or queryset is refused with the reservation named, since no argument can reach it: rename it, or give it a default, and a defaulted one is not advertised and runs on its default. A callable that takes request, user, progress or view is handed it on every call, so a required parameter of one of those names registers under any binding; the refusal for a parameter with no source lists them among the sources.

  • unknown_arguments= — how arguments keys outside the binding's declared field set are handled.
  • UnknownArguments.REJECT (default) — the validator rejects unknown keys with an isError validation_error result naming them under detail.non_field_errors, and the outer inputSchema advertises "additionalProperties": false.

    The closed schema is advertised exactly where the runtime refuses an undeclared name. A service tool hands its unknown_arguments to drf-services' dispatch as registered, with or without an input_serializer, and the inputSchema lists the set dispatch declares for the spec under the tool's argument_binding:

    1. the input_serializer's fields, when there is one;
    2. the keys of the target lookup dispatch calls (the collection_selector_spec when declared, else the instance_selector_spec; a many=True spec has neither);
    3. without an input_serializer, under SPREAD_AUTHOR_WINS, the service's own parameters, which are its input there. A parameter without a default is required, unless the spec's kwargs= provider may fill it.

    Less every name the server fills: the pool seeds, the ones registered with pool_seeds=, every key the service, a precondition or the target lookup marks NotClientInput, which dispatch drops from the caller's input, every key the target lookup's own kwargs= provider says it fills, which dispatch lays over the arguments under every binding, and, under SPREAD_AUTHOR_WINS, every key the service's kwargs= provider says it fills, which dispatch lays over the caller's spread. REJECT still admits a key one of the providers fills and serves the provider's value, so leaving it out only stops asking for a value the call would replace. A key either provider may decline with UNSET stays listed, not required. A lookup parameter a precondition hides is therefore not advertised, though the lookup names it.

    An input_serializer field named like a parameter of the target lookup's selector is refused at registration, under every binding, naming the tool and the field. drf-services withholds a name the lookup reads from the serializer's data, so the row is the lookup's to choose and never the body's, and every call was refused for the field as missing. Leave the lookup key out of the input_serializer, or declare it read_only=True.

    So def archive_task(*, instance, reason) behind a pk lookup, under SPREAD_AUTHOR_WINS, advertises pk and reason with "additionalProperties": false, and a call adding notify_owner is a validation_error naming it. Bundled, it advertises pk alone, because nothing then reads reason by name, and refuses reason the same way.

    The schema stays open ("additionalProperties": true) where dispatch serves every name: a target lookup taking a bare **kwargs or carrying a filter_set, or a spread service taking a bare **kwargs, which then receives every key the caller sent. Telling a client a typo'd field will be refused, while the server takes it and throws it away, is the worse failure. A **kwargs whose annotation does not resolve at runtime (an Unpack[...] of a TypedDict imported under TYPE_CHECKING) leaves the set unknown, so REJECT cannot be enforced against it and registration raises ImproperlyConfigured. The decorator form meets the same refusal for a TypedDict declared below the decorated function: the decorator registers while the module is still importing, before the class exists. Declare it above the function, or register the tool once the module has imported.

    Earlier releases downgraded REJECT before dispatch for a service with no input_serializer and advertised its schema open to match, so an argument the service never declared was dropped without a word. That downgrade is gone. To keep a call that sends undeclared keys working, register IGNORE, which drops them.

    Selector and chain bindings enforce the closed set in this package, against their own input serializer, so they are closed under REJECT with one and open without one, where nothing checks the keys. - UnknownArguments.PASSTHROUGH — retired in drf-services 0.58.0, where an argument the spec does not declare never reaches a callable. Every tool registrar (register_service_tool, register_selector_tool, register_chain_tool, and the decorators, register_specs and register_tools through them) refuses it with ImproperlyConfigured naming the tool, so a server carrying it does not start. Declare the argument (a reads entry, a serializer field or a parameter) and pass IGNORE or REJECT. - 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, and a tool is left out of tools/list whenever an operation-scope affordance refuses it right now (see below). Setting always_listed=True keeps the binding visible through both as a discovery aid; the permission and the affordance still gate the actual invocation.

A tool that cannot run now is not listed

A service's affordances say when the operation is possible. A condition written as a callable is answered against the pool's seeds — user, request, progress, and any the server registers with pool_seeds= — and never against the call's arguments, so when it is unmet every call of the tool is refused, whatever the client sends. tools/list therefore asks each such condition, through drf-services' unmet_operation_affordance, and leaves the tool out while any is unmet:

from django_service_specs import Affordance
from rest_framework_services import ServiceSpec

server.register_service_tool(
    name="ledger.close_period",
    spec=ServiceSpec(
        service=close_period,
        affordances=[
            Affordance(
                code="books_closed",
                reason="The books for this period are already closed.",
                when=lambda user: user.ledger.is_open,
            ),
        ],
    ),
)
  • Asked on every tools/list, with the request and user a call would use. The condition sees the same kind of request a call's condition sees — the DRF Request built around the transport's own, method POST, with no arguments and no query string — and the token's user. It is not gated by FILTER_LISTINGS_BY_PERMISSIONS: that setting is off by default because a permission may read arguments that do not exist at list time, and a condition answered against the seeds cannot read arguments at all.
  • A condition on the row is never asked here. One written as an ORM expression (when=~Q(status="shipped")) has no row at list time; it is skipped without a query and goes on being answered per object at the call. A tool declaring only those is always listed.
  • Declaring nothing costs nothing. A listing builds one pool, and only once a listed tool declares a condition on the operation; each such condition then runs once per listing, so keep it cheap. One that raises fails the listing, as it would fail the call.
  • Selector tools are never left out. A SelectorSpec's affordances names other operations to project onto its rows; it is not a condition on the read.
  • A chain is left out when any service step's condition is unmet. Every step runs, in order, and nothing can skip one, so such a step refuses every call that reaches it, and a call that does not reach it has already failed earlier.
  • Resources and prompts are unaffected. They are reads, and neither holds a spec for an affordance to sit on.
  • The listing is advisory; the call is the authority. tools/call looks the tool up by name without consulting any listing, so a client holding an older one gets the refusal it would have got anyway: an isError result carrying the affordance's code (see Dispatch flow), not an unknown tool.

Keeping clients current. Nothing is cached on the server — each tools/list asks again — so the question is when a client asks:

  • A condition that flips on an event (an admin closes the books): announce it once the change has committed, and a client re-lists.

    from asgiref.sync import async_to_sync
    from django.db import transaction
    from rest_framework_mcp import NotificationKind
    
    transaction.on_commit(
        lambda: async_to_sync(server.notify_list_changed)(NotificationKind.TOOLS_LIST_CHANGED)
    )
    

    This reaches a client only when a subscription broker is configured and the client speaks the modern era, the same two conditions listChanged is advertised on, and has opted in to toolsListChanged on its subscription. Without a broker it is a no-op, so it is safe to call unconditionally.

  • A condition that flips with the clock (a period ends at midnight): nothing announces it. The client sees it on its next tools/list, which one honouring the catalog's ttlMs may put off for up to CATALOG_CACHE_TTL_MS; a call made in between is refused with the code.

A listing that asked any condition is served with cacheScope: private, because it was answered for this caller and is no longer identical for every caller.

Saying why a tool is missing. A listing only leaves a tool out; nothing on the wire says which tools were left out or why. An in-process consumer can ask: MCPServer.unavailable_tools names each tool the listing omits with the Affordance that omitted it, from the same pass, so a model can be told the operation exists and why it cannot run now rather than guessing (see Full in-process transport).

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.

A service tool also reads its spec's declared ServiceSpec.idempotent: idempotent=True advertises "idempotentHint": true, a declared False advertises false, and the default None (nothing declared) leaves the hint off, which a client reads as the MCP default of false. A chain derives no idempotentHint, because two idempotent steps in sequence need not make an idempotent operation.

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,
    # A non-destructive mutation. Its idempotency is declared on the spec
    # (ServiceSpec(idempotent=True)), so it needs no hint here:
    annotations={"destructiveHint": False},
)

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

An agent is the common case, and it has a shorter path still: this package's sister repo, djangorestframework-pydantic-ai, exposes the same specs to a pydantic_ai.Agent directly. See Connect a Pydantic-AI agent for that choice and for driving this server over the wire instead.

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, before the target lookup, which judges each class's has_permission against the request and view the call runs with, and an on_target_resolved hook that runs only the object-level half, each class's has_object_permission, on the resolved target, so has_permission is asked once per class. That request and view are laid out as DRF lays out an HTTP request: the URL kwargs in view.kwargs, the declared QueryParam values in request.query_params, the rest of the arguments in request.data, the tool's name as view.action, and request.auth set to None. acall_tool and the wire handlers build theirs the same way, and also judge the binding's wrapped copy of each class first, against a stand-in built from the same values, so there a call asks has_permission twice per class. 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. Refused input (an unexpected argument, an input_serializer rejection, a filter value the spec's FilterSet refuses), a service raising ServiceValidationError / ServiceError, and a missing required instance come back as isError results, the ones the wire serves; a denied permission 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, the same availability filter, and the same opaque-cursor pagination the HTTP transport uses. alist_tools is its twin for an event loop, running the listing in Django's thread-sensitive executor.
  • 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 django-pydantic-agent's drf-mcp bridge consumes to run drf-mcp tools in-process with HTTP-equivalent semantics.

A consumer that lists once and follows availability per step. An agent loop offers its model tools on every step, and whether a tool can run may change between steps. Re-listing each step would rebuild every schema to learn a few booleans, so the transport splits the two:

page = server.list_tools(user=request.user, request=request, include_unavailable=True)
# ... build the model's tool definitions from page["tools"] once, then each step:
missing = await server.aunavailable_tools(user=request.user, request=request)
offered = [tool for tool in definitions if tool.name not in missing]
for name, affordance in missing.items():
    ...  # tell the model: `name` exists but cannot run now, because affordance.reason
  • list_tools(..., include_unavailable=True) (and alist_tools) lists every tool the caller may see and asks no condition, so a tool that is unavailable when the consumer lists still has a definition when it becomes available. The listing asked nothing about the caller, so its cacheScope is decided as if no tool declared a condition. The wire has no such parameter.
  • unavailable_tools(*, user, request=None, scopes=None) maps each tool a plain list_tools would leave out to the Affordance that left it out — its code and its reason, the sentence written for people and models alike. It is the same pass over the same tools, so a name is reported exactly when the listing omits it: never an always_listed tool, never one FILTER_LISTINGS_BY_PERMISSIONS hides from this caller, and for a chain the first unmet step in order. It asks again on every call; nothing is remembered.
  • aunavailable_tools is its twin for an event loop. A server none of whose tools declares a condition on the operation answers {} without leaving the loop, since that is decided from the declarations alone and a consumer asks every step.

Like the listing, the answer is advisory: acall_tool still enforces every condition, so a tool that became unavailable after the step began is refused with its code.

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.
documents_spec = SelectorSpec(
    kind=SelectorKind.LIST,
    selector=list_loan_documents,
    reads=[
        UrlKwarg(
            name="loan_pk",
            type="string",
            required=True,
            description="Primary key of the *loan*, not the borrower.",
        )
    ],
)
server.register_selector_tool(
    name="list_loan_documents",
    spec=documents_spec,
    description="List the documents filed against a loan.",
)

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.

What the server writes for you: conventions=

A few sentences a model reads are not in any registration, because they explain how this server behaves rather than what one tool does. The server writes them, and MCPServer(conventions=...) changes the words. It takes an AgentConventions, per server, so two servers in one project can speak to different readers:

from rest_framework_mcp import AgentConventions, MCPServer

server = MCPServer(
    name="invoicing",
    conventions=AgentConventions(
        handle_line="Pass ids to other tools; never show them to the user.",
    ),
)

Each field is one sentence, and lands in exactly these places:

Field Where it appears None
handle_field_description The outputSchema description of a handle field that declares none of its own (Hide plumbing from the model). The field is left undescribed.
handle_line The line appended to the description of a tool whose output carries a handle. The line is dropped, with its Identify records by `<label>`. prefix.
query_param_on_pages The end of a read-shaping query param's description on a paged tool, and the end of the isError message when a value it supplied is refused while rendering (On a paged tool). Dropped from both.
missing_arguments The message of a call refused for leaving out an argument the selectors require, or a UrlKwarg(required=True) (Which selector parameters a client is asked for). Refused with ImproperlyConfigured, as is an empty or whitespace-only string: it is the result's whole message.

AgentConventions() is the default, and a server given none uses it. Change one field and the others keep the package's wording, so a later correction to a sentence you did not override still reaches you.

The server still decides whether a sentence appears: a tool with no handle gets no handle line, and an unpaginated tool gets no scope sentence, whatever the conventions say. A field changes what it says. The wording reaches every route a server answers on: both transports in both eras, call_tool, acall_tool, list_tools, and a task's worker.

Every field is a str.format template, rendered wherever it lands whether or not it has a placeholder, so a literal brace is written twice, {{ or }}, in every field alike: handle_line="Ids look like {{this}}." reaches the model as Ids look like {this}.. Only missing_arguments has a placeholder, {names}, filled with the missing names sorted, each in backticks, joined with ", "; the default reads Missing required argument(s): `pk`.. A field the server could not render raises ImproperlyConfigured naming it when the conventions are built, rather than inside the first call that reaches it: a placeholder the field does not accept, a single brace, a format spec or conversion its value cannot take ({names:q}, {names!z}), or a value that is neither a string nor None. This is the rule the Pydantic-AI toolset applies to its own AgentConventions, so a template one transport accepts reads the same on the other.

Two of these sentences state facts: that a selection applies to each item and never to the envelope, and that the envelope's keys are items, page, totalPages and hasNext. Wording you supply owns keeping those facts true.

The default handle description and missing-argument message are word for word the ones the Pydantic-AI toolset in djangorestframework-pydantic-ai writes for the same specs, and the handle line gives the same advice as its instructions, so a spec served both ways tells the model the same thing on both routes until one side overrides it.

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
Spec fields honoured all of them five; the other ten are refused at registration

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.

That last row is the one that decides a shape, so it is worth reading before committing to a resource surface: What a resource cannot carry, and why registration refuses it.

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.

ResourceEncoding.BLOB is the binary case — a PDF, an image, a generated spreadsheet. The selector returns bytes and the body is base64-encoded into the spec's blob field instead of text; the two are mutually exclusive on a contents entry, so a client reads whichever is present.

server.register_resource(
    name="invoice_pdf",
    uri_template="invoices://{pk}.pdf",
    selector=SelectorSpec(kind=SelectorKind.RETRIEVE, selector=render_pdf),
    mime_type="application/pdf",
    encoding=ResourceEncoding.BLOB,
)

Non-text tool results

A tool result's content array is the same content vocabulary — text, image, audio, a link to a resource, or an embedded resource. As with resource bodies, a binding declares what it returns rather than having it guessed, because a base64 string and a text body are indistinguishable by inspection:

server.register_service_tool(
    name="charts.render",
    spec=ServiceSpec(service=render_chart, atomic=False),  # returns bytes
    content_kind=ToolContentKind.IMAGE,
    content_mime_type="image/png",
)

ToolContentKind.TEXT (the default) renders JSON per the binding's output_format and mirrors it in structuredContent. IMAGE and AUDIO take the media itself — bytes, or a str already in base64 — and carry no structuredContent or outputSchema, since neither can describe a PNG; registering one alongside those is refused rather than ignored.

ToolContentKind.RESOURCE_LINK is usually the better answer for anything large. The tool returns a mapping with uri and name (or a list of them), each becoming a link the client can read through resources/read — so no bytes ride on the tool-result path and the client fetches only what it decides it wants. structuredContent is kept for this kind: the links are ordinary JSON.

server.register_selector_tool(
    name="invoices.attachments",
    spec=SelectorSpec(kind=SelectorKind.LIST, selector=list_attachments),
    content_kind=ToolContentKind.RESOURCE_LINK,
)
# → [{"uri": "invoices://1.pdf", "name": "Invoice 1", "mimeType": "application/pdf"}, ...]

A payload that doesn't match the declared kind comes back as an isError result naming the binding — the same treatment an oversized result or a missed deadline gets, so the client always has a well-formed response to read.

Argument completion

Clients offer autocompletion while a user fills in a prompt argument or a URI template variable. Register a completer per argument and this server answers completion/complete:

server.register_prompt(
    name="code_review",
    render=review_prompt,
    arguments=[PromptArgument(name="language")],
    completions={
        "language": lambda value: Language.objects.filter(name__startswith=value).values_list(
            "name", flat=True
        )
    },
)

Completers are dispatched through the same kwarg-pool machinery as everything else, so declare whichever of value (the text typed so far), arguments (siblings the client has already resolved, also spread by name), request and user you need. Return any iterable — a list, a generator, a queryset: the handler slices it to the spec's cap of 100 and sets hasMore rather than draining it, so a queryset reads 101 rows, not the table.

Resource templates work the same way, keyed by the {variable} name:

server.register_resource(
    name="invoice",
    uri_template="invoices://{pk}",
    selector=SelectorSpec(kind=SelectorKind.RETRIEVE, selector=get_invoice),
    completions={"pk": recent_invoice_ids},
)

A completer keyed to an argument the binding doesn't have is refused at registration — the failure mode otherwise is an empty dropdown with nothing in the logs.

A completion is a read

Completion runs the binding's permissions and rate_limits before the completer. Without that, a resource a caller may not read would still answer "which ids exist?" one keystroke at a time.

The completions capability is advertised only when something is actually completable, which is the same rule tools, resources and prompts follow: a capability is a promise, and a server that declares one and then answers -32601 is worse off than one that never declared it.

Icons

Tools, resources, resource templates, prompts and the server itself can carry display icons. This package only emits them — fetching, sanitising and rendering are the client's problem, and the spec puts a long list of MUSTs on that side.

server.register_service_tool(
    name="invoices.create",
    spec=spec,
    icons=(Icon(src="https://cdn.example.com/invoice.png", sizes=("48x48",)),),
)

MCPServer(name="billing", website_url="https://example.com", icons=(...,))

src must be https: or a data: URI — clients are required to reject anything else, so a http://localhost/... icon is refused at registration rather than shipped as an icon nobody will ever see. The server's own identity (title, description, websiteUrl, icons) can also come from the SERVER_INFO setting.

Streaming progress

A long-running tool can report how far it has got, and the client sees it as it happens. Declare progress on the service or selector and call it:

def export_invoices(*, data, progress):
    rows = list(build_rows(data))
    for index, row in enumerate(rows):
        write(row)
        progress(
            index + 1, total=len(rows), message="writing rows", meta={"com.example/file": data.path}
        )

Nothing about the registration changes — progress is a kwarg-pool seed from djangorestframework-services, so it arrives like request and user do, and the same service runs unchanged over HTTP where nobody is listening.

The client opts in by putting a progressToken in the request's _meta:

{"jsonrpc": "2.0", "id": 1, "method": "tools/call",
 "params": {"name": "invoices.export", "arguments": {},
            "_meta": {"progressToken": "abc123"}}}

That token is the only trigger. With it, the response becomes text/event-stream carrying notifications/progress frames followed by the result; without it, a single JSON object as before — a stream whose only event is the final response costs a connection and buys nothing.

Era-independent. _meta.progressToken sits in the same place in 2025-11-25 and 2026-07-28, so a legacy client gets streamed progress on exactly the same terms as a modern one.

ASGI only, for streamed progress. A sync WSGI view cannot yield while its dispatch is still running, so server.urls keeps answering application/json. That stays spec-legal — a single JSON object is always permitted. Use server.async_urls if you want progress on the wire mid-call.

The same service reports fine under WSGI when it runs as a task: there the reports go to the task record rather than to a stream, and the client polls for them.

What the server does with your reports

progress must increase A non-increasing report is dropped. The spec makes increase a MUST, so forwarding one would put this server in violation on the service's behalf.
Frames are capped MAX_PROGRESS_NOTIFICATIONS (default 1000) per request. The spec asks both parties to rate-limit; a per-row reporter over a large table is a flood. Past the cap reports are dropped — the dispatch is untouched and the result still arrives.
meta rides in _meta Structured detail goes where the protocol puts extension data, so message stays prose. Namespace your keys.
Closing the stream cancels Client disconnect is the cancellation signal in 2026-07-28. It cancels the await, not the work — a thread parked in a driver's socket read is not interruptible by asyncio, the same caveat DISPATCH_TIMEOUT carries.

Permissions are checked before the stream opens

A streaming response commits its HTTP status before the handler runs, so a permission denial found inside could only ride as an in-stream error inside a 200 — losing the 403 the authorization spec makes normative. The permission stack therefore runs once at the transport before the stream is opened, so a denied call still gets 403 and a WWW-Authenticate challenge. Rate limits stay inside the handler: consuming one is not idempotent, and a rate-limit rejection was already a 200 with the detail in the body.

Server-pushed notifications: subscriptions

A client that wants to know when something changes opens subscriptions/listen — a POST whose response stream stays open — and names exactly what it wants:

{"method": "subscriptions/listen",
 "params": {"notifications": {
     "resourceSubscriptions": ["invoices://42"],
     "toolsListChanged": true}}}

Every type is opt-in, and that is a MUST: the server must not send a notification type the client did not ask for. So an absent field is a refusal, not a default — and a subscription that was granted nothing is acknowledged and then closed, rather than held open to deliver silence.

The first frame is always notifications/subscriptions/acknowledged, carrying the subset the server agreed to honour. That is where a client learns it will not get something — a resource it may not read, a promptsListChanged on a server with no prompts — instead of waiting for an event that was never coming. Every frame after it carries the subscription id in _meta, so one client can run several subscriptions and tell them apart.

Wiring it up

from redis.asyncio import Redis
from rest_framework_mcp.subscriptions.redis_subscription_broker import (
    RedisSubscriptionBroker,
)

server = MCPServer(
    name="invoices",
    subscription_broker=RedisSubscriptionBroker(Redis.from_url("redis://…")),
)

There is no default broker, deliberately. A server that quietly got an in-process one would advertise support and then deliver nothing the moment a second worker existed.

The in-memory broker is single-worker only

InMemorySubscriptionBroker is for development and tests. The write that triggers a notification lands on whichever worker served that request, and the subscriber's stream is parked on a different one — so past one worker it delivers to nobody, and the failure looks exactly like "the resource never changed". RedisSubscriptionBroker (in the [redis] extra) is the deployable one.

A subscription occupies a worker for as long as it is open. One parked ASGI task per subscriber — inherent to the wire format, since this method exists to replace the old GET endpoint. Two settings bound it:

SUBSCRIPTION_MAX_SECONDS (1 h) The server closes the stream gracefully and the client re-subscribes. Also the re-authorization interval: a subscription's permissions are checked once, when it opens, so this is what stops a principal whose access was revoked from receiving change signals indefinitely.
MAX_CONCURRENT_SUBSCRIPTIONS (100) Per worker. Without it an authenticated caller can exhaust the pool by opening streams in a loop. Past the cap a new subscription is refused with 503 / -32603 rather than queued.

Watching a task instead of polling it

A client that holds a task id can subscribe to it and stop calling tasks/get:

{"method": "subscriptions/listen",
 "params": {"notifications": {"taskIds": ["786512e2-…"]}}}

Every status change pushes notifications/tasks carrying the whole task — identical to what tasks/get would have returned at that moment — so a missed notification costs nothing and polling stays genuinely optional rather than a fallback you have to implement anyway.

A task is watchable only by the principal that created it: its status is as revealing as its result, since knowing someone else's export finished is knowing they ran one.

One exception to "refused entries are dropped": a client asking for taskIds without declaring the io.modelcontextprotocol/tasks extension gets a JSON-RPC error, not a quiet omission. The spec requires it, and it is not an existence oracle — the error turns on what the client declared, not on anything about the tasks it named.

notifications/progress and notifications/message are MUST NOT on this stream. Progress for a task is the task's own status; the progress channel belongs to the request-scoped stream, which a task by definition outlived.

Publishing a change

Declare it on the tool that does the writing — the server is already in the call path, so this is the trigger that needs no discipline to remember:

server.register_service_tool(
    name="invoices.create",
    spec=ServiceSpec(service=create_invoice),
    invalidates=("invoices://{pk}", "invoices://"),
)

Templates use the same {var} syntax as a resource's uri_template, rendered against the result merged with the call's arguments (result wins — after a write it is authoritative; the arguments cover a delete, whose result carries nothing). The arguments are the ones the tool ran with: on a retry, its inputResponses merged over the arguments as sent, so an answer naming another {project_pk} announces the project the service ran on. Publishing happens after the transaction commits, and a call that came back isError publishes nothing.

Its boundary is real, and is why the explicit trigger exists too. It fires for calls that go through this server and for nothing else — a management command, a Celery job or an admin edit changes the same rows and announces nothing. For those:

from django.db import transaction

transaction.on_commit(
    lambda: async_to_sync(server.notify_resource_updated)(f"invoices://{invoice.pk}")
)

Publish after the transaction commits. Inside transaction.atomic() you would be announcing a change that may still roll back, and a subscriber that re-reads immediately sees the old value — worse than no notification at all. invalidates= does this for you; notify_resource_updated is yours to place.

Matching is exact, not by prefix. Publish the concrete URI and the collection URI if you want watchers of the collection to hear about it. A prefix rule would match invoices://1 against invoices://11 and would miss a tenant-scoped scheme entirely, so the publisher says what it means instead.

Calling notify_resource_updated on a server with no broker is a no-op rather than an error, which is what makes it safe to call unconditionally from a service.

Who may watch what

Subscribing to a resource requires the same permission as reading it. Otherwise a subscription would be a side channel around resources/read: a caller denied the body could still learn every time it changed, and when something changes is often the more sensitive signal. A task may be watched only by the principal that created it.

A refused entry is dropped from the acknowledgement, not turned into an error — matching the spec's own handling of unsupported types, and avoiding an oracle that would let a caller tell "no such resource" from "not yours".

Long-running work: tasks

Streaming progress keeps the client informed while it waits. Tasks remove the waiting. A task-eligible tool answers tools/call with a durable handle instead of a result; the work happens in a queue worker, and the client polls tasks/get until the status is terminal.

This is the io.modelcontextprotocol/tasks extension, and it is modern-era only — the client declares support on every request, which a legacy client has no way to do.

Wiring it up

Two pieces. An executor says where work goes, and a store is where tasks live between the request that created one and the worker that finishes it:

from celery import shared_task


@shared_task
def run_mcp_task(task_id: str) -> None:
    server.run_task(task_id)


class CeleryExecutor:
    def enqueue(self, task_id: str) -> None:
        run_mcp_task.delay(task_id)


server = MCPServer(name="invoices", task_executor=CeleryExecutor())

That is the whole seam: one method taking one string. Nothing in this package imports Celery, and an RQ or Dramatiq call, or a ThreadPoolExecutor.submit in a test, satisfies it just as well. Passing task_executor= also builds a DjangoCacheTaskStore namespaced to the server, so the common case is one argument; pass task_store= to override it.

InMemoryTaskStore is not deployable

Unlike an in-memory session store, which is merely restart-fragile, an in-memory task store is broken by design: the web worker that creates a task and the worker that finishes it are different processes. The result is written into a dict the web process cannot see, and every poll answers "unknown task" until the client gives up. It fails silently and looks like a hung job. Use it for tests and a single-process runserver.

Opting a tool in

The extension makes the server the sole decider — a client cannot ask for a task — so the choice lives on the binding, next to every other per-tool knob:

server.register_service_tool(
    name="reports.generate",
    spec=ServiceSpec(service=generate_report),
    task_policy=TaskPolicy.OPTIONAL,
)
task_policy A client that declared the extension A client that did not
FORBIDDEN (default) runs inline runs inline
OPTIONAL gets a task handle runs inline
REQUIRED gets a task handle -32021, missing capability

FORBIDDEN is the default, so every tool registered before this existed behaves exactly as it did. Reach for OPTIONAL when a tool is slow but can still finish inside a request, and REQUIRED when it genuinely cannot — where running it inline would only hit the dispatch deadline.

What a client sees

{"resultType": "task", "taskId": "…", "status": "working",
 "ttlMs": 86400000, "pollIntervalMs": 5000}

Then tasks/get until completed / failed / cancelled, honouring pollIntervalMs. tasks/cancel signals intent to stop; tasks/update answers a task that is parked on input_required.

Mcp-Name must carry the taskId on all three methods — the extension requires it so a gateway can route a follow-up to the instance holding the task's state. A mismatch is -32020, exactly as for tools/call.

Progress from inside a task

The same progress seed works, and the service body is unchanged:

def export_invoices(*, data, progress):
    for n, row in enumerate(rows, 1):
        progress(n, total=len(rows), message="Exporting")

Run inline, that streams notifications/progress. Run as a task, there is no connection to stream on — so the reports land on the task record instead, and the client reads them from the statusMessage it was already polling:

{"taskId": "…", "status": "working", "statusMessage": "Exporting (142/500)"}

This is what makes progress worth declaring at all under the task model. Without it the seed is live-connection-only, and the operations most in need of progress — the ones promoted to tasks because they run long — are exactly the ones it would silently do nothing for. Nothing about the service, the spec, or the registration distinguishes the two paths.

statusMessage is the whole channel The protocol Task has no numeric field. progress / total are kept on the record for logs and admin visibility, but a polling client only ever receives the rendered string.
meta is dropped On the inline path it rides in the notification's _meta. A task has no notification and no free-form slot, so the argument is accepted and discarded rather than given a home no client could read.
No notifications/tasks per tick That notification means the status changed; progress is movement inside working. Subscribers would get a firehose describing a task that has not moved. Poll at pollIntervalMs.
A finished task is never rewritten A late report from a worker still unwinding cannot move lastUpdatedAt on a completed task or make a cancel look like it did not take.
A store that is down does not fail the work The report describes the operation; it is not the operation. Write failures are swallowed.

Things worth knowing

Permissions run twice Once before the task is created, so a denied call never reaches the queue and still gets its 403; once again in the worker, against the same rebuilt token. The task stores the caller's scopes for exactly this reason.
Rate limits run once Consuming a quota is a side effect, so it is charged when the client asks and not again on replay. Charging both would halve every configured limit.
A tool error is completed, not failed The spec is explicit. A ServiceError produces a well-formed result carrying isError: true, and that is a task that finished. failed means the task machinery broke.
There is no tasks/list Deliberate in the spec: without sessions there is nothing to scope a listing by. Ids are high-entropy and ownership is checked on top — an id belonging to someone else answers exactly as one that never existed.
A queue that is down fails the task The record is already durable when enqueue raises, so the handle comes back with status: "failed" and the reason in statusMessage. The client finds out through the channel it was already using.
Redelivery runs the work once Queues deliver at least once; a task is claimed as the worker starts. Best-effort, not a lock — an idempotent service is still the right thing to write.

Asking the user: elicitation

Some calls cannot be decided by the arguments alone. "Delete everything matching this filter" is safe at three rows and alarming at nine thousand, and the service only finds out after it has looked.

A service says so by raising, and that is the whole of its involvement:

from django_service_specs import AdditionalInputRequired


def delete_rows(*, data):
    doomed = rows_matching(data)
    if len(doomed) > 100 and not data["confirmed"]:
        raise AdditionalInputRequired(
            f"{len(doomed)} rows match. Confirm to proceed.",
            schema={"confirmed": {"type": "boolean"}},
        )
    ...

confirmed is an ordinary field on the tool's input serializer. The server turns the raise into a question, the client puts it to the user, and the answer arrives back as that ordinary field — so the service reads it exactly as it would read anything else a caller sent.

What goes over the wire

{"resultType": "input_required",
 "inputRequests": {"additionalInput": {
   "method": "elicitation/create",
   "params": {"mode": "form",
              "message": "9412 rows match. Confirm to proceed.",
              "requestedSchema": {"type": "object",
                                  "properties": {"confirmed": {"type": "boolean"}},
                                  "required": ["confirmed"]}}}},
 "requestState": "…"}

This is a success, not an error. input_required is a second legal shape for a tools/call result, inside a 200, and a client that treats a non-complete resultType as a failure will never retry.

The client collects the input and retries the original call — a new request, a new JSON-RPC id — carrying inputResponses and the requestState verbatim:

{"name": "rows.delete", "arguments": {"count": 9412},
 "inputResponses": {"additionalInput": {"action": "accept",
                                        "content": {"confirmed": true}}},
 "requestState": "…"}

Nothing is held between the two requests. That is the point of the pattern — it replaced server-initiated requests precisely so the retry can land on a different process, behind a load balancer that knows nothing about the first one. The service is not resumed; it runs again from the top, with the answer present. A service that did irreversible non-transactional work before raising will do it twice, which is a reason to raise early and to keep atomic=True.

An answer can move the route. It is merged over the arguments with no limit on its keys, so it can name a URL kwarg the call sent or left out. The tool's permissions judge the arguments as sent first, and judge again the route the merge produces whenever it is not exactly that one, before the rate limit is charged, the target is looked up or the service runs. Exactly means by value and by type: true or 1.0 answered for 1 is another route, since a lookup through a CharField reads "True" or "1.0". A caller refused on either route is not charged; a declined or cancelled answer is, as any other call is.

requestState is attacker-controlled

It leaves the server, passes through the client, and comes back. So it is signed (HMAC, via django.core.signing) and carries three things that are checked before any of it is believed:

Bound to Rejects
the authenticated principal a token that leaked and is presented by someone else
a digest of the original call a confirmation given for a harmless call, replayed onto a destructive one
a timestamp (INPUT_REQUEST_TTL_SECONDS) anything captured from a log or a proxy and used later

All four failures — including a bad signature — answer identically: the state is ignored and the user is asked again. Distinguishing them would turn the endpoint into an oracle, and an honest client cannot use the distinction anyway, since it is forbidden from looking inside the token.

Signed is not encrypted: a client can decode it. What is in there is the caller's own principal id, a digest of the caller's own request, and the answers the user at that client just typed. Form mode is documented for non-sensitive values for exactly this reason — do not ask for a password.

More than one question

requestState accumulates the answers, so a service that wants a confirmation and then a reason works: the client sends only the latest round's response, and the earlier answers ride in the token. MAX_INPUT_ROUNDS bounds it, so a service whose condition an answer never clears fails instead of volleying the same question at a user forever.

Clients that cannot be asked

The spec forbids sending an elicitation to a client that did not declare the capability. Rather than a protocol error, such a call gets an ordinary isError result carrying the service's message and the schema:

{"error": {"type": "input_required",
           "message": "9412 rows match. Confirm to proceed.",
           "requestedInput": {"confirmed": {"type": "boolean"}}}}

A model reading that can simply pass confirmed: true on its next call, which is the same outcome by a shorter route. This is what a legacy-era client sees, a URL-only client, and a task worker replaying a call with nobody at the other end.

Which of the two a real client gets is decided by its MCP SDK, not by anything you configure — Connect a Pydantic-AI agent names the versions where the question is asked and where it degrades.

Boundaries

Service tools only A chain tool degrades instead of asking. MRTR completes a call by re-running it, and a chain that asked at step three would run steps one and two twice on the retry. A selector is a read — one that needs the user to decide something is a tool wearing the wrong registration.
Form mode only The spec's other mode hands the user a URL to complete out of band. Nothing here knows how to mint one; a service that needs it has a redirect to build, not a schema to declare.
Top-level fields only requestedSchema is a restricted subset: strings, numbers, booleans and enums, no nesting. A schema outside it raises ImproperlyConfigured at the moment it would have been sent, rather than shipping something the client must reject.
tools/call only The spec also permits input_required on prompts/get and resources/read. Neither is implemented: both dispatch a bare callable with no failure channel, and a prompt render or resource read that needs to stop and ask is not a shape this package has a caller for.
Sampling and roots Not built. Both are Deprecated as of 2026-07-28; elicitation is the only input worth asking for.

Protocol eras

This server speaks two revisions of MCP at once, on one endpoint, and picks which by reading the request:

Legacy (2025-11-25, 2025-06-18) Modern (2026-07-28)
Opens with initialize nothing — every request stands alone
State a session, in Mcp-Session-Id none
Version negotiated once declared per request
Detected by absence of the marker below _meta["io.modelcontextprotocol/protocolVersion"]
Push notifications none subscriptions/listen
Tasks, elicitation not offered available

A modern request carries its version, client identity and client capabilities in params._meta, and mirrors selected body fields into headers so gateways can route without parsing JSON:

POST /mcp/ HTTP/1.1
Mcp-Protocol-Version: 2026-07-28
Mcp-Method: tools/call
Mcp-Name: invoices.create

Those headers are validated against the body. A mismatch is 400 with JSON-RPC -32020 — not pedantry: a load balancer that routes on the header while the server executes the body is a confused deputy, and the check is what closes it. Mcp-Name may arrive Base64-wrapped (=?base64?…?=) when the value would not survive as a plain ASCII header; it is decoded before comparison.

Two more modern-only status codes matter, because clients use them to work out which era they are talking to: an unknown method is 404 with a -32601 body (the body is what distinguishes this endpoint from a server that does not host one), and an unsupported version is 400 with -32022 listing what is supported. GET and DELETE return 405 to a caller naming a modern revision — the SSE stream and session termination were both removed there.

Keeping legacy is a deliberate choice

Legacy clients have no fall-forward mechanism: drop the era and every client that has not migrated is stranded with nothing but an error string. The cost of carrying both is one branch at the transport edge — everything below it is era-agnostic, with two exceptions.

resources/read answers a missing URI with -32002 for a legacy caller and -32602 for a modern one, because the revision that retired -32002 also told clients to keep recognising it, so neither value is safe to send to both.

The advertised capabilities follow the caller, not the server. A capability is a promise, and two of them can only be kept for a modern client: every push flag (subscribe, and the three listChanged fields) describes a notification that leaves through subscriptions/listen, and extensions is not a field on the legacy ServerCapabilities at all. So a legacy initialize is told about neither, however the server is configured — and server/discover, which both eras may call, answers according to the version the caller declared.

resources/subscribe is deliberately not implemented. It is optional in 2025-11-25 and gone from 2026-07-28, where the schema says SubscriptionFilter.resourceSubscriptions "replaces the former resources/subscribe RPC" — which this server does implement. Building the legacy RPC would mean a cross-process session→URI registry serving only the era being carried for compatibility rather than grown.

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 and constructs and enforces the CSP from what you declared. Neither is implemented here, and neither should be.

The ui/* postMessage bridge is not on that list, and reading it as though it were is the single most expensive misunderstanding this extension produces. The bridge has two ends. The host runs its end; the document runs the other, and the document is yours. That second end is a mandatory startup handshake whose failure modes are all silent — see Writing the bridge yourself.

So the package ships the view end, inlined into a document it composes for you:

server.register_ui_resource(
    name="invoices_table",
    uri="ui://invoices/table.html",
    body_template_name="mcp/invoices_table.html",  # markup only
)

body_template_name= takes a template holding the view's markup, styles and scripts, and wraps it in a document carrying build_app_document's shell and bridge. The other three sources — template_name=, html=, selector= — hand you a whole document to fill, bridge included.

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 callable returning one, which must take no arguments — one that declares a parameter is refused at registration, because the read path would fill it from a pool carrying request and user, and a view that varies by caller is exactly what the exemption below assumes cannot exist).

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.

Writing the bridge yourself

body_template_name= exists so you do not have to read this. Everything below is what template_name=, html= and selector= hand back to you along with the whole document, and it is written as a checklist because every item on it has cost somebody at least one debugging round.

The thing that makes this expensive is not difficulty, it is silence. A broken bridge raises nothing, logs nothing, and leaves a clean resources/read in the server log. The host keeps the frame hidden until the view says it is ready — so an error message the view writes into the document is itself invisible. There is no diagnostic surface until the handshake completes, which makes the first rule the one that matters.

  1. The view MUST send ui/notifications/initialized — on any reply to ui/initialize, including an error, and even if the host never replies at all. The spec requires that "the Host MUST NOT send any request or notification to the View before it receives an initialized notification", and says nothing about what a view should do when ui/initialize fails. The natural shape — treat the error as fatal, explain in the document, return — produces a frame that stays hidden forever with the explanation sealed inside it. Send the notification first, then read the result. A timeout that sends it anyway costs four lines and covers the silent host.

    The extension's own SDK does not do this

    App.connect() awaits ui/initialize inside a try and sends initialized only after a successful result, so an error reply jumps past it to the catch. A view built on the SDK inherits this.

  2. The view MUST push ui/notifications/size-changed. The host never asks. A silent view is left at whatever the host guessed. Measure an element that is an ancestor of the overflow, not documentElement: content inside an overflow-x: auto container does not widen its scrolling ancestor, so anything above that container measures as wide as the frame it already has and the view asks for the width it was just given. A wrapper element is not enough on its own — a view that scrolls a wide table internally has to measure the content inside the scroller. Note that a host may fix the width and flex only the height, and refuse the width silently.

  3. ui/initialize params are appInfo, appCapabilities and protocolVersion. The spec's prose and its worked example disagree here — the pinned 2026-01-26 revision's example sends capabilities against normative text requiring appCapabilities. Follow the generated schema, which is what hosts are built from.
  4. The view MUST answer host requests it does not implement, with a JSON-RPC -32601 rather than silence. An unanswered request leaves the host waiting on a promise that never settles.
  5. Write <html>, <head> and <body> out. HTML5 infers all three and browsers do not care, but the sandbox loads this document as raw HTML and applies a CSP to it, and a sandbox injecting anything into <head> has nowhere to put it when the element is implied. Every reference view does it.
  6. Inline everything. A view that imports its runtime from a CDN needs that origin in csp.resource_domains before it will boot, on every host — and the extension's own advice is to inline.
  7. End the URI in .html. Not a spec requirement; every reference implementation does it, and the one public report of a host resolving a view and rendering nothing also used an extensionless URI.

A last one that is not about the bridge: a host may prefetch and cache a ui:// document, and is not obliged to honour ttlMs: 0 — which is what RESOURCE_CACHE_TTL_MS defaults to. A stale view will disprove a fix you actually made. The recipe carries the hashed-URI workaround.

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), before any rate limit is charged. Denial → 403 with WWW-Authenticate carrying any required scopes. The spec's own permission_classes are among them, each wrapped in a DRFPermissionAdapter and judged against a stand-in built from the same values as the dispatch view: the URL kwargs in view.kwargs, the declared QueryParam values in request.query_params, the rest of the arguments in request.data, and the tool's name as view.action. Then judge each class's has_permission again, against the dispatch view itself and the request the call runs with: before the target lookup in step 3, before a missing argument is named and, on a selector tool, before its input_serializer runs. A denied caller is answered -32006 alike for a row that exists and one that does not, and is not told which argument it left out. Only the object-level half, each class's has_object_permission, runs later, on the row step 3 resolves. A chain tool judges every step's wrapped classes up front, each under its step's alias with the chain's arguments as request.data, and each step judges its classes again against its own view before its inputs run and its target is looked up, then only the object-level half on the target it resolves.
  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"). Because the lookup arrives as an argument, the tool's inputSchema advertises it: the target selector's parameters (and a filter_set's fields) are reflected the same way a selector tool's own are, so task_by_pk(*, pk) puts pk beside the input serializer's fields. A collection_selector_spec is advertised the same way; drf-services refuses a spec declaring both lookups when it is built, because dispatch would never call the instance one. An input field or UrlKwarg of the same name keeps its property, and a lookup parameter is required when it has no default and the server does not fill it (Which selector parameters a client is asked for), whatever the serializer says about the name: the lookup reads the raw arguments, not the validated ones. A call leaving one out is refused here, before the lookup runs, with a validation_error result.
  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. A many=True spec validates the list under its many_argument instead, item by item, with no target to resolve (A list payload).
  6. Build a kwarg pool: {request, user, data} and the server's pool_seeds, 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. Refused input — the serializer rejecting the arguments, an unexpected argument under UnknownArguments.REJECT, a missing argument a target lookup requires (step 3), or a service raising ServiceValidationError — and a service raising ServiceError return 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). A serializer's refusal carries the message "Invalid arguments" and its field-keyed errors under detail. A missing argument refused before the serializer ran (step 3) carries "Missing required argument(s): `pk`." instead, the server's conventions.missing_arguments, over the same keyed detail. The MCP spec files input validation under tool execution errors in every protocol version this server serves, and keeps JSON-RPC -32602 for an unknown tool and a request that fails the CallToolRequest schema (a non-object arguments, say), which is all tools/call answers with it. Chain steps add failedStep. A call refused by one of the spec's declared affordances raises drf-services' ActionUnavailable, and its error object also carries that affordance's code — {"type": "service_error", "message": "The books are closed.", "code": "books_closed"} — so a client branches on the stable name while the model reads the sentence. type is still "service_error", and any other ServiceError (a ServiceConflict raised by hand, say) carries no code key at all. 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. A LIST re-fetch serves a bare array — a service tool never paginates — and the tool's outputSchema advertises {type: array, items} to match. With no selector there is no re-fetch, and the nested kind still decides: a LIST presents the service's own return as the list it declares, served and advertised as an array, and drf-services refuses a return that is not a set of rows (a mapping, a str, None) as the author's error, after the service has run. A RETRIEVE presents it as one object. A many=True spec never re-fetches: the service's list is rendered as a list and advertised as an array.
  10. Wrap as a ToolResult with OutputFormat-driven encoding for the human- readable content[0] block. structuredContent is always JSON.

A serializer that rejects while rendering, in step 8 or in a selector tool's render, joins step 7's validation_error arm only when the caller shaped the render with a read-shaping param (Query params). Anything a dispatch raises that no arm maps is a server fault, and the transport answers it as one: a JSON-RPC -32603 whose message is Internal error, carrying the request's id, under HTTP 500 so proxies and error-rate monitoring still count it. The exception's own text never reaches the client; it is logged at ERROR with its traceback (Observability). A progress-carrying stream has already committed its 200, so there the same error arrives as the stream's last frame.

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. A selector reports a missing row by returning None or an empty queryset, or by raising DoesNotExist or Django's Http404 (what get_object_or_404 raises); all four answer the same way, with no ERROR log line, because none is a fault. A spec that sets allow_none=True (the nullable-resource contract) makes the miss a successful result with nothing to present instead (see below). A LIST tool's selector raising Http404, usually a parent lookup, is a not_found result too (<tool>: not found), and allow_none does not turn it into an empty list; DoesNotExist escaping a LIST selector is a fault in the selector, and stays one. 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). The array schema is not special to selector tools: wherever a result renders as an unpaginated list — a service tool whose output_selector_spec declares a LIST, re-fetched or not, or a chain whose output step is either kind of LIST — the tool advertises the same bare array, because that is what it serves.

A result with nothing to present is {}, and the schema admits it. Three successful calls render to nothing: an allow_none RETRIEVE that finds no row, a single-row service tool whose output re-read selector finds none (drf-services materializes the re-read with .first(), so a re-read that filters out the row the service just archived yields nothing, whatever the nested spec declares), and a single-row service with no re-read that declares ServiceSpec(allow_none=True) and returns None. A single-row service with nothing to re-read that returns None without that declaration is served {} as well, but against an outputSchema that does not admit {}: allow_none=True is the declaration that admits it (see the warning below). MCP requires structuredContent to be an object, so all three are served as "structuredContent": {} with a text block of {}, on every tool kind and every entry point (the wire, call_tool / acall_tool, a task). And because a server advertising an outputSchema must return structured content that conforms to it, the schema of a tool that can present nothing keeps its object root and its properties while its required list moves beside the empty object. Which tools can is drf-services' can_present_nothing, the question its own output schema asks before admitting null, so this server admits {} exactly where drf-services admits null. Four kinds of tool advertise this shape: an allow_none RETRIEVE selector tool, a single-row service tool whose output_selector_spec has a selector, a single-row service tool declaring allow_none=True with no selector to re-read, and a chain whose output step is one of those three:

{
  "type": "object",
  "properties": {"id": {"type": "integer"}, "title": {"type": "string"}},
  "anyOf": [{"required": ["title"]}, {"maxProperties": 0}]
}

A full row satisfies the first branch and {} the second, while a non-empty row missing a required field satisfies neither. Every other tool keeps a strict root required: a RETRIEVE without allow_none, a LIST result (allow_none or not, because a list is empty rather than absent), a many=True service, and a service whose output_selector_spec names only an output_serializer, with no selector, so the service's own return renders, unless it declares allow_none=True. That last one is most service tools, and loosening their schemas would turn every row field optional for each client generating types from them. Leaving structuredContent off the result instead was ruled out: a client is entitled to reject a successful result that has an outputSchema and no structured content, and the TypeScript SDK does.

A service returning None with no re-read must declare it

A service with no re-read selector whose function returns None is served "structuredContent": {}. Declare ServiceSpec(allow_none=True) and the schema admits it. Undeclared, the {} is still served, because drf-services presents an undeclared None rather than refusing it, but against a schema that requires the row's fields, so a client validating the result rejects it: nothing else the spec declares tells this service from one that always returns a row. Declare it, or return the row the service acted on.

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.

If you turned outputSchema off because your serializer returns a subset

That is the right instinct against the wrong cause, and it costs the model the schema for every other tool too. A serializer that drops fields at runtime does advertise a shape its results do not match — but only when the pruning happens somewhere this server cannot see.

Prune with field markings instead. The advertised schema is generated from the same declaration the payload is projected through, so the two cannot disagree: a field marked hidden leaves both. Then verify it rather than trusting it — assert_tool_result_conforms checks a real result against the schema its own tools/list entry advertised, types and formats included.

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.