Feature: @mcp_dispatcher + @mcp_action

Audience: Developers managing large API surfaces
Package version: 1.1.x


The problem this solves

Auto-discovery registers one MCP tool per ViewSet action. A typical DRF project with 20 models exposes 100–200 tools; a large platform like Nautobot or NetBox exposes 1,000–2,000. Every tool's full schema loads into the agent's context window on tools/list. At hundreds of tools, the schema overhead alone exhausts the context budget before the agent can do any useful work.

The dispatcher pattern collapses N tools into 1. The agent calls a single dispatcher tool with an action parameter to route to the underlying operation. Context overhead stays constant regardless of how many actions the dispatcher covers.

See Dispatcher Pattern and The Token Problem for the design rationale and measured token numbers.


How it works

A dispatcher is a class where each method decorated with @mcp_action becomes a routable action. The class is registered as a single MCP tool. Agents call it with {"action": "action_name", "params": {...}}. Passing action="help" (or omitting action) returns a structured listing of all available actions.


Decorators

@mcp_dispatcher

from frisian_mcp import mcp_dispatcher

@mcp_dispatcher(
    name="orders",
    description="Manage customer orders: list, create, retrieve, update, cancel.",
    permission_classes=[IsAuthenticated],  # optional gateway-level guard
)
class OrdersDispatcher:
    ...
Parameter Type Required Description
name str Yes Tool name in tools/list.
description str Yes Human-readable description for the agent.
permission_classes list No DRF permission classes for the dispatcher entry point. Per-action tiers are enforced separately.

@mcp_action

from frisian_mcp import mcp_action

@mcp_action(
    name="list",
    description="List all orders. Supports status and date_from filters.",
    params={
        "status": "Filter by status: open | paid | shipped | cancelled",
        "date_from": "ISO 8601 date — return orders on or after this date",
        "limit": "Maximum number of results (default 20)",
    },
    write=False,
    admin=False,
)
def list_orders(self, arguments: dict, request: HttpRequest) -> list:
    ...
Parameter Type Required Description
name str Yes Action name used in the action field of a dispatch call.
description str Yes Shown in help-mode responses.
params dict[str, str] No Param name → human-readable hint. Shown in help mode.
input_schema dict No Full JSON Schema for per-call validation. Optional; params is sufficient for most cases.
write bool No Truepermission_tier="read_write".
admin bool No Truepermission_tier="admin".

Full example

# myapp/dispatchers.py

from django.http import HttpRequest
from rest_framework.permissions import IsAuthenticated
from frisian_mcp import mcp_action, mcp_dispatcher

@mcp_dispatcher(
    name="orders",
    description="Manage customer orders: list, retrieve, create, cancel.",
    permission_classes=[IsAuthenticated],
)
class OrdersDispatcher:

    @mcp_action(
        name="list",
        description="List orders, optionally filtered by status and date_from.",
        params={
            "status": "open | paid | shipped | cancelled",
            "date_from": "ISO 8601 date — return orders on or after this date",
            "limit": "Maximum number of results (default 20)",
        },
    )
    def list_orders(self, arguments: dict, request: HttpRequest) -> list:
        from myapp.models import Order
        qs = Order.objects.all()
        if status := arguments.get("status"):
            qs = qs.filter(status=status)
        if date_from := arguments.get("date_from"):
            qs = qs.filter(created_at__gte=date_from)
        limit = int(arguments.get("limit", 20))
        return list(qs.values("id", "status", "total", "created_at")[:limit])

    @mcp_action(
        name="retrieve",
        description="Get a single order by ID.",
        params={"order_id": "Primary key of the order."},
    )
    def retrieve_order(self, arguments: dict, request: HttpRequest) -> dict:
        from myapp.models import Order
        try:
            order = Order.objects.get(pk=arguments["order_id"])
        except Order.DoesNotExist:
            raise ValueError(f"Order {arguments['order_id']} not found")
        return {"id": order.pk, "status": order.status, "total": str(order.total)}

    @mcp_action(
        name="cancel",
        description="Cancel an open order.",
        params={"order_id": "Primary key of the order to cancel."},
        write=True,
    )
    def cancel_order(self, arguments: dict, request: HttpRequest) -> dict:
        from myapp.models import Order
        try:
            order = Order.objects.get(pk=arguments["order_id"])
        except Order.DoesNotExist:
            raise ValueError(f"Order {arguments['order_id']} not found")
        if order.status not in ("open", "paid"):
            raise ValueError(f"Cannot cancel order with status '{order.status}'")
        order.cancel()
        return {"order_id": order.pk, "status": order.status}

Import the dispatcher class from your AppConfig so it registers at startup:

# myapp/apps.py

from django.apps import AppConfig

class MyAppConfig(AppConfig):
    name = "myapp"

    def ready(self) -> None:
        import myapp.dispatchers  # noqa: F401

Agent interaction

The agent calls the dispatcher like any other tool:

{
  "method": "tools/call",
  "params": {
    "name": "orders",
    "arguments": {
      "action": "list",
      "params": {"status": "open", "limit": 10}
    }
  }
}

Calling with action="help" or omitting action returns a structured listing:

{
  "actions": [
    {"name": "list", "description": "List orders...", "params": {...}},
    {"name": "retrieve", "description": "Get a single order...", "params": {...}},
    {"name": "cancel", "description": "Cancel an open order.", "params": {...}, "tier": "read_write"}
  ]
}

Dispatcher shadowing

When a dispatcher named orders is registered, auto-discovery suppresses any flat tools whose name prefix matches — e.g., orders.list, orders.create, order.list (singular form). This prevents duplicate tool registration. The dispatcher replaces the flat tool surface for that resource.


Per-action permission tiers

The dispatcher entry point itself is always registered at read tier so it appears in tools/list for all callers. Per-action enforcement happens at dispatch time:

  • A caller with only a read token calling a write=True action receives a PermissionErrorisError=True response.
  • The help response only shows actions the caller's tier can reach.

See also

  • Dispatcher Pattern — design rationale and token efficiency measurements
  • The Token Problem — why this matters at scale
  • features/mcp-tool.md — for standalone tools that don't need a dispatcher
  • features/mcp-heavy.md — for dispatcher actions that return large result sets