Concepts¶
A short tour of every moving part. Read this once; the rest of the docs assume you have these in your head.
ServiceSpec is the unit of registration¶
The MCP server does not wrap, walk, or otherwise reach into DRF viewsets,
routers, or views. Consumers register
ServiceSpec
instances directly. Same value object as the HTTP transport — same callable can
serve both at once.
from rest_framework_mcp import SelectorKind, SelectorSpec, ServiceSpec # re-exported for ergonomics
spec = ServiceSpec(
service=create_invoice,
input_serializer=InvoiceInputSerializer,
output_selector_spec=SelectorSpec( # nested spec for the post-call
kind=SelectorKind.RETRIEVE, # render pipeline (RETRIEVE → many=False,
output_serializer=InvoiceOutputSerializer, # LIST → many=True)
selector=None, # optional post-call re-fetch callable
),
atomic=True, # wrap dispatch in transaction.atomic()
success_status=None, # ignored by MCP — used by HTTP
kwargs=None, # optional per-spec kwargs provider; see below
)
This means a project that uses neither ServiceViewSet nor DRF routers can
still expose its services over MCP. The HTTP and MCP transports are siblings,
not layers — neither owns the other.
What ServiceSpec / SelectorSpec carries through to MCP¶
The MCP layer honors the same spec fields as the HTTP transport — register a spec once and both surfaces get the same shape:
permission_classes— 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-onlyordering_fields/paginate, 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.
URL kwargs — route values a provider reads off view.kwargs¶
On a tool call, view.kwargs is empty by default (a tool has no URL). So a
provider shared with an HTTP view that scopes by a route capture — e.g.
view.kwargs["project_pk"] behind a tenant/role lookup — would return its
fallback (usually None) over MCP and mis-scope for every caller. Register a
UrlKwarg so the model can supply that route value: it is
advertised as a tool argument, popped at dispatch, and seeded into the off-HTTP
view.kwargs — from where drf-services spreads it into the dispatch pools
(authoritative over the spec params, below the spec.kwargs provider).
from rest_framework_mcp import UrlKwarg
from rest_framework_services import ServiceSpec
def scope_by_project(view, request) -> dict:
# Over HTTP this reads a URL capture; over MCP it reads the UrlKwarg the
# model supplied — same code, both transports.
return {"role": role_in_project(request.user, view.kwargs.get("project_pk"))}
server.register_service_tool(
name="policies.update",
spec=ServiceSpec(service=update_policy, kwargs=scope_by_project),
url_kwargs=(UrlKwarg("project_pk", type="integer", description="owning project"),),
)
Because a URL kwarg is popped before the spec sees the arguments, it never counts
as an unknown argument (the REJECT policy ignores it) and never lands in the
service's validated payload — it routes only through view.kwargs. A name
can't collide with a reserved transport key (ordering / page / limit, or the
request / user / data / instance / serializer / collection pool
seeds); colliding with an ordinary spec input is allowed and is the intended way
to route a route-capture the spec also reads directly.
A capture the spec genuinely cannot run without takes required=True:
The name joins the tool's inputSchema required list, so the model is told up
front — and, because a schema hint is only a hint, a call that omits it comes back
as an isError validation result naming the missing argument rather than failing
somewhere less legible. required can't be combined with a default (a default
always satisfies the argument, so requiring it would be a no-op); that raises at
registration.
A reflected **extras key is not a route capture¶
A selector typed def list_widgets(user, **extras: Unpack[WidgetExtras]) that
reads extras["project_pk"] already has that key reflected into the tool's
inputSchema by drf-services (0.26+) — no UrlKwarg needed for the selector
itself, which receives it through the spec params. Marking it InputRequired
(drf-services 0.28+) makes the model supply it; that is a schema statement and
changes nothing about where the value lands.
The two declarations answer different questions, and only one of them puts a value on the request:
reflected **extras key (± InputRequired) |
registered UrlKwarg |
|
|---|---|---|
In the inputSchema |
yes | yes |
| Can be required | yes (InputRequired) |
yes (required=True, plus an isError result when omitted) |
| Reaches the selector | yes, as a spec param | yes, via the view.kwargs spread |
Reaches view.kwargs |
no | yes |
| Ranks above caller-supplied params | no — it is caller input | yes |
So anything that reads request state rather than its own arguments — a
spec.kwargs provider, extend_queryset, a permission class, an
output_serializer_context provider — sees nothing for a reflected-only key. A
scoping provider doing view.kwargs.get("project_pk") returns None and
mis-scopes every call instead of failing: the failure mode worth naming here
is that it is silent.
Register the UrlKwarg as well when the value is scope. It is a strict superset
— the selector still receives it in **extras, and the schema keeps one property
and one required entry (an explicit UrlKwarg wins the merge over a reflected
key of the same name).
That split mirrors HTTP, where a route capture arrives in the URL and never in the
body — which is what makes it unspoofable. Over MCP the arguments are whatever the
model chose; a UrlKwarg value outranks them. If a provider scopes by it, it has
to come through the channel that carries that precedence.
UrlKwarg is
drf-services' type,
re-exported here — the declaration is the same whichever transport carries it, and
the two adapters that each had a copy had already drifted apart on which names
they reserved. from rest_framework_mcp import UrlKwarg keeps working.
Requires djangorestframework-services>=0.28.1.
SelectorSpec for resources¶
register_resource(selector=...) requires a
SelectorSpec,
mirroring register_service_tool(spec=ServiceSpec(...)). The unit of registration
is a spec on both surfaces.
.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 rest_framework_mcp import SelectorKind, SelectorSpec
server.register_resource(
name="invoice",
uri_template="invoices://{pk}",
selector=SelectorSpec(
kind=SelectorKind.RETRIEVE,
selector=get_invoice,
output_serializer=InvoiceOutputSerializer,
kwargs=with_tenant,
),
)
Bare callables are rejected with TypeError — this is intentional: keeping
the imperative surface symmetric with register_service_tool makes the spec the
single point where output serializers and kwargs providers attach. Use the
@server.resource(uri_template=...) decorator if you'd rather skip the
boilerplate; it wraps the function in a SelectorSpec for you.
Per-tool registration kwargs¶
Beyond permissions=, output_format=, and include_structured_content=,
register_service_tool / register_selector_tool (and their decorator
forms) accept three behavior knobs:
argument_binding=— how the 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 the pool.ArgumentBinding.SPREAD_AUTHOR_WINS(default for selector tools) — every key from the validated arguments is spread into the pool as a top-level kwarg, so selectors can declare individual parameters (def list_drafts(*, project_id, page=1)).spec.kwargs(...)wins on conflict so author-declared invariants beat client input.ArgumentBinding.SPREAD_CALLER_WINS— likeSPREAD_AUTHOR_WINSbut the spread wins on conflict, sospec.kwargs(...)supplies client-overridable defaults.ArgumentBinding.AUTO— resolve per spec type (service →BUNDLE, selector →SPREAD_AUTHOR_WINS).
Reserved transport-pool seeds (request / user / data / instance /
serializer) and the
selector pipeline keys (ordering / page / limit) are stripped
from the spread regardless of mode so clients can't poison
transport-controlled state.
unknown_arguments=— howargumentskeys outside the binding's declared field set are handled.UnknownArguments.REJECT(default) — the validator rejects unknown keys with-32602, and the outerinputSchemaadvertises"additionalProperties": false. This holds only when the binding has aninput_serializerto validate against: a serializer-less binding has no declared field set, soREJECTcan't fire and its schema stays open ("additionalProperties": true) to match the runtime.UnknownArguments.PASSTHROUGH—"additionalProperties": true; unknown keys survive validation and are merged onto the validated payload before binding.UnknownArguments.IGNORE—"additionalProperties": true; unknown keys are silently dropped (the historic DRF default).
Selector tools' pipeline-reserved keys are always treated as "known", so the policy doesn't fight the post-fetch pipeline.
always_listed=— whenREST_FRAMEWORK_MCP["FILTER_LISTINGS_BY_PERMISSIONS"]is enabled, bindings are dropped fromtools/list/resources/list/prompts/listwhen their permissions deny the current caller. Settingalways_listed=Truekeeps the binding visible as a discovery aid; the permission still gates the actual invocation.
Tool annotations¶
Every tool advertises the MCP-standard ToolAnnotations hints, derived
from what the server already knows about the tool's mutation profile —
so downstream clients get correct readOnlyHint / destructiveHint
without a hand-set flag:
- Selector tools are reads →
{"readOnlyHint": true}. - Service tools are mutations →
{"readOnlyHint": false, "destructiveHint": true}. - Chain tools are read-only only when every step is a selector; any service step makes the whole chain a mutation.
destructiveHint / idempotentHint are spec-meaningful only when
readOnlyHint is false, so a read-only tool emits neither. Pass
annotations= at registration to override or extend the derived hints —
the explicit values win:
server.register_service_tool(
name="invoices.mark_paid",
spec=mark_paid_spec,
# An idempotent, non-destructive mutation:
annotations={"destructiveHint": False, "idempotentHint": True},
)
The merged bundle lands on binding.annotations and on the tools/list
wire payload.
Generic _meta¶
Separate from annotations — which is a closed, spec-defined set of
client hints — the base protocol gives most wire objects a free-form
_meta object. It is the extension namespace: each protocol extension
owns a top-level key inside it, and a server may add its own.
Pass meta= at any registration surface (register_service_tool,
register_selector_tool, register_chain_tool, register_resource,
register_prompt, the matching decorators, or a ToolDefinition /
SelectorDefaults / ServiceDefaults):
server.register_selector_tool(
name="invoices.list",
spec=list_invoices_spec,
meta={"example.com/panel": {"href": "panel://invoices"}},
)
The bundle lands on binding.meta and is emitted verbatim under the
"_meta" key of the binding's listing entry — tools/list,
resources/list, resources/templates/list, prompts/list — and, for a
resource, on the contents block resources/read returns. It is
omitted entirely when empty.
Nothing here validates, reserves, or rewrites a key: the whole point of
_meta is that its contents are opaque to the transport. On the
tools/call result envelope _meta is per-call rather than per-binding,
so it is a build_tool_result(..., meta=...) argument instead of
something sourced from the binding.
Bulk registration¶
For projects that register many tools in one place, the
register_tools(server, definitions, *, selector_defaults=None,
service_defaults=None) entry point collapses the boilerplate. Pass a
list of ToolDefinition.service(...) / ToolDefinition.selector(...)
instances plus per-kind defaults that fill in fields each definition
leaves as None. The function loops over the existing per-tool
registration methods, so every guarantee and bug fix applies
automatically.
from rest_framework_mcp import (
ServiceDefaults,
SelectorDefaults,
ToolDefinition,
register_tools,
)
register_tools(
server,
[
ToolDefinition.service(name="invoices.create", spec=create_spec),
ToolDefinition.service(name="invoices.update", spec=update_spec),
ToolDefinition.selector(name="invoices.list", spec=list_spec),
],
service_defaults=ServiceDefaults(permissions=[ScopeRequired(["invoices:write"])]),
selector_defaults=SelectorDefaults(permissions=[ScopeRequired(["invoices:read"])]),
)
Per-definition kwargs win over defaults on conflict; None is the
"no override" sentinel across both layers.
Transport-neutral invocation: call_tool¶
server.call_tool(name, arguments, *, user, request=None) invokes a
registered spec-backed tool off the HTTP / JSON-RPC path and returns
the same ToolResult the wire handlers build. An in-process consumer — a
bridge, a Pydantic-AI toolset, a management command — uses it instead of
re-implementing dispatch:
result = server.call_tool("invoices.create", {"number": "A-1"}, user=request.user)
result.structured_content # the rendered payload
It is built on djangorestframework-services' transport-neutral
dispatch_spec / render_spec_output / enforce_permissions, so the
spec-execution core (instance resolution, input validation, the
service / selector run, the output-selector re-fetch, queryset shaping
including filter_set, and the retrieve nullability contract) is shared
with the HTTP transport rather than reproduced.
It honours the binding's argument_binding / unknown_arguments policies
(mapped onto dispatch_spec's) and the spec's permission_classes in two
layers: an upfront enforce_permissions call for the class-level
has_permission check, plus the on_target_resolved=enforce_permissions hook
for object-level checks on the resolved target.
It does not layer on the read-shaped transport extras (pagination,
ordering, a selector binding's MCP-only input_serializer); those stay with
the wire handlers, as do the transport-level MCP permissions / rate limits.
Chain tools are unsupported — they orchestrate several specs and raise
TypeError. A service raising ServiceValidationError / ServiceError and a
missing required instance come back as isError results; a denied permission
or malformed input raises, for the caller to map.
Full in-process transport: acall_tool / list_tools¶
call_tool is the spec core. When an in-process consumer needs the whole
transport — exactly what a remote MCP client sees — MCPServer exposes two
async-friendly siblings that route through the same wire handlers:
page = server.list_tools(user=request.user, request=request) # one tools/list page
page["tools"] # merged inputSchema per tool
page["nextCursor"] # pass back to list_tools(cursor, ...) to paginate
result = await server.acall_tool("invoices.list", {"ordering": "-amount", "page": 1},
user=request.user, request=request)
result["structuredContent"] # the wire's result payload (dict, not ToolResult)
list_tools(cursor=None, *, user, request=None)returns one page of the tool catalog with the same mergedinputSchema(serializer fields plus a selector tool's filter / ordering / pagination arguments and theadditionalPropertiespolicy), the sameFILTER_LISTINGS_BY_PERMISSIONSper-caller filter, and the same opaque-cursor pagination the HTTP transport uses.acall_tool(name, arguments=None, *, user, request=None)invokes a tool with the full transport applied: the transport-level MCP permissions and rate limits, the selector post-fetch pipeline (filter / order / paginate), a selector binding's MCP-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 the django-ag-ui bridge consumes to run drf-mcp tools in-process
with HTTP-equivalent semantics.
Documenting tools¶
A tool's description and its argument descriptions are the entire contract a model has to work from. Both have a channel; neither is filled in for you.
The tool description¶
description= on any register_* call. There is no docstring fallback for
spec registration — a docstring is written for the next developer, not for a
model choosing between tools, so promoting one silently would ship prose nobody
reviewed for that audience.
Registering without one emits UndescribedToolWarning, and
REST_FRAMEWORK_MCP["REQUIRE_TOOL_DESCRIPTIONS"] = True turns that into an
ImproperlyConfigured. This mirrors REQUIRE_TOOL_PERMISSIONS: two properties
are equally required for a tool to be usable — something must gate the call, and
something must say what the call does.
Per-argument descriptions¶
Do not restate argument meaning in the tool description. Three channels feed
inputSchema.properties.*.description directly:
# 1. Serializer fields — `help_text` becomes the property description.
class ArchiveWidgetInput(serializers.Serializer):
widget_id = serializers.IntegerField(
help_text="Primary key of the widget. Not the public slug.",
)
# 2. URL kwargs — `UrlKwarg` takes a description of its own.
server.register_selector_tool(
name="list_loan_documents",
spec=documents_spec,
description="List the documents filed against a loan.",
url_kwargs=[
UrlKwarg(
name="loan_pk",
type="string",
required=True,
description="Primary key of the *loan*, not the borrower.",
)
],
)
The third is drf-services' Annotated marker vocabulary on an
Unpack[TypedDict] extras key, which today carries InputRequired and
NotClientInput but no description — a key documented only that way has to fall
back to the tool description until that gap is closed upstream.
Duplicated prose is where descriptions get longest and go stale, so an argument
whose name doesn't match the entity it identifies belongs in help_text or
UrlKwarg(description=…), written once.
Tools vs resources¶
| Tools | Resources | |
|---|---|---|
| MCP capability | tools |
resources |
| Mutation? | Yes (services) | No (selectors) |
| Addressable? | By name (invoices.create) |
By URI (invoices://42) |
| Dispatched via | tools/call |
resources/read |
| Backed by | ServiceSpec |
SelectorSpec |
| Schema advertised | inputSchema + optional outputSchema |
mimeType |
Tools are imperative (the client decides when to call them and supplies arguments). Resources are read-only and addressable by URI; they have a stable identifier and the client can rely on the same URI returning a consistent shape over time.
URI templates¶
Resource URIs follow a small subset of RFC 6570.
Each {var} placeholder becomes a kwarg in the selector's signature:
server.register_resource(
name="invoice",
uri_template="invoices://{pk}",
selector=SelectorSpec(
kind=SelectorKind.RETRIEVE,
selector=get_invoice, # def get_invoice(*, pk): ...
output_serializer=InvoiceOutputSerializer,
),
)
Concrete URIs (no placeholders) appear in resources/list; templated ones
appear in resources/templates/list so clients can fill them in.
Resource body encoding¶
A resource advertises a mime_type and returns a body. Those are two separate
decisions, so encoding= is declared rather than inferred from the mime type —
sniffing would silently change the body for anyone already advertising
something other than JSON.
server.register_resource(
name="changelog",
uri_template="docs://changelog",
selector=SelectorSpec(kind=SelectorKind.RETRIEVE, selector=read_changelog),
mime_type="text/markdown",
encoding=ResourceEncoding.TEXT,
)
ResourceEncoding.JSON (the default) pretty-prints the selector's return
value. ResourceEncoding.TEXT returns it verbatim, which is what Markdown,
CSV, plain text and HTML need — under JSON the document would come back
wrapped in a quoted string literal instead of as itself. A TEXT resource's
selector must return a str; anything else is reported as a JSON-RPC error on
the read rather than raising through the transport.
Interactive views (MCP Apps)¶
A tool can declare an HTML view that an MCP host renders inline in the chat, under the MCP Apps extension. It layers over the base protocol this package already speaks, so there is no protocol bump and no transport change.
The host/server split is the whole shape of it. This package declares:
server.register_ui_resource(
name="invoices_table",
uri="ui://invoices/table.html",
template_name="mcp/invoices_table.html",
ui=UIResourceMeta(
csp=UICsp(connect_domains=["https://api.example.com"]),
prefers_border=True,
),
)
The host renders: it builds the sandboxed iframe, constructs and enforces
the CSP from what you declared, and runs the ui/* postMessage bridge. None of
that is implemented here, and none of it should be.
A view is an ordinary resource — one URI namespace with your data resources,
listed in resources/list, and guardable with permissions= — with three
things fixed for you: the text/html;profile=mcp-app mime type, TEXT body
encoding, and a _meta bundle under the extension's key. Give it exactly one
content source: template_name= (a Django template, the idiomatic choice),
html= (a literal document), or selector= (a zero-argument callable).
Views are unguarded by default. The MCP session is already authenticated,
a view is a static asset rather than tenant data, and hosts may prefetch one
before any tool call. Pass permissions= if your project wants otherwise.
Keep tenant data out of the view
Hosts may prefetch and cache a view, so it is a shell that hydrates itself at runtime from tool results — which is why the template renders with no context. This is a house rule, not a spec rule, and it is the one thing a Django author's instinct gets wrong: rendering the queryset into the template is normally the right answer, and here it would leak data across the host's cache.
A tool then points at the view, and the host renders that tool's result inside it instead of showing raw JSON:
server.register_selector_tool(
name="list_invoices",
spec=list_invoices_spec,
ui=UIToolMeta(resource_uri="ui://invoices/table.html"),
)
The render payload is the structuredContent the tool already emits — no
second serialisation path — and a tools/call the view makes comes back
through the ordinary endpoint, inheriting your auth, MCPPermissions and rate
limits unchanged.
Three ways a link can be wrong all fail the same way at runtime — a view that silently never renders — so all three are refused at registration:
| Mistake | Why it's caught |
|---|---|
resource_uri names no view on this server |
The host resolves it against the same server, so a typo reaches it as a dangling reference. Register the view before the tool that links to it. |
The tool has include_structured_content=False |
That is the render payload; the view would come up blank. Checked against the effective value, so a project that turned it off globally is caught too. |
Both ui= and a "ui" key in meta= |
Both write the same _meta key, so one would quietly overwrite the other. |
visibility declares who may call the tool — UIVisibility.MODEL, APP, or
both. It is host-enforced: a host is required not to offer the model a tool
whose visibility omits MODEL, which makes an APP-only tool a useful shape
for a fine-grained operation that exists to serve the view rather than the
conversation. This server declares the field and does not filter tools/list
on it — a client that doesn't implement the extension wouldn't honour the rule
anyway.
A client advertises Apps support as capabilities.extensions on initialize,
which is parsed onto ClientCapabilities.extensions. Advertisement is
one-directional, client → server — the spec defines no matching server
capability, so nothing is sent back, and _meta.ui is emitted unconditionally.
Unknown _meta keys are ignorable by design, so a client that doesn't
implement Apps is unaffected.
Dispatch flow¶
The MCP package owns its own dispatch flow. It does not import
_execute_mutation or anything under rest_framework_services.viewsets.
tools/call:
- Look up the
ToolBindingby name; reject unknown. - Evaluate per-binding
MCPPermissionclasses (AND-combined). Denial → 403 withWWW-Authenticatecarrying any required scopes. - 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"). - 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. - Build a kwarg pool:
{request, user, data}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. The serializer
rejecting the arguments shape stays a JSON-RPC
-32602. A service raising on well-shaped input —ServiceValidationErrororServiceError— returns 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). Chain steps addfailedStep. 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. - Wrap as a
ToolResultwithOutputFormat-driven encoding for the human- readablecontent[0]block.structuredContentis always JSON.
RETRIEVE selector tools mirror the sister repo's read semantics: a
QuerySet return is materialized via .first(), and a missing row is a
not_found isError result — unless the spec sets allow_none=True
(the nullable-resource contract), which renders a successful null
result instead. LIST tools advertise a kind-aware outputSchema: a bare
array schema unpaginated, the {items, page, totalPages, hasNext}
envelope with paginate=True (enable pagination for a fully
spec-compliant object-shaped structuredContent).
resources/read:
- 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.
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.