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 | True → permission_tier="read_write". |
admin |
bool |
No | True → permission_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
readtoken calling awrite=Trueaction receives aPermissionError→isError=Trueresponse. - 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 dispatcherfeatures/mcp-heavy.md— for dispatcher actions that return large result sets