A selector tool with filtering, ordering, and pagination¶
Service tools wrap mutations — register_service_tool runs the
spec's service callable inside transaction.atomic() and renders the
result through an output serializer.
For reads you want a different shape. A "list invoices" tool is
read-only and benefits from filtering, ordering, and pagination — none
of which belong inside the selector. register_selector_tool keeps the
selector tiny (return a queryset) and gives the tool layer the
post-fetch knobs.
arguments → validate(merged inputSchema)
→ run_selector
→ FilterSet(data=…).qs (if spec.filter_set)
→ FilterSet applies ordering (if it declares an OrderingFilter)
→ paginate (if paginate=True)
→ output_serializer(many=True)
→ ToolResult
Each knob is opt-in. A bare register_selector_tool with no
filter/order/paginate behaves like a plain RPC read.
Install the optional extra¶
spec.filter_set requires django-filter:
Without it, importing rest_framework_mcp still works — the
ImportError only fires when a selector spec actually carries a
filter_set.
Define the pieces¶
A model:
# invoices/models.py
from django.db import models
class Invoice(models.Model):
number = models.CharField(max_length=32, unique=True)
amount_cents = models.PositiveIntegerField()
sent = models.BooleanField(default=False)
created_at = models.DateTimeField(auto_now_add=True)
An output serializer:
# invoices/serializers.py
from rest_framework import serializers
from invoices.models import Invoice
class InvoiceOutputSerializer(serializers.ModelSerializer):
class Meta:
model = Invoice
fields = ["id", "number", "amount_cents", "sent", "created_at"]
A scoped selector — returns a raw queryset, no filtering or ordering:
# invoices/selectors.py
from django.db.models import QuerySet
from invoices.models import Invoice
def list_invoices(*, user) -> QuerySet[Invoice]:
"""Return every invoice the caller is allowed to see."""
return Invoice.objects.for_user(user) # your scoping manager
A FilterSet describing the parametric reads the tool exposes:
# invoices/filters.py
import django_filters
from invoices.models import Invoice
class InvoiceFilterSet(django_filters.FilterSet):
sent = django_filters.BooleanFilter()
min_amount = django_filters.NumberFilter(field_name="amount_cents", lookup_expr="gte")
max_amount = django_filters.NumberFilter(field_name="amount_cents", lookup_expr="lte")
created_after = django_filters.DateTimeFilter(field_name="created_at", lookup_expr="gte")
# Ordering is declared here, like any other filter. `OrderingFilter`
# subclasses `ChoiceFilter`, so it is reflected into the tool's
# `inputSchema` as a choice over the names on the left — the public
# vocabulary, not the ORM paths behind them — each carrying the label
# django-filter derives for it.
ordering = django_filters.OrderingFilter(
fields=(("created_at", "created"), ("amount_cents", "amount")),
)
class Meta:
model = Invoice
fields = ["sent", "min_amount", "max_amount", "created_after"]
Register the selector tool¶
from rest_framework_services.types.selector_kind import SelectorKind
from rest_framework_services.types.selector_spec import SelectorSpec
from rest_framework_mcp import MCPServer
from invoices.filters import InvoiceFilterSet
from invoices.selectors import list_invoices
from invoices.serializers import InvoiceOutputSerializer
server = MCPServer(name="invoicing")
server.register_selector_tool(
name="invoices.list",
spec=SelectorSpec(
kind=SelectorKind.LIST,
selector=list_invoices,
output_serializer=InvoiceOutputSerializer,
filter_set=InvoiceFilterSet,
),
description="List invoices, optionally filtered / ordered / paginated.",
paginate=True,
)
filter_set lives on the SelectorSpec (since
djangorestframework-services 0.18), so the same declaration drives the
HTTP transport and MCP — declare the filterable shape once. Ordering
rides along with it: nothing about ordering appears on the registration
call, because the OrderingFilter already declared it. paginate is
the one MCP-only pipeline mechanic left here.
Two ways to offer ordering¶
Ordering is not a registration knob, and there are two channels that
need none. Both are reflected into the tool's inputSchema, so one
declaration serves the HTTP transport and every agent transport.
- An
OrderingFilteron theFilterSet— the one above. Prefer this wherever there is aFilterSet: the value is validated against a published set of choices before anything reaches the ORM, and the choices are consumer-facing names rather than column paths. -
A sort parameter the selector declares itself — for a project with no
django-filterdependency. drf-services reflects the callable's own parameters, so this needs nothing at registration either:def list_invoices(*, user, sort: str = "-created_at"): """List the caller's invoices, newest first by default.""" return Invoice.objects.for_user(user).order_by(sort)The trade-off is the validation: whatever the model sends reaches
.order_by()with only the checking the selector itself does, so constrain it there (an allowlist, or aChoiceFieldon aninput_serializer) rather than passing it straight through.
Do not name that parameter page or limit
Those two names belong to the selector-tool read pipeline and are
stripped from the arguments the selector is called with, so that a
**kwargs selector never receives them. The strip goes by name, so a
selector declaring page as a parameter of its own gets it advertised
in the schema and then dropped at dispatch, with its default running
instead — silently.
ordering is not reserved. It was, while ordering_fields
existed and the pipeline sorted the queryset itself. Sorting now
belongs to whatever declares it, under whatever name it likes, so
name the parameter ordering if that reads best.
ordering_fields was removed
The registration once took ordering_fields=[...], a list of raw ORM
paths handed to .order_by(). That was a third vocabulary for the
same ordering argument, deprecated in 0.30.0 and removed since —
passing it now raises TypeError at registration.
Migrate by moving the field list into an OrderingFilter on the
FilterSet, as above, mapping each ORM path to the public name you
want the model to use — or, with no FilterSet, onto a sort parameter
of the selector's own. A model's Meta.ordering covers the default
order without any argument at all.
The decorator form is symmetric with @server.service_tool. It
auto-builds the SelectorSpec from kind + the wrapped function, so it
covers paginate but not filter_set (a
FilterSet belongs on the spec). A decorated selector orders through its
own sort parameter; for the FilterSet route, use the explicit
register_selector_tool form above, or hand the decorator a ready
spec= that carries the filter_set:
@server.selector_tool(
name="invoices.list",
kind=SelectorKind.LIST,
output_serializer=InvoiceOutputSerializer,
paginate=True,
)
def list_invoices(*, user):
return Invoice.objects.for_user(user)
Generated inputSchema¶
tools/list advertises the merged shape:
{
"type": "object",
"properties": {
"sent": {"type": "boolean"},
"min_amount": {
"type": "number",
"description": "Matches `amount_cents` with the `gte` lookup."
},
"max_amount": {
"type": "number",
"description": "Matches `amount_cents` with the `lte` lookup."
},
"created_after": {
"type": "string",
"format": "date-time",
"description": "Matches `created_at` with the `gte` lookup."
},
"ordering": {
"oneOf": [
{"const": "created", "title": "Created"},
{"const": "-created", "title": "Created (descending)"},
{"const": "amount", "title": "Amount"},
{"const": "-amount", "title": "Amount (descending)"}
],
"title": "Ordering"
},
"page": {"type": "integer", "minimum": 1},
"limit": {"type": "integer", "minimum": 1, "maximum": 100}
}
}
Filter properties are always optional — they narrow the queryset but
aren't required to call the tool. Ordering accepts both ascending
(field) and descending (-field) variants, and each variant carries
its label, so a model is told that -amount means descending rather
than having to infer it from the sign. page defaults to 1 and
limit to 100 when the model omits it.
The description on min_amount, max_amount and created_after is
derived, not written: where a filter's own name does not give away which
column it matches or with which lookup, drf-services states both. A
filter whose name, field and lookup already agree — sent — says nothing
extra, and a help_text you write yourself always wins over the derived
wording. A label becomes title the same way.
limit's maximum is the effective MAX_PAGE_SIZE
— the server-wide setting, or the max_page_size= passed at registration.
It is advertised here and clamped at dispatch, and it disappears from the
schema entirely when the bound is None. It is unrelated to PAGE_SIZE,
which bounds listing calls such as tools/list and never reaches a selector
tool's limit.
Filter type mapping¶
The schema generator reads FilterSet.base_filters — no FilterSet
instantiation, so a Meta-driven set without an explicit queryset
still works. Common filter classes are mapped accurately:
django_filters class |
JSON Schema fragment |
|---|---|
CharFilter |
{"type": "string"} |
BooleanFilter |
{"type": "boolean"} |
NumberFilter |
{"type": "number"} |
DateFilter |
{"type": "string", "format": "date"} |
DateTimeFilter |
{"type": "string", "format": "date-time"} |
TimeFilter |
{"type": "string", "format": "time"} |
UUIDFilter |
{"type": "string", "format": "uuid"} |
ChoiceFilter |
{"oneOf": [{"const": <value>, "title": <label>}, …]}, or {"enum": [<values>]} when the labels only restate their values (or {"type":"string"} if choices are deferred) |
MultipleChoiceFilter |
{"type": "array", "items": <the ChoiceFilter shape>} |
BaseInFilter (CSV) |
{"type": "array", "items": <scalar>} |
BaseRangeFilter |
{"type": "object", "properties": {"min": <scalar>, "max": <scalar>}} |
ModelChoiceFilter |
{"type": "string"} (FK PK; coerced by FilterSet at dispatch) |
Custom filter classes that don't match any of the above fall through
to {} (JSON Schema's "any value" shape) so a niche filter never
breaks tool discovery — discoverability degrades gracefully rather
than failing the whole tools/list call.
Paginated response shape¶
When paginate=True, tools/call wraps the rendered list in a
pagination envelope:
Without paginate, the response is the rendered list directly. Choose
based on how many rows your selector can return — paginate as soon as
the list could outgrow a single tool-call response.
Combining with input_serializer¶
spec.filter_set only describes the filter shape. If your tool also needs
non-filter arguments, declare them through input_serializer= — the
two schemas merge in inputSchema and the validated payload reaches
the selector via the kwargs pool.
class InvoiceListInput(serializers.Serializer):
include_drafts = serializers.BooleanField(required=False, default=False)
server.register_selector_tool(
name="invoices.list",
spec=SelectorSpec(
kind=SelectorKind.LIST,
selector=list_invoices,
output_serializer=InvoiceOutputSerializer,
filter_set=InvoiceFilterSet,
),
input_serializer=InvoiceListInput,
paginate=True,
)
include_drafts lands in data for the selector to consume (or any
**kwargs-shaped argument the selector declares); FilterSet-driven
properties are applied after the selector returns its base queryset.
When to reach for a service tool instead¶
If the operation creates / updates / deletes rows, use
register_service_tool. The service-tool path runs inside
transaction.atomic() (by default) and renders through an output
serializer, not a queryset pipeline. Selector tools are the
read-shaped sibling — they should never have side effects.