Skip to content

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 (from django-service-specs, which djangorestframework-services re-exports) is the one declaration site. MCPServer.register_specs is the MCP end of it.

Declare once

# orders/registry.py
from django_service_specs 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.

Both are the djangorestframework-services specs (from rest_framework_services import ServiceSpec, SelectorSpec), because this server dispatches through drf-services, which runs a DRF-bound spec. A SpecRegistry also accepts the kernel's own django_service_specs.ServiceSpec / SelectorSpec and any object with as_kernel_spec(); register_specs refuses such an entry with a TypeError naming the tool and the type it found. For a kernel spec it adds that django_service_specs.dispatch runs one with no transport; for anything else it names the import to declare the operation with.

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,
            "description": "List orders, newest first by default.",
        },
        "refund_order": {
            "annotations": {"title": "Refund an order"},
            "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, server_arguments, and the rest.

A fact about the operation is not an MCP knob, though, even when MCP has a hint for it. Whether repeating a refund is safe is true of the refund on every transport, so it is declared once on the spec, where each transport reads it:

# orders/specs.py
refund_order = ServiceSpec(
    service=refund_order_service,
    input_serializer=RefundInput,
    permission_classes=[IsAdminUser],
    # Declared, not left unsaid: a second call refunds again.
    idempotent=False,
)

The server turns that declaration into "idempotentHint": false on the tool. A spec that declares nothing (idempotent=None, the default) gets no hint, and an annotations= override still wins over the derived value. See Tool annotations.

What the entry already says

A spec declares what a caller with no HTTP request has to be told, because the URLconf and query string tell an HTTP one for free: the route captures and query values it reads, as its reads, and the output fields an agent sees, as its field_audiences. Every registrar reads both off the spec, so the same entry mounted here and in an in-process Pydantic-AI toolset synthesises the same absent request:

from django_service_specs import SelectorKind
from rest_framework.permissions import IsAuthenticated
from rest_framework_services import SelectorSpec, UrlKwarg

orders_spec = SelectorSpec(
    kind=SelectorKind.LIST,
    selector=list_project_orders,
    permission_classes=[IsAuthenticated],
    reads=[UrlKwarg("project_pk", type="integer")],
)
registry.register("list_project_orders", orders_spec)

server.register_specs(registry)  # the tool takes project_pk, declared once

SpecRegistry.register takes no agent_contract= (drf-services 0.58.0 removed it). To add channels to an entry registered elsewhere, re-declare the spec it holds with dataclasses.replace and mount the result:

from dataclasses import replace

from django_service_specs import SpecRegistry
from rest_framework_services import QueryParam, UrlKwarg

entry = registry.get("list_project_orders")
scoped = SpecRegistry(
    [
        replace(
            entry,
            spec=replace(
                entry.spec,
                reads=[UrlKwarg("project_pk", type="integer"), QueryParam("since")],
            ),
        )
    ]
)

server.register_specs(scoped)  # the tool takes project_pk and since

register_specs passes nothing about an entry's channels: each registrar reads the spec's own reads and field_audiences, so the spec above registered with register_selector_tool(spec=...) is the same tool, advertising the same arguments.

A QueryParam read naming one of the selector's own parameters is served, on every route: the spec says where the parameter comes from, so it is not a shadow. It is advertised once, as the selector's parameter, and the value reaches both the parameter and request.query_params. A query param declared at the mount (query_params=, or an agent_contract= carrying one) and named like a selector parameter is still refused, because that value would be routed away from the parameter.

Answering an argument for the caller: server_arguments

A mount that already knows an argument - the project this server is scoped to, say - answers it with a ServerArguments from django_service_specs, as an override like any other:

from django_service_specs import ServerArguments

server.register_specs(
    registry,
    overrides={
        "list_project_orders": {
            "server_arguments": ServerArguments(
                values={"project_pk": 7},  # always this project
                withheld={"since"},  # never the caller's; the default applies
            ),
        },
    },
)

Both names leave the tool's inputSchema and its required list, so the model is never asked for them. A caller sending one anyway has its value dropped before anything reads it, and the server logs the tool and the argument names at WARNING, never the values. Registration checks the binding: a name the spec does not declare, a value its declared type refuses, and a withheld name with no default each raise ImproperlyConfigured naming the tool. See server-supplied arguments for the full rule.

The values are fixed for the life of the server. A value computed per request, such as a project read off the signed-in user, is not something server_arguments carries; scope it in a kwargs= provider or a permission.

The url_kwargs, query_params and agent_contract overrides are deprecated and warn: each declares at the mount what the spec declares itself. While they last, an explicit url_kwargs / query_params replaces the spec's reads of that kind in what the tool advertises, and agent_contract replaces all of them and the audiences. None of them takes a channel away from dispatch, which still reads the spec's own declaration; only withheld does.

Two things fail loudly rather than quietly:

  • An overrides key naming a spec the registry doesn't hold raises ValueError. A typo would otherwise be a silent no-op.
  • A knob that belongs to the other spec kind — paginate on a ServiceSpec — raises TypeError from 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

A spec declaring no permissions at all never reaches this server: SpecRegistry.register refuses it (drf-services 0.58.0), because off HTTP there is no view whose policy it could inherit. permission_classes=[] still registers, as a spec with no guards, and register_specs calls the same per-tool methods you would have called, so the permission-declaration guard applies to it. A spec with empty permission_classes and no permissions override is refused with ImproperlyConfigured — or, under REST_FRAMEWORK_MCP["REQUIRE_TOOL_PERMISSIONS"] = False, downgraded to an UnguardedToolWarning.

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:

server.register_specs(
    registry,
    overrides={"refund_order": {"permissions": [ScopeRequired("orders:write")]}},
)