Register tools from a shared spec registry¶
If MCP is the only way your project exposes its services, keep calling
register_service_tool / register_selector_tool directly — this recipe buys
you nothing.
It pays off when the same specs are exposed over more than one transport:
MCP here, plus an agent toolset via
djangorestframework-pydantic-ai,
plus HTTP views. Each transport has to be told which specs it exposes, so you
end up writing the list once per transport — and the lists drift. A spec gets
added to the MCP wiring and forgotten in the agent's, or the same operation
ends up named create_order here and something else there.
SpecRegistry (djangorestframework-services 0.27+) is the one declaration
site. MCPServer.register_specs is the MCP end of it.
Declare once¶
# orders/registry.py
from rest_framework_services import SpecRegistry
registry = SpecRegistry()
# orders/apps.py
from django.apps import AppConfig
class OrdersConfig(AppConfig):
name = "orders"
def ready(self) -> None:
from orders import specs
from orders.registry import registry
registry.register("list_orders", specs.list_orders, tags=("read", "public"))
registry.register("get_order", specs.get_order, tags=("read", "public"))
registry.register("refund_order", specs.refund_order, tags=("write", "admin"))
The registry holds only what every transport agrees on — which spec, its canonical name, its tags.
Register them as tools¶
# orders/mcp.py
from rest_framework_mcp import MCPServer
from orders.registry import registry
server = MCPServer(name="orders")
server.register_specs(registry)
Each entry becomes a tool: a ServiceSpec goes through
register_service_tool, a SelectorSpec through register_selector_tool.
Registration order is the registry's order, so tools/list stays stable.
This is a source for the server's own ToolRegistry, not a replacement for
it. Every tool still lands as a normal binding, and names still share the one
MCP tool namespace — registering a name twice raises, exactly as before.
Keep the MCP knobs where they belong¶
The registry is deliberately transport-agnostic: it has no idea what
paginate or annotations mean. Everything MCP-specific stays here, per tool,
via overrides:
server.register_specs(
registry,
overrides={
"list_orders": {
"paginate": True,
"ordering_fields": ["created_at", "total"],
},
"refund_order": {
"annotations": {"destructiveHint": True, "idempotentHint": False},
"permissions": [ScopeRequired("orders:write")],
},
},
)
Each value is the keyword arguments for that entry's registration method, so
anything those methods accept works — title, output_format,
include_output_schema, rate_limits, url_kwargs, and the rest.
Two things fail loudly rather than quietly:
- An
overrideskey naming a spec the registry doesn't hold raisesValueError. A typo would otherwise be a silent no-op. - A knob that belongs to the other spec kind —
paginateon aServiceSpec— raisesTypeErrorfrom the registration method, since it has no such parameter.
Two mounts, two surfaces¶
The registry does not force one surface. Filtered views (by_tag, subset)
each return a new registry, so a multi-mount deployment feeds each server
its own projection with no shared state:
internal = MCPServer(name="orders-internal")
public = MCPServer(name="orders-public")
internal.register_specs(registry) # everything
public.register_specs(registry.by_tag("public")) # reads only
Independent registries work too, when the surfaces have nothing in common: names are unique within a registry, so two registries may reuse one.
Permissions are not bypassed¶
register_specs calls the same per-tool methods you would have called, so the
permission-declaration guard still applies to every entry. A spec with no
permission_classes and no permissions override still raises
UnguardedToolWarning — or ImproperlyConfigured under
REST_FRAMEWORK_MCP["REQUIRE_TOOL_PERMISSIONS"] = True.
Bulk registration is a convenience, not a way to register a surface you
haven't secured. If a spec can't declare its own permission_classes, guard it
at the binding: