Skip to content

Dispatch (stable surface)

The stable dispatch surface: the primitives an alternate transport (such as djangorestframework-mcp-server) builds on instead of re-implementing the "how to call a service / selector" rules. Every symbol here is importable from the top-level package, follows semantic versioning, and will not move or change signature within a major version. Blessed in 0.17, which also removed the private _compat packagerun_service / arun_service now live in services/ and is_async at the package root; downstreams re-point their imports here when they bump past 0.17.

Deliberately not part of this surface: dispatch_mutation_for_spec and dispatch_selector_for_spec. They are view-coupled orchestrators (they take a view, read its URL kwargs, and walk the get_<action>_*_kwargs hook chains). The transport-neutral spec dispatcher that composes the leaves below — dispatch_spec — is documented first.

Transport-neutral dispatch

The single execution path a non-HTTP transport drives: hand a spec, the acting user, and a flat params mapping, get back a DispatchResult to format for your wire. No view, no request required.

Each primitive comes as a sync / async pair — dispatch_spec / adispatch_spec, render_spec_output / arender_spec_output. The async member is not a thin alias: a spec's callables (selector, service, kwargs provider, extend_queryset, filter_set, serializer-context providers) are written once for both transports and so are never async def, and rendering walks the ORM. The async members run all of that in Django's thread-sensitive executor, which is what keeps SynchronousOnlyOperation out of an async transport. Await the pair together; don't mix an a-prefixed call with a sync one on the same result.

dispatch_spec

dispatch_spec

dispatch_spec(
    spec: ServiceSpec[Any, Any, Any] | SelectorSpec[Any, Any],
    *,
    user: Any,
    params: Mapping[str, Any] | list[Any],
    request: Any = None,
    view: Any = None,
    success_status: int | None = None,
    argument_binding: ArgumentBinding = ArgumentBinding.AUTO,
    unknown_arguments: UnknownArguments = UnknownArguments.IGNORE,
    on_target_resolved: TargetGuard | None = None,
    progress: ProgressReporter | None = None,
    view_hooks: ViewHooks | None = None,
    instance: Any = UNSET,
    filter_data: Mapping[str, Any] | None = None,
) -> DispatchResult

Execute spec without a DRF view, returning a DispatchResult.

The single transport-neutral execution path: a caller hands the flat params mapping (the role request.data / query_params / URL kwargs play on HTTP) plus the acting user, and gets back the resolved domain value to format for its wire. No pagination, ordering, or output rendering happens here — those are transport concerns; render the result with render_spec_output.

  • A ServiceSpec runs the mutation flow: resolve the target via instance_selector_spec (from params) → validate input_serializer → run the service → re-fetch through output_selector_spec → result. A missing instance yields kind="not_found".
  • A SelectorSpec runs the read flow: invoke the selector → apply queryset shaping (select_relatedfilter_set) → for RETRIEVE materialize via .first() and honour allow_none / not-found; LIST returns the shaped + filtered queryset.

Every argument below the acting user is optional, and the defaults reproduce the pre-policy behaviour exactly.

Parameters:

Name Type Description Default
spec ServiceSpec[Any, Any, Any] | SelectorSpec[Any, Any]

The ServiceSpec or SelectorSpec to execute.

required
user Any

The acting user, seeded into every callable's pool.

required
params Mapping[str, Any] | list[Any]

The flat client input — a list on a many=True spec.

required
request Any

Forwarded only to user callables that declare it (extend_queryset, the context providers, kwargs); a pure non-HTTP caller passes neither this nor view.

None
view Any

As request, plus its kwargs (a route's captures, seeded by build_offline_context(kwargs=…)) are spread into the selector / target pools — the off-HTTP counterpart of the HTTP extra_url_kwargs=view.kwargs, authoritative over params on a conflict, below the spec.kwargs provider.

None
success_status int | None

Overrides the mutation status hint (else spec.success_status, else 200).

None
argument_binding ArgumentBinding

Whether client input lands as a single data bundle or is spread as individual kwargs, and how it ranks against the author's kwargs. AUTO resolves per spec type (service → bundle, selector → spread). Meaningless on a many=True spec — the service receives the whole list as one data argument — where a non-default value raises ValueError rather than being ignored.

AUTO
unknown_arguments UnknownArguments

Strictness about params keys outside the spec's declared set: IGNORE (drop), REJECT (raise), PASSTHROUGH (forward to the callable). Honoured per list element on a many=True spec.

IGNORE
on_target_resolved TargetGuard | None

Hook invoked with the resolved mutation target before the service runs. Pass enforce_permissions directly for object-level permissions; the core itself stays authz-agnostic.

None
progress ProgressReporter | None

The transport's own progress sink, fanned together with the one spec.progress_reporter declares.

None
view_hooks ViewHooks | None

The calling DRF view's resolved hook-chain layers. HTTP-only.

None
instance Any

A target the caller resolved itself, skipping instance_selector_spec. None is a supplied value (a create), which is why the default is a sentinel.

UNSET
filter_data Mapping[str, Any] | None

The data the filter_set reads, wherever one can be declared — a selector's own filtering, a service's instance_ / collection_selector_spec target lookup, and its output-selector re-fetch. On the target lookups that makes it a scoping channel: a filter_set there narrows the row a mutation may reach, and one bound to the wrong mapping validates clean (every filter field is optional) and narrows nothing. Only meaningful when params is not the filter source: off HTTP one flat mapping is usually both, so this stays None, whereas over HTTP the body validates and the query string filters, and merging them would let a query parameter satisfy a serializer field.

None

The

Type Description
DispatchResult
DispatchResult

— value, kind, status, and on the mutation path the service's own return,

DispatchResult

resolved instance and data.

Raises:

Type Description
TypeError

spec is neither a ServiceSpec nor a SelectorSpec.

adispatch_spec

adispatch_spec async

adispatch_spec(
    spec: ServiceSpec[Any, Any, Any] | SelectorSpec[Any, Any],
    *,
    user: Any,
    params: Mapping[str, Any] | list[Any],
    request: Any = None,
    view: Any = None,
    success_status: int | None = None,
    argument_binding: ArgumentBinding = ArgumentBinding.AUTO,
    unknown_arguments: UnknownArguments = UnknownArguments.IGNORE,
    on_target_resolved: TargetGuard | None = None,
    progress: ProgressReporter | None = None,
    view_hooks: ViewHooks | None = None,
    instance: Any = UNSET,
    filter_data: Mapping[str, Any] | None = None,
) -> DispatchResult

Async dispatch_spec.

Identical contract, arguments, policies and DispatchResult shape — see the sync twin. What differs is only the execution model: async selectors and services are awaited, and sync ones run in Django's thread-sensitive executor so the ORM stays safe off the event loop.

That rule covers every callable a spec carries, not just the selector / service: kwargs providers, extend_queryset, filter_set, input_serializer_context, a callable success_status, preconditions, and the on_target_resolved guard all run in the executor (see arun_off_loop). None of them can be async def — a spec is written once for both transports — so any that queries would otherwise raise SynchronousOnlyOperation here and nowhere else.

A LIST result comes back as the lazy shaped queryset, for the async transport to materialize / paginate in a thread.

render_spec_output

render_spec_output

render_spec_output(
    spec: ServiceSpec[Any, Any, Any] | SelectorSpec[Any, Any],
    value: Any,
    *,
    many: bool = False,
    view: Any = None,
    request: Any = None,
    extras: Mapping[str, Any] | None = None,
    view_hooks: ViewHooks | None = None,
) -> Any

Render value to a JSON-shaped payload using spec's output serializer.

The blessed render step that pairs with dispatch_spec: every transport renders the same way instead of re-implementing serializer + context plumbing. Reads the output serializer from spec.output_serializer (selector) or spec.output_selector_spec.output_serializer (service); when none is set the value passes through (list-coerced when many=True so a queryset evaluates).

The serializer context always carries DRF's baseline — request / format / view, from base_serializer_context — so a serializer that reads self.context["request"] renders identically here and behind a DRF view. The spec's output_serializer_context provider, if any, is resolved with view / request plus the resolved-data extras it declares (page for a list, instance for a retrieve, result for a mutation) and merged over that baseline.

Pagination is the caller's job — pass the already-sliced page as value (and the same page object under the matching extras key) so an id-keyed batched context query reuses the page's result cache.

arender_spec_output

arender_spec_output async

arender_spec_output(
    spec: ServiceSpec[Any, Any, Any] | SelectorSpec[Any, Any],
    value: Any,
    *,
    many: bool = False,
    view: Any = None,
    request: Any = None,
    extras: Mapping[str, Any] | None = None,
    view_hooks: ViewHooks | None = None,
) -> Any

Async render_spec_output.

Identical arguments, identical result — the whole render runs in Django's thread-sensitive executor instead of on the event loop, which is what an async transport needs. Rendering is full of sync ORM work, none of it optional:

  • serializer.data iterates the value. For many=True that evaluates the queryset; per row, every relation a field traverses is another query unless it was select_related in.
  • The spec's output_serializer_context provider is user code, and is documented as the place to run one batched query keyed on the page.
  • The no-serializer path list-coerces, which evaluates a queryset too.

So an async caller that awaited adispatch_spec — which returns a LIST result as a lazy queryset, deliberately — cannot render it inline without raising SynchronousOnlyOperation. Pair the two:

result = await adispatch_spec(spec, user=user, params=params)
payload = await arender_spec_output(spec, result.value, many=result.kind == "list")

A transport that already does its own thread hop around rendering (to paginate or post-process in the same hop) can keep calling render_spec_output inside it — this is for the ordinary case, where the hop shouldn't have to be the caller's problem to remember.

Pagination is still the caller's job; see the sync twin for the extras contract and the rest of the semantics.

render_for_audience

The agent-audience twin of render_spec_output: it renders through the same path, then applies the serializer's FieldMarking markings. An alternate transport whose consumer is a model calls this instead, so every such transport shapes payloads identically. Not for a pipeline that feeds one spec's output into the next — that still wants render_spec_output, or the handles the next step reads by will have been projected away.

render_for_audience

render_for_audience(
    spec: ServiceSpec[Any, Any, Any] | SelectorSpec[Any, Any],
    value: Any,
    *,
    projection: AudienceProjection | None = None,
    many: bool = False,
    view: Any = None,
    request: Any = None,
    extras: Mapping[str, Any] | None = None,
    view_hooks: ViewHooks | None = None,
) -> Any

render_spec_output plus the agent projection.

The single call an agent transport makes instead of render_spec_output, so an MCP server, an in-process toolset, and anything added later shape payloads identically rather than each growing its own post-processor. Two copies of the render path have drifted in this stack before; this exists so a third does not.

Every argument other than projection is passed straight through and means exactly what it means there, pagination included.

projection is the serializer's resolved markings. Omit it and one is derived from the spec, which costs a serializer instantiation per call — a transport that registers its tools up front should build it once with audience_projection_for_spec and pass it in.

Render the agent's answer with this. A pipeline that feeds one spec's output into the next must keep rendering with render_spec_output, or the handles the next step reads by will have been projected away.

arender_for_audience

arender_for_audience async

arender_for_audience(
    spec: ServiceSpec[Any, Any, Any] | SelectorSpec[Any, Any],
    value: Any,
    *,
    projection: AudienceProjection | None = None,
    many: bool = False,
    view: Any = None,
    request: Any = None,
    extras: Mapping[str, Any] | None = None,
    view_hooks: ViewHooks | None = None,
) -> Any

Async render_for_audience.

Identical arguments, identical result. The whole render — and the projection that follows it — runs in Django's thread-sensitive executor, for the same reason arender_spec_output exists: rendering evaluates querysets and traverses relations, so an async caller cannot do it inline without SynchronousOnlyOperation.

paginate_output

One page of a list selector's rows, in the envelope output_to_json_schema has always published for kind=LIST, paginate=True{items, page, totalPages, hasNext}. That schema described a payload nothing in this package produced: the shaper lived in a transport, so one agent transport wrapped its pages and another returned a bare list, against one schema claiming the envelope for both.

Rendering happens between the two calls, because it needs a view, a request and a spec that belong to the caller:

page = paginate_output(rows, page=page, limit=limit, max_page_size=ceiling)
rendered = render_for_audience(spec, page.items, projection=projection, many=True, ...)
payload = page.envelope(rendered)

page and limit are taken already parsed. Turning an untyped argument into an integer is where transports legitimately differ — a public endpoint clamps a malformed value and answers, an in-process toolset can hand the model its mistake back and ask again — and that is a policy about bad input, not about what a page is. Everything that is about what a page is lives here: the clamps at both ends, the count taken before the slice, and the reported page being the one actually served.

paginate_output

paginate_output(
    rows: Any,
    *,
    page: int | None = None,
    limit: int | None = None,
    max_page_size: int | None = None,
) -> OutputPage

Slice rows into the page an agent transport serves.

page / limit default to 1 and DEFAULT_PAGE_SIZE. Out-of-range values clamp at both ends — limit down to max_page_size and up to 1, page up to 1 and down to the last page that exists — and the clamps are not silent the way truncating an unpaginated result would be: totalPages / hasNext are computed from the clamped limit, and the returned page is the one actually served. A caller that asked for 500 rows and got 100, or for page 10 of 3, is told what it received.

Both values are taken already parsed. Turning an untyped argument into an integer is where the transports legitimately differ — a public endpoint clamps a malformed value and answers, an in-process toolset can hand the model its mistake back and ask again — and that is a policy about bad input, not about what a page is.

The upper clamp on page is why total is counted before the slice: (page - 1) * limit on an unclamped page is an arbitrarily large SQL OFFSET, which a backend either scans towards or rejects outright with a DatabaseError this does not catch.

Raises:

Type Description
TypeError

If rows is neither a queryset nor a sized, sliceable sequence — there is nothing to count and nothing to slice.

DEFAULT_PAGE_SIZE

DEFAULT_PAGE_SIZE module-attribute

DEFAULT_PAGE_SIZE = 100

OutputPage

OutputPage dataclass

One page of rows, plus what a caller needs to know to ask for the next.

The envelope this describes is already the one output_to_json_schema publishes for kind=LIST, paginate=True. That schema was here before anything in this package produced the payload it describes: the shaper lived in a transport, so one agent transport wrapped its pages and the other returned a bare list, against one schema that claimed the envelope for both. Two implementations of one mechanism drift; one of them was missing entirely.

items is the slice itself, unrendered — rendering needs a view, a request and a spec, all of which belong to the caller. Hand the rendered result back to envelope to get the wire shape.

total_pages property

total_pages: int

How many pages exist at this limit. At least one, even for no rows.

An empty result is one empty page rather than zero pages: page is 1-based and the page served for an empty result is 1, so reporting 0 would describe a page the caller was just handed as not existing.

has_next property

has_next: bool

Whether asking for page + 1 would return anything.

envelope

envelope(rendered: Any) -> dict[str, Any]

Wrap already-rendered rows in the published pagination envelope.

rendered rather than items because the projection lands on the rows and never on the envelope: page / totalPages / hasNext are this shape's own keys and belong to no serializer, so a projection walking them would look for markings that cannot exist.

base_serializer_context

The DRF baseline (request / format / view) that every serializer gets for free over HTTP, synthesized for the off-HTTP path. dispatch_spec and render_spec_output apply it themselves — reach for it directly only when a transport builds a serializer outside them. See Customise serializer context.

base_serializer_context

base_serializer_context(*, view: Any, request: Any) -> dict[str, Any]

Build the baseline serializer context a spec-driven render starts from.

Over HTTP every serializer DRF builds carries get_serializer_context{"request", "format", "view"} — so serializers routinely read self.context["request"] unguarded (build_absolute_uri, request.user, a permission check in a SerializerMethodField). A spec's input_serializer_context / output_serializer_context provider is additive config layered on top of that baseline, not a replacement for it, so off HTTP — where there is no DRF view to ask — the baseline has to be synthesized rather than skipped. Without it the same serializer that renders over HTTP raises KeyError: 'request' when the spec is dispatched from an MCP tool call, a Pydantic-AI toolset, or a management command.

Two sources, in order:

  • view.get_serializer_context() when the view has it — a real DRF view (the HTTP bulk path renders through the same helper). It is the view's own documented extension point and may already be overridden, so it wins.
  • Otherwise DRF's shape, synthesized from the view / request the caller passed: the synthetic pair from build_offline_context off HTTP, or None when the caller supplied neither. format is always None — content negotiation is an HTTP-only concern.

The keys are always present, mirroring HTTP: a serializer reading self.context["request"] off HTTP sees the synthetic request (and None only when the caller passed no request at all), never a KeyError. Absolute-URI fields additionally need real headers — pass the ambient http_request to build_offline_context when the transport has one, as the MCP server does.

The spec's provider is merged over this by resolve_output_context / resolve_input_context, so a provider keeps the final say on every key — including these three.

DispatchResult

DispatchResult dataclass

What dispatch_spec resolved, pre-wire.

A transport-neutral carrier the caller formats for its own wire (an HTTP Response, an MCP ToolResult, …). It holds the raw resolved domain value — never a paginated page or rendered serializer output — because ordering, pagination, and the response envelope are transport concerns. Render the value through render_spec_output.

instance and data are informational: a transport that only renders reads value / kind / status and can ignore them.

Attributes:

Name Type Description
value Any

The resolved value — a single instance (RETRIEVE / mutation result), a queryset / iterable (LIST), or None (a nullable retrieve under allow_none, a missing instance, or a side-effect-only mutation).

kind str

One of "instance" (a single value, possibly None), "list" (a collection to order / paginate / render many=True), or "not_found" (a required instance could not be resolved).

status int

An HTTP-ish status hint the transport may map to its wire: the spec's success status for mutations, 200 for reads, 404 for "not_found".

service_result Any

The service's own return value, captured before an output_selector_spec re-fetch replaced it in value. It is the flags carrier (an upsert DTO's created, a domain outcome enum) that a callable success_status and a response_finalizer key on, while value is the thing to render. None on the read path — a selector has no service.

instance Any

The resolved mutation target — the row an update / destroy acted on, or None on a create, a read, or a bulk path. Resolved from instance_selector_spec, so a transport needing the pre-mutation target reads it here rather than resolving a second time and risking a different answer.

data Any

The validated input (serializer.validated_data), or None when the spec declares no input_serializer — so a transport can key a post-dispatch decision on what was validated without re-running the serializer.

Input policies

Three optional, caller-side policies let a transport map its wire onto a spec without dispatch_spec baking in HTTP's implicit answers. The spec declares what (its inputs, filters, output shape, permissions); the caller declares how its flat input becomes callable arguments, how strict to be about undeclared keys, and how to authorize a resolved target. The defaults reproduce the pre-policy behaviour exactly, so a caller that passes none is unaffected.

ArgumentBinding

How the flat params map onto a dispatched callable's keyword arguments — bundled as one data payload or spread as individual kwargs, and how the spread ranks against the spec author's kwargs.

ArgumentBinding

Bases: Enum

How dispatch_spec turns the flat params into a callable's kwargs.

Every dispatched callable's keyword pool always carries the request / user seeds (and, for a mutation, data / serializer / instance / collection). This enum controls the one remaining question a caller answers about its wire: can client-supplied input land as individual keyword arguments, and do those override the spec author's kwargs(...) invariants? It is a trust-boundary decision, not plumbing — which is why it belongs to the caller rather than the spec. Pass the member directly; the value never appears on a wire. Those reserved seeds are always stripped from the spread in the SPREAD_* modes, so a client cannot poison transport-controlled state by naming an argument after one of them.

Attributes:

Name Type Description
AUTO

The default — resolve per spec type: ServiceSpecBUNDLE (a mutation takes its validated payload as one data bundle), SelectorSpecSPREAD_AUTHOR_WINS (a read spreads its params so the selector can declare them as parameters).

BUNDLE

Only the validated payload reaches the callable, as data=; individual client fields are not spread as kwargs. The safe choice for a mutation whose kwargs scopes the write — the client cannot inject arbitrary keyword arguments.

SPREAD_AUTHOR_WINS

Client fields are spread into the pool as individual kwargs, but spec.kwargs(...) overrides on conflict — an author-scoped tenant_id cannot be reset by the client.

SPREAD_CALLER_WINS

Like SPREAD_AUTHOR_WINS, but the client wins on conflict: spec.kwargs(...) supplies defaults it may override.

UnknownArguments

How strict dispatch_spec is about params keys outside the spec's declared set — drop them, reject them, or pass them through to the callable.

UnknownArguments

Bases: Enum

How dispatch_spec treats params keys outside a spec's declared set.

The declared set is derived from the spec without any transport knowledge: a ServiceSpec's input_serializer fields plus the keys its nested target selectors consume; a SelectorSpec's selector parameters. When the set cannot be enumerated — a callable that declares **kwargs, or a duck-typed filter_set whose fields are opaque to the core — the spec is treated as open and this policy is a no-op (there is nothing to call "unknown").

Members (internal knob — the value never appears on a wire):

  • IGNORE — undeclared keys are dropped (DRF serializers already do this to a mutation's payload, and a selector simply never receives kwargs it doesn't declare). The default, reproducing the pre-policy behaviour.
  • REJECT — an undeclared key raises ValidationError, the same surface a strict serializer produces. Useful when the caller wants a clean correction signal (e.g. a model calling a tool with a mistyped argument).
  • PASSTHROUGH — undeclared keys survive: they are merged onto the mutation's validated_data before the keyword pool is built, so a callable that declares them (or **kwargs) receives them. The one policy that must live inside dispatch_spec — it needs the seam between validation and pool construction that a wrapper cannot reach.

Callers strip their own transport-only keys (pagination, ordering, output format) before calling, so the declared-set check sees only spec inputs.

TargetGuard

The object-permission hook invoked with the resolved mutation target before the service runs. Its signature matches enforce_permissions, so that primitive is passed directly — by name, not wrapped in a lambda.

TargetGuard

Bases: Protocol

Invoked with the resolved target before a mutation runs; raise to abort.

dispatch_spec deliberately does not consult permission_classes (authorization is the caller's job — which is why enforce_permissions ships separately). But object-level checks need the resolved instance, which only dispatch_spec sees, before the service runs. So dispatch_spec exposes this hook rather than folding authz in: it stays authz-agnostic, only invoking a caller-supplied guard.

The signature is deliberately identical to enforce_permissions, so the canonical wiring is passing that function directly, by name — not wrapping it in a lambda:

dispatch_spec(spec, ..., on_target_resolved=enforce_permissions)

A consumer needing custom logic writes a module-level function with the same shape and passes it the same way; ty enforces conformance.

dispatch_spec builds the OfflineContext itself (it already holds user / request / view) and calls guard(spec, context, instance=target). instance is the resolved row for an update, the resolved set for a collection (bulk) mutation, and None for a create — so the guard fires uniformly on every resolved target, running the class-level check when there is no object.

ViewHooks

The calling DRF view's resolved hook-chain layers, handed to the core so one pipeline serves both transports. HTTP-only — like view, an off-HTTP caller omits it and nothing changes. It carries the view layers only (get_service_kwargs, get_input_data, the serializer-context hooks and their per-action twins); the spec's own kwargs / input_data / input_serializer_context providers stay the core's job, so they resolve exactly once no matter who dispatches.

ViewHooks dataclass

The HTTP view's hook-chain contributions, resolved and passed down.

dispatch_spec is the single execution core, but the view layer owns a configuration surface the core knows nothing about: the get_service_kwargs / get_<action>_service_kwargs / get_input_data / get_<action>_input_data / get_*_serializer_context chains declared on MutationFlowMixin and its viewset mixins. Those are methods on a DRF view, so the core cannot resolve them; this carrier is how a caller that has resolved them hands them over.

These are the view layers only — never the spec's own providers. Each chain resolves view.get_<x>view.get_<action>_<x>spec.<x>, spec winning on overlap. dispatch_spec owns that last layer, so a caller must pass spec_kwargs=None / spec_provider=None when resolving these; hand over the fully resolved chain instead and the core runs the spec provider twice, which a spec.kwargs doing a tenant lookup will not survive.

Every field defaults to None (contributes nothing), so a transport with no view — MCP, an agent toolset, a management command — simply omits the argument and the core behaves exactly as it did before this existed.

Attributes:

Name Type Description
extra_kwargs Mapping[str, Any] | None

Merges into the dispatched callable's pool, beneath spec.kwargs. The same carrier serves the selector chain (get_selector_kwargs / get_<action>_selector_kwargs) — only the view-method names differ, not the layering.

input_data Mapping[str, Any] | None

Merges onto the client payload before validation, beneath spec.input_data; server-provided keys win over the client's.

input_serializer_context Mapping[str, Any] | None

Layers onto the baseline serializer context (base_serializer_context), beneath spec.input_serializer_context.

output_serializer_context Callable[[Any], Mapping[str, Any]] | None

Lazy — a callable taking the final post-selector result and returning the context — because the output context provider is documented to see the exact instance being serialized, which does not exist until after the service and output selector have run.

progress Any | None

The view's own progress sink, resolved for both chains — a selector can be long too (a large export is a selector). Reach for it only when a buffered request genuinely needs it: if a request runs long enough to want progress, a task plus polling is usually the right shape, and this seam is for the cases where that does not apply (a streaming response, or a websocket sidecar the host already runs).

Authorizing an off-HTTP call

dispatch_spec is authz-agnostic by design — it never consults a spec's permission_classes (on HTTP that is the view's job). An off-HTTP transport that wants the same authorization a DRF view would apply wires enforce_permissions in two places:

from rest_framework_services import (
    adispatch_spec,
    build_offline_context,
    enforce_permissions,
)

context = build_offline_context(user)
# 1. Class-level `has_permission`, before any work. Covers create / list-payload
#    and every spec that has no resolvable target.
enforce_permissions(spec, context)
# 2. Object-level `has_object_permission`, on the resolved target. Fires on the
#    mutation *and selector* paths (update, retrieve, and — class-level only —
#    bulk / list collections, which are not per-row authorized).
result = await adispatch_spec(
    spec,
    user=user,
    params=params,
    request=context.request,
    view=context.view,
    on_target_resolved=enforce_permissions,
)

The upfront call is what authorizes a spec with no target (a create, or a many=True list-payload); the on_target_resolved hook adds object-level checks once the row (or collection) is resolved. Together they are the canonical wiring for every spec kind. enforce_permissions is collection-safe: a resolved queryset runs only the class-level check, never has_object_permission.

build_offline_context

build_offline_context

build_offline_context(
    user: Any,
    params: Mapping[str, Any] | list[Any] | None = None,
    *,
    http_request: HttpRequest | None = None,
    action: str | None = None,
    kwargs: Mapping[str, Any] | None = None,
    query_params: Mapping[str, Any] | None = None,
    host: str | None = None,
) -> OfflineContext

Build the OfflineContext for dispatching a spec outside an HTTP request.

dispatch_spec forwards request / view to spec callables (kwargs providers, extend_queryset, context providers) that declare them, and enforce_permissions needs a request + view to evaluate permission_classes. This synthesizes both so a spec written for the HTTP transport keeps working when driven from a Pydantic-AI toolset, the MCP server, a management command, or a task runner.

Parameters:

Name Type Description Default
user Any

Set as request.user, so callables and permissions that read it behave as on HTTP.

required
params Mapping[str, Any] | list[Any] | None

Seeds request.data, straight into DRF's parsed-data cache (a synthetic request has no stream to parse). It is not validated here — that is dispatch_spec's job, which takes params directly and never touches request.data. Pass both the same value.

None
http_request HttpRequest | None

An ambient Django request to wrap, so its headers / META are available (the MCP server passes its real one); otherwise an OfflineHttpRequest is created. Either way the method is forced to POST, because mutation callables often branch on it. The caller keeps ownership: a request passed here is never written to. What gets wrapped is a shallow copy, so the method / GET / user this function sets land on the copy alone, while META, the session, the upload handlers and any body already read stay shared — headers and session writes behave as they do on HTTP, and the live request keeps its own method, query string and user for the rest of its cycle. Pass the request you are serving; dispatching several specs from one request is safe, and neither leaks into the next.

None
action str | None

The view action name, exposed on the OfflineServiceView.

None
kwargs Mapping[str, Any] | None

The channel for route-derived values — the off-HTTP counterpart of a view's URL captures. dispatch_spec spreads it into the selector / target pools exactly where the HTTP path spreads extra_url_kwargs=view.kwargs: authoritative over params on a key conflict, below a spec.kwargs provider, which also sees it as view.kwargs. Pass every route capture a spec depends on.

None
query_params Mapping[str, Any] | None

Seeds the request's GET QueryDict, the source request.query_params reads — how read-shaping params that are not spec inputs reach the serializer offline (SelectorSpec.filter_set, a serializer branching on query_params). Values are stringified as on HTTP; a list / tuple becomes a multi-valued param. Replaces a wrapped http_request's GET — on the copy that is wrapped, so the caller's own GET still reads its real query string afterwards.

None
host str | None

The origin the synthesized request reports, so build_absolute_uri — which DRF's FileField / HyperlinkedIdentityField call whenever a request is in the serializer context — returns real absolute URLs off HTTP. Accepts "example.com", "example.com:8000", or a full origin whose scheme decides whether links are https. There is no default: unset, absolute-URI building degrades to the relative URL rather than raising (see OfflineHttpRequest). Ignored when http_request is supplied, whose own headers are authoritative, so a caller can pass both unconditionally.

None

The

Type Description
OfflineContext
OfflineContext

to hand to dispatch_spec.

Read-shaping over the offline path. Pass query_params= to seed the synthetic request's GET QueryDict — the source request.query_params reads. That is how read-shaping params that are not spec inputs reach the serializer off-HTTP: SelectorSpec.filter_set (when you don't hand filter_data in another way), and any serializer that branches on request.query_params (django-restql field selection, custom serializers). It does not make DRF filter_backends (SearchFilter / OrderingFilter) run — the offline path never calls filter_queryset; filter_set is the drf-services-native equivalent.

OfflineHttpRequest

The synthetic request build_offline_context creates when there is no ambient one. Relevant when you're deciding what build_absolute_uri() should return off the HTTP path — see Absolute URLs off the HTTP path.

OfflineHttpRequest

Bases: HttpRequest

An HttpRequest with no ambient host, made safe to use.

Built by build_offline_context when the caller has no real request to wrap (a Pydantic-AI toolset, a management command, a task runner). A bare HttpRequest has an empty META, and Django resolves the host from HTTP_HOST / SERVER_NAME — so get_host() raises KeyError: 'SERVER_NAME' and takes build_absolute_uri() down with it. That reaches serializers through the most ordinary field there is: DRF's FileField.to_representation calls request.build_absolute_uri(value.url) whenever a request is in the context.

Two behaviours, decided by whether the caller configured a host (build_offline_context(host=…), which seeds META — a configured request is an ordinary one and none of this applies):

  • Host configured — nothing here intervenes. Absolute URIs are built by Django exactly as on HTTP.
  • No hostbuild_absolute_uri returns the location unchanged, i.e. the relative URL. There is no honest absolute URL to return: the process has no idea what origin serves it, and a guess (the first ALLOWED_HOSTS entry, say) would emit confidently-wrong links that look valid. A relative URL is the same thing DRF's own file / hyperlinked fields fall back to when there is no request in the context at all, so this is the shape those serializers already handle.

get_host() still raises without a host — but with a message naming the fix, rather than a bare KeyError from Django's internals.

offline_host class-attribute instance-attribute

offline_host: str | None = None

The configured host ("example.com" / "example.com:8000"), or None when the caller didn't configure one. Set by build_offline_context; when it is set, META carries the matching keys and Django's own machinery is in charge, so nothing on this class changes behaviour.

build_absolute_uri

build_absolute_uri(location: str | None = None) -> str

Absolute URI when a host is configured; the location itself when not.

Degrading beats raising: the caller is a serializer field rendering a link, and a relative URL is a usable answer that its consumer can resolve against whatever origin it reached the data through.

get_host

get_host() -> str

The configured host verbatim; a pointed error when there is none.

Deliberately not validated against ALLOWED_HOSTS. That setting rejects spoofed Host headers from untrusted clients; this value came from the project's own code, and there is no client. Requiring it to appear in ALLOWED_HOSTS would break the ordinary case of a worker or management command that renders links for a site it does not itself serve — and would add nothing, since a caller who can pass a host can equally pass one that is on the list.

enforce_permissions

enforce_permissions

enforce_permissions(
    spec: ServiceSpec[Any, Any, Any] | SelectorSpec[Any, Any],
    context: OfflineContext,
    *,
    instance: Any = None,
) -> None

Enforce spec.permission_classes against an off-HTTP context.

Mirrors what a DRF view does on the HTTP path — [perm() for perm in spec.permission_classes] then perm.has_permission(request, view) — but against the synthetic request and view from build_offline_context. dispatch_spec deliberately does not consult permission_classes (authorization is the view's job on HTTP), so an off-HTTP transport must call this itself before dispatching, or it would skip authorization entirely.

When instance is a Django Model, object-level permissions (has_object_permission) are also checked — matching the HTTP path's check_object_permissions against a resolved instance. (drf-mcp's adapter omits this; off-HTTP parity restores it.) A non-Model instance — most importantly the collection queryset a bulk / LIST dispatch resolves — runs only the class-level check, never has_object_permission: object permissions are a per-row concept (the BULK decision authorizes per-set, not per-row), and has_object_permission(request, view, <QuerySet>) would AttributeError or silently mis-authorize. This makes on_target_resolved=enforce_permissions the safe canonical guard for every dispatch mode.

spec.permission_classes is None (the default — "inherit the view's class-level permissions" on HTTP) is a no-op off-HTTP: there is no view class to inherit from, so the transport owns any default policy. An empty sequence is likewise a no-op ("no permissions").

Raises PermissionDenied (403) on the first failing permission, carrying that permission's message / code when it declares them — the same surface a DRF view produces.

Permission classes that read DRF APIView attributes beyond request / action / kwargs (e.g. DjangoModelPermissions, which inspects view.queryset) are not supported off-HTTP.

Shared

resolve_callable_kwargs

resolve_callable_kwargs

resolve_callable_kwargs(fn: Callable[..., Any], pool: dict[str, Any]) -> dict[str, Any]

Pick the subset of pool matching fn's declared parameters.

If fn declares **kwargs, the entire pool is passed. Otherwise only parameters present in the signature are forwarded.

is_async

is_async

is_async(fn: Callable[..., Any]) -> bool

Return True if calling fn(...) produces a coroutine.

Handles plain async def functions, functools.partial wrapping one (via inspect.iscoroutinefunction's built-in unwrapping), and any callable whose __call__ is a coroutine function.

Service side

run_service

run_service

run_service(fn: Callable[..., Any], kwargs: dict[str, Any], *, atomic: bool) -> Any

Call fn(**kwargs) from sync code, optionally inside transaction.atomic().

An async def service is bridged transparently via async_to_sync, mirroring run_selector. The bridge is not optional politeness: without it an async service returns its coroutine object to the caller un-awaited — no exception, and under atomic=True the transaction commits before the body would have run. The HTTP path always bridged (its own dispatch_service wrapper did this); dispatch_spec reached this leaf directly, so the same spec resolved over HTTP and returned a coroutine over MCP.

Async services under atomic=True route through arun_service, which owns the thread-sensitivity rule that keeps the ORM connection holding the open transaction the same one the inner async DB calls use.

arun_service

arun_service async

arun_service(
    fn: Callable[..., Awaitable[Any]], kwargs: dict[str, Any], *, atomic: bool
) -> Any

Await fn(**kwargs), optionally inside transaction.atomic().

build_input_serializer

build_input_serializer

build_input_serializer(
    request: Request,
    input_serializer: type | None,
    *,
    partial: bool = False,
    extra_data: Mapping[str, Any] | None = None,
    context: dict[str, Any] | None = None,
    instance: Any = None,
    many: bool = False,
) -> Serializer | None

Construct + validate the bound input serializer; None if absent.

The serializer is returned validated (is_valid(raise_exception=True) has run) but never saved; the service owns persistence.

Parameters:

Name Type Description Default
request Request

The request whose data is validated.

required
input_serializer type | None

A bare dataclass type (wrapped in a DataclassSerializer on the fly), a DataclassSerializer subclass, or any other Serializer subclass such as ModelSerializer. The first two produce a dataclass instance as validated_data, the third a dict.

required
partial bool

Validate partially, as DRF's serializer(partial=…).

False
extra_data Mapping[str, Any] | None

Merged on top of request.data before the serializer is constructed, server-provided keys winning on overlap — the seam the input_data resolver chain uses to lift URL kwargs into serializer input. The merge goes through apply_input_data, which keeps a form-encoded / multipart QueryDict's scalars from flattening into one-element lists.

None
context dict[str, Any] | None

Forwarded to the serializer's context= so DRF-style self.context["request"] / ["view"] lookups work inside validators and fields.

None
instance Any

The resolved mutation target on update / destroy flows. The serializer is constructed DRF-style, so self.instance is populated inside validate() / field validators and instance-aware validators (UniqueValidator excluding the current row) behave as under DRF's own update flow.

None
many bool

Validate data as a list — the bulk list-payload path; validated_data is then a list of items.

False

build_input_serializer_from_data

build_input_serializer_from_data

build_input_serializer_from_data(
    data: Any,
    input_serializer: type | None,
    *,
    partial: bool = False,
    context: dict[str, Any] | None = None,
    instance: Any = None,
    many: bool = False,
) -> Serializer | None

Construct + validate the bound input serializer from a raw data dict.

The transport-neutral core of build_input_serializer: it takes the input data directly instead of reaching into a DRF request.data, so a non-HTTP caller (dispatch_spec) and the HTTP view path share one validation implementation. See build_input_serializer for the remaining parameter semantics.

validate_input

validate_input

validate_input(
    request: Request,
    input_serializer: type | None,
    *,
    partial: bool = False,
    extra_data: Mapping[str, Any] | None = None,
    context: dict[str, Any] | None = None,
    instance: Any = None,
) -> Any

Validate request.data against input_serializer; None if absent.

Thin wrapper over build_input_serializer (see there for the parameter semantics) returning only validated_data, for callers that don't need the bound serializer itself.

resolve_mutation_instance

resolve_mutation_instance

resolve_mutation_instance(view: Any, spec: ServiceSpec[Any, Any, Any]) -> Any

Resolve the mutation target, or defer to the core.

``None`` for a **bulk** spec (``many=True`` or a

Name Type Description
Any

collection_selector_spec): there is no single instance, and the

Any

get_object() lookup would 404 a body-only bulk endpoint. UNSET when the

Any

spec carries an instance_selector_specthe core resolves it, with the

Any

right kwarg pool, error label, and reserved-seed strip. Object permissions still

run Any

the core fires on_target_resolved against the resolved target and the

Any

HTTP caller passes check_view_object_permissions. Otherwise the view's

Any

get_object() chain (an action_specs["retrieve"] selector via

Any
Any

else DRF's queryset / lookup_field lookup, else a user override) — the

Any

one branch that is genuinely HTTP-only and so cannot move.

filter_backends do not apply to the two spec-driven branches. DRF runs filter_queryset() inside its own get_object(), so an instance_selector_spec (resolved by the core) and a retrieve selector (which overrides get_object()) both bypass it, exactly as a hand-written get_object() override does. A tenant-scoping backend in DEFAULT_FILTER_BACKENDS therefore does not narrow the row a PATCH or DELETE may reach here, while the sibling list action stays scoped. Put the scoping in the selector's own queryset. Only the last branch — DRF's own get_object() — applies the backends.

Selector side

run_selector

run_selector

run_selector(fn: Callable[..., Any], kwargs: dict[str, Any]) -> Any

Call a selector from sync code, transparently bridging async ones.

arun_selector

arun_selector async

arun_selector(
    fn: Callable[..., Any] | Callable[..., Awaitable[Any]], kwargs: dict[str, Any]
) -> Any

Call a selector from async code; sync ones run inline.

is_queryset

is_queryset

is_queryset(obj: Any) -> bool

True for Django QuerySet objects and Manager instances.

The one definition of a queryset-shaping target: what the shaping fields may be applied to, and what a RETRIEVE selector materializes from. Tested by type rather than by hasattr(…, "first"), which would also match a domain object that happens to expose first. QuerySet subclasses all pass.

apply_queryset_shaping

apply_queryset_shaping

apply_queryset_shaping(
    qs: Any,
    view: Any,
    request: Request,
    *,
    select_related: Any,
    prefetch_related: Any,
    annotations: Any,
    extend_queryset: Any,
    filter_set: Any = None,
    filter_data: Any = None,
    source_label: str,
) -> Any

Apply the five shaping fields to qs.

The order is fixed: the declarative fields first (in declaration order), then extend_queryset, so the user callable always sees the fully statically-shaped queryset, and finally filter_set, so filtering composes with shaping and runs before the retrieve .first() materialization the caller does next.

Parameters:

Name Type Description Default
request Request

Passed to extend_queryset, and into the filter_set constructor when it declares a request parameter — so a request-scoped FilterSet sees the same self.request it would behind DjangoFilterBackend. A bare (data, queryset) stand-in is called exactly as before.

required
filter_set Any

Applied by duck typing as filter_set(data=filter_data, queryset=qs).qs, after validation (mirroring DjangoFilterBackend's 400 on invalid filter input).

None
filter_data Any

The flat {field: value} mapping the FilterSet reads. None falls back to request.query_params — the HTTP view path; a transport-neutral caller passes its own params.

None
source_label str

Named in the misconfiguration error to point at the offending callable ("SelectorSpec.selector" vs "ServiceSpec.output_selector_spec.selector").

required

Returns:

Type Description
Any

The shaped queryset, or qs unchanged when nothing is configured.

Raises:

Type Description
ImproperlyConfigured

Shaping is configured but qs is not a Django queryset — loud failure beats a stray AttributeError deep in DRF rendering.

ValidationError

filter_set rejected filter_data.

Pool seeds

Every kwargs pool this package builds for a dispatched spec starts from base_pool — the HTTP view layer and off-HTTP dispatch_spec alike — so a spec callable that declares a seed behaves the same over either.

base_pool is a builder, not a gate, and nothing checks a pool assembled elsewhere. If you are writing a transport adapter, build your pool by calling itbase_pool(user=…, request=…, **your_own_entries) — rather than writing a dict literal of your own:

  • A literal that omits a seed carries no trace of it. resolve_callable_kwargs forwards only the keys the pool has, so a callable declaring the missing seed raises a TypeError at call time: the same spec works when your adapter dispatches it one way and fails another. progress is the usual casualty — it is the seed with a default, so a callable can declare it in the documented progress: ProgressReporter form and never see that anything is missing until a pool without it comes along.
  • Spreading your own entries through **extra makes a name collision loud. An entry called user or request raises TypeError at the base_pool call; in a dict literal the same entry silently outranks the value your transport authenticated.

base_pool

base_pool(
    *, user: Any, request: Any, progress: ProgressReporter | None = None, **extra: Any
) -> dict[str, Any]

The seeds a dispatched callable's kwargs pool carries, on every transport.

Every pool this package builds for a dispatched spec routes through here — the HTTP view layer and off-HTTP dispatch_spec alike — so a spec callable that declares a seed behaves the same whichever of them dispatched it.

That is a property of the pools built here, not a rule the framework can enforce on its callers: this is a builder, not a gate. A transport adapter that assembles a pool as a dict literal of its own carries exactly the keys it wrote there, because resolve_callable_kwargs forwards only keys the pool actually has. A callable declaring a seed the literal omitted then raises TypeError at call time rather than running — progress most often, since it is the seed with a default and so the one nobody remembers.

An adapter that dispatches callables through this package must build its pool from this function, with its own entries spread in — base_pool(user=…, request=…, **own_entries) — rather than restating the seeds. Routing those entries through **extra is also what makes a name collision loud: an entry called user or request raises TypeError here, where in a dict literal it would quietly outrank the value the transport authenticated.

progress defaults to null_progress rather than to None, so a declared reporter is always callable — see ProgressReporter.

null_progress

null_progress(
    progress: float,
    *,
    total: float | None = None,
    message: str | None = None,
    meta: Mapping[str, Any] | None = None,
) -> None

The ProgressReporter a transport with nowhere to send progress uses.

Seeded by base_pool whenever the caller supplied no reporter, which is every HTTP request, every test, and every off-HTTP dispatch from a transport that has no progress channel of its own.

The default is what makes the seed usable. Without it, a service declaring progress would work over one transport and raise a TypeError over the others, so nobody could declare it in code meant to be shared — which is the entire premise of writing a service once. Discarding the report is the honest behaviour for a caller that cannot forward it; refusing the call is not.