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 —
input_serializer_context/output_serializer_context(onServiceSpec) andoutput_serializer_context(onSelectorSpec) are invoked with the synthesised view + DRF request and forwarded ascontext=to the serializer constructor on both sync and async dispatch paths. SelectorSpec.kind— requiredSelectorKinddiscriminator (LISTorRETRIEVE). It drives themany=flag on the output serializer and gates which post-fetch knobs the registration accepts (aRETRIEVEspec rejectsfilter_set/ordering_fields/paginate).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_mcp import MCPServiceView, ServiceSpec
def with_tenant(view: MCPServiceView, 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 :class:MCPServiceView (synthesised 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.
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.ArgumentBinding.DATA_ONLY(default for service tools) — onlydata=<validated>enters the pool.ArgumentBinding.MERGE(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.REPLACE— likeMERGEbut the spread wins on conflict, sospec.kwargs(...)supplies client-overridable defaults.
Reserved transport-pool seeds (request / user / data) 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) — outerinputSchemaadvertises"additionalProperties": falseand the validator rejects unknown keys with-32602.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.
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.
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.
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.