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— DRFBasePermissionclasses. Auto-wrapped withDRFPermissionAdapterand prepended to the per-bindingpermissionstuple, so spec-declared permissions run before any tool-levelMCPPermissionyou add at the MCP call site.SelectorSpecqueryset shaping —select_related,prefetch_related,annotations, andextend_querysetare 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 asget_serializer_context()supplies it behind a view — so a serializer readingself.context["request"]unguarded renders the same over both surfaces. On top of that,input_serializer_context/output_serializer_context(onServiceSpec) andoutput_serializer_context(onSelectorSpec) are merged over the baseline and forwarded ascontext=to the serializer constructor, on both sync and async dispatch paths. Providers are invoked through the keyword pool — each receives the subset ofview/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. Requiresdjangorestframework-services>=0.29.0. SelectorSpec.kind— requiredSelectorKinddiscriminator (LISTorRETRIEVE). It drives themany=flag on the output serializer and gates which post-fetch knobs the registration accepts (aRETRIEVEspec rejects the collection-onlypaginate, butfilter_setis allowed — it is shaped + applied before the single-instance.first()).SelectorKindis re-exported fromrest_framework_mcpfor convenience.ServiceSpec.output_selector_spec— a nestedSelectorSpec | Nonedescribing the post-call render pipeline (optional re-fetch via itsselector, thenoutput_serializerwithmany=driven by itskind). The decorator forms (@server.service_tool, etc.) accept flatoutput_serializer=/output_selector=kwargs and build the nested spec internally; directServiceSpec(...)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 onresources/read. - The availability check asks with them too.
tools/list,list_toolsandunavailable_toolsresolve the seeds into the pool they ask an operation condition against, sowhen=lambda *, tenant: tenant.can_close_booksis 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
inputsreturned, 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
inputSchemaand 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,QueryParamor 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_seedsand drf-services' own seeds (request,user,progress, ...); - a
UrlKwargthat declares adefault, 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 aTypedDict(NotRequiredkeys 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 itsinput_serializerfills when the client sends nothing (a writable field with adefault, aHiddenFieldincluded), because the validated values are laid over the selector's params. A dataclass input (a bare@dataclassor aDataclassSerializer) validates into an instance, which is laid over them field by field as adictis, so a dataclass field declaring adefaultor adefault_factoryfills its name too: the instance is built with it.call_tooldoes not run a selector tool'sinput_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:
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
QueryParamhas no such flag — unlikeUrlKwarg, where a missing route capture means the spec cannot run at all. - One name, one channel. Declaring the same name as both a
QueryParamand aUrlKwargraises 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
QueryParamnamed after any input the tool'sinputSchemaoffers the caller, read from the reader that builds that schema: on a selector tool, a selector parameter or afilter_setfield (which would silently stop filtering); on a service tool, aninput_serializerfield, 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 fromrequest.query_paramswith akwargs=provider whoseTypedDictdeclares it, read the value there in the callable and drop the input, or drop theQueryParam.
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
QueryParamthe spec itself declares inreads. 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 andrequest.query_params, on every registration route. Only a query param declared at the mount (query_params=, or anagent_contract=carrying one) is refused under this rule; - a name a
kwargs=provider declares it fills, by returning aTypedDictwhose key cannot beUNSET, or thatspec_kwargs_provides=claims. A provider readingrequest.query_params["status"]into the selector'sstatus, besideQueryParam("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 itsinput_serializer's fields or its target lookup's parameters, which a lookup's ownkwargs=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_serializerlays back: the serializer validates the arguments before theQueryParamtakes 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 adictis, so its fields exempt their names alike; aread_onlyfield, or one whosesource=names another attribute, is not laid back under its name and exempts nothing.call_tooldoes not run a selector tool'sinput_serializer, so there the value reaches onlyrequest.query_params. A service tool's serializer validates the arguments theQueryParamhas 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
ValidationErrorfor a name it does not know (django-restql's default, for one). That comes back as anisError: truetool result of typevalidation_error, the same shape a service'sServiceValidationErrorproduces (Dispatch flow), and its message names the argument, then gives the serializer's own words: "fieldswas rejected while rendering the result: Unknown fielditems." 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 undernon_field_errorsand 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
inputSchemaand itsrequiredlist, ontools/listand wherever else the schema is built, so the model is never asked for them. A bound value can fill a read the spec marksrequired=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 atWARNING, 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
UrlKwarglands inview.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
ImproperlyConfigurednaming the tool. A binding built by hand and passed toserver.tools.registeris 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_argumentssays, 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_argumentsapplies inside each item. Each item is checked as a single-item tool's arguments are, and each item's ownadditionalPropertiessays 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_errorresult whoseerror.detailis{"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"; withINCLUDE_VALIDATION_VALUEon,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
outputSchemais an array of the item schema.structuredContentis 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=Trueservice raisingAdditionalInputRequiredcan 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.
.kindis the requiredSelectorKinddiscriminator (LISTorRETRIEVE); it drives themany=flag on the output serializer at dispatch.RETRIEVEis the typical choice for a single-object URI-template lookup..selectoris the callable that gets dispatched (must be set; specs withselector=Noneare rejected)..output_serializerfills in when the caller didn't pass one explicitly..kwargsbecomes 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 validatedargumentsflow into the callable's kwarg pool. The enum is re-exported fromdjangorestframework-services(the transport-neutraldispatch_specowns these policies).ArgumentBinding.BUNDLE(default for service tools) — onlydata=<validated>enters a service's pool. Without aninput_serializernothing does, so a service requiringdatais refused at registration. A selector is never handeddataorserializer, under any binding: drf-services' selector dispatch seeds neither and strips both from the spread. So a selector tool declaring aninput_serializerunderBUNDLEis refused at registration, because the validated payload has no way to reach the selector, and so is a selector requiringdataorserializerunder 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 withImproperlyConfigured: 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. UseSPREAD_AUTHOR_WINSfor a service whose own parameters are the caller's input, orBUNDLEfor one validateddatapayload; where the caller's value should win for a key, leave that key out ofspec.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=— howargumentskeys outside the binding's declared field set are handled.-
UnknownArguments.REJECT(default) — the validator rejects unknown keys with anisErrorvalidation_errorresult naming them underdetail.non_field_errors, and the outerinputSchemaadvertises"additionalProperties": false.The closed schema is advertised exactly where the runtime refuses an undeclared name. A service tool hands its
unknown_argumentsto drf-services' dispatch as registered, with or without aninput_serializer, and theinputSchemalists the set dispatch declares for the spec under the tool'sargument_binding:- the
input_serializer's fields, when there is one; - the keys of the target lookup dispatch calls (the
collection_selector_specwhen declared, else theinstance_selector_spec; amany=Truespec has neither); - without an
input_serializer, underSPREAD_AUTHOR_WINS, the service's own parameters, which are its input there. A parameter without a default is required, unless the spec'skwargs=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 marksNotClientInput, which dispatch drops from the caller's input, every key the target lookup's ownkwargs=provider says it fills, which dispatch lays over the arguments under every binding, and, underSPREAD_AUTHOR_WINS, every key the service'skwargs=provider says it fills, which dispatch lays over the caller's spread.REJECTstill 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 withUNSETstays listed, not required. A lookup parameter a precondition hides is therefore not advertised, though the lookup names it.An
input_serializerfield 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 theinput_serializer, or declare itread_only=True.So
def archive_task(*, instance, reason)behind apklookup, underSPREAD_AUTHOR_WINS, advertisespkandreasonwith"additionalProperties": false, and a call addingnotify_owneris avalidation_errornaming it. Bundled, it advertisespkalone, because nothing then readsreasonby name, and refusesreasonthe same way.The schema stays open (
"additionalProperties": true) where dispatch serves every name: a target lookup taking a bare**kwargsor carrying afilter_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**kwargswhose annotation does not resolve at runtime (anUnpack[...]of aTypedDictimported underTYPE_CHECKING) leaves the set unknown, soREJECTcannot be enforced against it and registration raisesImproperlyConfigured. The decorator form meets the same refusal for aTypedDictdeclared 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
REJECTbefore dispatch for a service with noinput_serializerand 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, registerIGNORE, which drops them.Selector and chain bindings enforce the closed set in this package, against their own input serializer, so they are closed under
REJECTwith 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_specsandregister_toolsthrough them) refuses it withImproperlyConfigurednaming the tool, so a server carrying it does not start. Declare the argument (areadsentry, a serializer field or a parameter) and passIGNOREorREJECT. -UnknownArguments.IGNORE—"additionalProperties": true; unknown keys are silently dropped (the historic DRF default). - the
Selector tools' pipeline-reserved keys are always treated as "known", so the policy doesn't fight the post-fetch pipeline.
always_listed=— whenREST_FRAMEWORK_MCP["FILTER_LISTINGS_BY_PERMISSIONS"]is enabled, bindings are dropped fromtools/list/resources/list/prompts/listwhen their permissions deny the current caller, and a tool is left out oftools/listwhenever an operation-scope affordance refuses it right now (see below). Settingalways_listed=Truekeeps 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 ofrequesta call's condition sees — the DRFRequestbuilt around the transport's own, methodPOST, with no arguments and no query string — and the token's user. It is not gated byFILTER_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'saffordancesnames 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/calllooks the tool up by name without consulting any listing, so a client holding an older one gets the refusal it would have got anyway: anisErrorresult carrying the affordance'scode(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
listChangedis advertised on, and has opted in totoolsListChangedon 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'sttlMsmay put off for up toCATALOG_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 mergedinputSchema(serializer fields plus a selector tool's filter / ordering / pagination arguments and theadditionalPropertiespolicy), the sameFILTER_LISTINGS_BY_PERMISSIONSper-caller filter, the same availability filter, and the same opaque-cursor pagination the HTTP transport uses.alist_toolsis 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-onlyinput_serializer, chain tools, and the output format — everythingcall_tooldeliberately omits. It returns the wire'sdictpayload (content/structuredContent/isError) or aJsonRpcErrorfor a protocol fault (unknown tool, malformedargumentsshape, 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)(andalist_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 itscacheScopeis 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 plainlist_toolswould leave out to theAffordancethat left it out — itscodeand itsreason, 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 analways_listedtool, never oneFILTER_LISTINGS_BY_PERMISSIONShides from this caller, and for a chain the first unmet step in order. It asks again on every call; nothing is remembered.aunavailable_toolsis 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:
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:
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.
-
The view MUST send
ui/notifications/initialized— on any reply toui/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 whenui/initializefails. 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()awaitsui/initializeinside atryand sendsinitializedonly after a successful result, so an error reply jumps past it to thecatch. A view built on the SDK inherits this. -
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, notdocumentElement: content inside anoverflow-x: autocontainer 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. ui/initializeparams areappInfo,appCapabilitiesandprotocolVersion. The spec's prose and its worked example disagree here — the pinned2026-01-26revision's example sendscapabilitiesagainst normative text requiringappCapabilities. Follow the generated schema, which is what hosts are built from.- The view MUST answer host requests it does not implement, with a
JSON-RPC
-32601rather than silence. An unanswered request leaves the host waiting on a promise that never settles. - 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. - Inline everything. A view that imports its runtime from a CDN needs that
origin in
csp.resource_domainsbefore it will boot, on every host — and the extension's own advice is to inline. - 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:
- Look up the
ToolBindingby name; reject unknown. - Evaluate per-binding
MCPPermissionclasses (AND-combined), before any rate limit is charged. Denial → 403 withWWW-Authenticatecarrying any required scopes. The spec's ownpermission_classesare among them, each wrapped in aDRFPermissionAdapterand judged against a stand-in built from the same values as the dispatch view: the URL kwargs inview.kwargs, the declaredQueryParamvalues inrequest.query_params, the rest of the arguments inrequest.data, and the tool's name asview.action. Then judge each class'shas_permissionagain, 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 itsinput_serializerruns. A denied caller is answered-32006alike 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'shas_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 asrequest.data, and each step judges its classes again against its own view before itsinputsrun and its target is looked up, then only the object-level half on the target it resolves. - If
spec.instance_selector_specis 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) - the nested spec's own
kwargsprovider; queryset shaping applies and a QuerySet return is materialized via.first(). A missing row short-circuits to anisError: truetool result (type: "not_found"). Because the lookup arrives as an argument, the tool'sinputSchemaadvertises it: the target selector's parameters (and afilter_set's fields) are reflected the same way a selector tool's own are, sotask_by_pk(*, pk)putspkbeside the input serializer's fields. Acollection_selector_specis 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 orUrlKwargof the same name keeps its property, and a lookup parameter isrequiredwhen 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 avalidation_errorresult. - Validate
argumentsviaspec.input_serializer(DRFSerializer, bare@dataclassauto-wrapped inDataclassSerializer, orNone).spec.partial=Truevalidates partially (and dropsrequiredfrom the advertisedinputSchema); the resolved instance is threaded into the serializer DRF-style so instance-dependentvalidate()seesself.instance. Amany=Truespec validates the list under itsmany_argumentinstead, item by item, with no target to resolve (A list payload). - Build a kwarg pool:
{request, user, data}and the server'spool_seeds, plus — when present — the resolvedinstanceand the bound, validatedserializer(both reserved seeds clients cannot poison; services opt in by declaring the parameter, e.g. to callserializer.save()). resolve_callable_kwargs(spec.service, pool)→run_service(spec.service, kwargs, atomic=spec.atomic).- 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 raisingServiceValidationError— and a service raisingServiceErrorreturn anisError: truetool result the model can read and self-correct from, with a JSON{"error": {"type": "validation_error" | "service_error", "message": ..., "detail": ...}}payload incontent[0](and nostructuredContent, which is tied to the success schema). A serializer's refusal carries the message"Invalid arguments"and its field-keyed errors underdetail. A missing argument refused before the serializer ran (step 3) carries"Missing required argument(s): `pk`."instead, the server'sconventions.missing_arguments, over the same keyeddetail. The MCP spec files input validation under tool execution errors in every protocol version this server serves, and keeps JSON-RPC-32602for an unknown tool and a request that fails theCallToolRequestschema (a non-objectarguments, say), which is alltools/callanswers with it. Chain steps addfailedStep. A call refused by one of the spec's declaredaffordancesraises drf-services'ActionUnavailable, and its error object also carries that affordance'scode—{"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.typeis still"service_error", and any otherServiceError(aServiceConflictraised by hand, say) carries nocodekey at all. SettingREST_FRAMEWORK_MCP["INCLUDE_VALIDATION_VALUE"] = Trueadditionally echoes the offendingargumentsdict back undervalue— handy for debugging schema mismatches against opaque client SDKs, off by default because the dict can carry sensitive payloads. - If
spec.output_selector_specis set, run its post-call pipeline: optionally re-fetch viaoutput_selector_spec.selector(same kwarg-pool dispatch), then render throughoutput_selector_spec.output_serializerwithmany=driven byoutput_selector_spec.kind. Ifoutput_selector_specisNone, the service's return value is passed through unchanged. ALISTre-fetch serves a bare array — a service tool never paginates — and the tool'soutputSchemaadvertises{type: array, items}to match. With noselectorthere is no re-fetch, and the nestedkindstill decides: aLISTpresents 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, astr,None) as the author's error, after the service has run. ARETRIEVEpresents it as one object. Amany=Truespec never re-fetches: the service's list is rendered as a list and advertised as an array. - Wrap as a
ToolResultwithOutputFormat-driven encoding for the human- readablecontent[0]block.structuredContentis 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:
- Resolve URI through
ResourceRegistry(returns binding + URI-template variables). - Permission check.
- Build kwarg pool:
{request, user, **uri_vars}. resolve_callable_kwargs(selector, pool)→run_selector(...).- Render through
binding.output_serializerif set, then JSON-encode.
Sessions, headers, origins¶
The MCP 2025-11-25 transport requires:
MCP-Protocol-Version— the version the client speaks. Validated againstREST_FRAMEWORK_MCP["PROTOCOL_VERSIONS"]. Missing → 400 except oninitialize, which is allowed to omit it for the initial handshake. Some clients omit the header on every request; setREST_FRAMEWORK_MCP["REQUIRE_PROTOCOL_VERSION_HEADER"] = Falseto 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 toinitialize. 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 pluggableSessionStore— by default the Django cache.Origin— strict allowlist. Empty allowlist means "no cross-origin requests"; an emptyOriginheader is treated as same-origin and allowed. Configure viaREST_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:
structuredContentis always JSON-shaped — clients parse it directly.content[0]is a text block whose payload is encoded perOutputFormat.
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 thestructuredContentfield ontools/callresults.REST_FRAMEWORK_MCP["INCLUDE_OUTPUT_SCHEMA"]— gates theoutputSchemafield ontools/listentries.
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 bloatstools/listresponses but you still want machine-parsable results. SetINCLUDE_OUTPUT_SCHEMA=False; leaveINCLUDE_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 toFalse. The text payload incontent[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 (
MCPAuthBackendProtocol). Authenticates a request and produces aTokenInfo. The transport callsauthenticate(request)on every call; returningNoneproduces a spec-mandated 401 with aWWW-Authenticateheader built fromwww_authenticate_challenge(...). The/.well-known/oauth-protected-resourceview delegates its payload to the backend'sprotected_resource_metadata(). - Permissions (
MCPPermissionProtocol). DRF-style classes attached to a binding (permissions=[ScopeRequired(["invoices:write"])]). Evaluated after authentication; AND-combined; required scopes from any denying class are surfaced inWWW-Authenticate.
Authentication walks through the full picture, including the django-oauth-toolkit recipe and audience binding.