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 ServiceSpec # re-exported for ergonomics
spec = ServiceSpec(
service=create_invoice,
input_serializer=InvoiceInputSerializer,
output_serializer=InvoiceOutputSerializer,
output_selector=None, # optional post-call shaping
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.
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_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.
.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 SelectorSpec
server.register_resource(
name="invoice",
uri_template="invoices://{pk}",
selector=SelectorSpec(
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.
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(
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. - Validate
argumentsviaspec.input_serializer(DRFSerializer, bare@dataclassauto-wrapped inDataclassSerializer, orNone). - Build a kwarg pool:
{request, user, data}. resolve_callable_kwargs(spec.service, pool)→run_service(spec.service, kwargs, atomic=spec.atomic).- Map
ServiceValidationError→-32602andServiceError→-32000. Validation errors carrydata.detail(per-field DRF detail). SettingREST_FRAMEWORK_MCP["INCLUDE_VALIDATION_VALUE"] = Trueadditionally echoes the offendingargumentsdict back asdata.value— handy for debugging schema mismatches against opaque client SDKs, off by default because the dict can carry sensitive payloads. - Optionally post-process via
spec.output_selector(also a callable; same kwarg-pool dispatch). - Render through
spec.output_serializerif set, else pass through. - Wrap as a
ToolResultwithOutputFormat-driven encoding for the human- readablecontent[0]block.structuredContentis always JSON.
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.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). 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.
DELETE /mcp/ with a session id terminates that session immediately. GET is
405 in v1 — server-initiated SSE is on the roadmap (see Phase 6 in the project
plan).
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_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.
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.