Skip to content

Expose a polymorphic action as per-variant tools

drf-services 0.25 added PolymorphicServiceSpec — one action that accepts N mutually exclusive payload shapes and picks a variant at dispatch time from a discriminator callable reading the request body. It exists because an HTTP URL is a scarce, addressable resource: sometimes you want one endpoint to accept a tagged-union body instead of N endpoints.

MCP has no such scarcity. You can register as many tools as you like, and a model calls tools far more reliably when each is a single-purpose tool with a flat schema than when it has to pick a anyOf arm and set a discriminator field correctly. So the right move is not to mirror the union — it's to expand a PolymorphicServiceSpec into one tool per variant. The tool name selects the variant, so the discriminator never runs and the model never sees a union.

PolymorphicServiceSpec.specs is a public Mapping[str, ServiceSpec] (variant key → full ServiceSpec, each with its own input_serializer, service, and output pipeline), so the expansion is a loop over existing API:

from rest_framework_services import PolymorphicServiceSpec, ServiceSpec
from rest_framework_mcp import MCPServer

# The same spec you'd hand a viewset's `action_specs` on the HTTP side.
moderate = PolymorphicServiceSpec(
    discriminator=lambda *, data: data["op"],       # HTTP-only; unused here
    specs={
        "approve": ServiceSpec(service=approve_document, output_selector_spec=DOC_OUT),
        "reject": ServiceSpec(service=reject_document, output_selector_spec=DOC_OUT),
    },
)

server = MCPServer(name="docs")
for variant_key, variant in moderate.specs.items():
    server.register_service_tool(
        name=f"moderate_document_{variant_key}",   # → moderate_document_approve, …
        spec=variant,
        description=f"Moderate a document ({variant_key}).",
    )

Each registered tool is an ordinary service tool: its inputSchema comes from that variant's input_serializer, and dispatch runs the usual input_serializer → run_service(atomic) → output pipeline. Nothing about PolymorphicServiceSpec leaks into the wire — the discriminator is a server-side HTTP concern, and the MCP surface stays a flat menu of clear tools.

Give each variant its own permissions, output_format, or annotations as needed — they're independent tools that happen to share an origin spec:

server.register_service_tool(
    name="moderate_document_reject",
    spec=moderate.specs["reject"],
    permissions=[ScopeRequired("documents:moderate")],
    annotations={"destructiveHint": True},
)

If several variants genuinely belong together as one atomic operation (retrieve-then-write, say), that's a chain, not a union — see Chain several specs into one tool.