MCP Tools
Tool registry
tools/base.py defines MCPTool (abstract base class) and MCPToolRegistry. A single global tool_registry instance is shared across the whole Django process.
Tools are registered in handlers/tools.py at import time. That module is imported by auto_loader.py, which is called from AppConfig.ready(). By the time Django finishes startup, all tools are registered and available to both the MCP protocol server and the admin chat agent.
Built-in tools
Introspection (tools/introspection.py)
Controlled by DjangoMCPModuleConfig.introspection. When introspection.enabled = False, these tools exist in the registry but return permission-denied responses.
| Tool | Description |
|---|---|
list_apps | Lists all installed Django apps and their models. |
get_model_schema | Returns field definitions for a specific model. |
list_urls | Returns URL patterns (requires introspection.expose_urls = True). |
Model queries (tools/model_tools.py)
Both tools check config.is_model_exposed(app_label, model_name) before executing. Fields listed in ModelMCPConfig.hidden_fields are stripped from results. max_results caps the queryset size.
| Tool | Description |
|---|---|
query_model | Filter and list records from any exposed model. |
get_object | Retrieve a single record by primary key. |
Management commands (tools/command_tool.py)
| Tool | Description |
|---|---|
execute_command | Run a Django management command. |
Only commands in CommandMCPConfig.allowed_commands are permitted. Execution is capped at timeout_seconds. Output is captured and returned as a string.
User info (tools/user_info.py)
| Tool | Description |
|---|---|
get_user_info | Returns information about the current agent session. |
Advanced analytics (agent/orm_tools.py)
These tools bypass the per-model exposure check — they are always available when MCP is enabled. They are intended for analytical queries.
| Tool | Description |
|---|---|
aggregate_model | COUNT, SUM, AVG, MIN, MAX on any model field. |
time_series | Group records by time period (day, week, month). |
top_values | Top N values for a field with occurrence counts. |
distribution | Value distribution / histogram for a numeric field. |
How tools work
Every tool is a class inheriting MCPTool:
class MCPTool(ABC):
name: str = "" # unique identifier used in tool calls
description: str = "" # shown to the LLM — affects when the tool is chosen
input_schema: Dict # JSON Schema for the arguments object
def execute(self, context: MCPContext, arguments: Dict) -> str:
... # must return a stringnameis what the LLM uses in a tool call. Must be unique across the registry.descriptionis the most important field — it determines when the LLM decides to use the tool. Write it precisely.input_schemais sent verbatim to the LLM as the function parameter schema in OpenAI tool format.execute()must always return astr. For structured data, return a JSON-encoded string. For errors, return a plain error message string — do not raise exceptions (the runner catches them and returns the message).
Adding a custom tool
Step 1: Create the tool class
# my_extension/mcp_tools.py
from django_cfg.modules.django_mcp.tools.base import MCPTool
from django_cfg.modules.django_mcp.services.context import MCPContext
class MyCustomTool(MCPTool):
name = "my_custom_tool"
description = "Does something specific. Use when the user asks about X."
input_schema = {
"type": "object",
"properties": {
"param": {
"type": "string",
"description": "The input parameter",
},
},
"required": ["param"],
}
def execute(self, context: MCPContext, arguments: dict) -> str:
param = arguments.get("param", "")
# Access config through context
if not context.config.enabled:
return "MCP is not enabled"
# Do your work
return f"Result: {param}"
my_custom_tool = MyCustomTool()Step 2: Register the tool
From a Django extension’s AppConfig.ready():
# my_extension/apps.py
from django.apps import AppConfig
class MyExtensionConfig(AppConfig):
name = "my_extension"
def ready(self):
from django_cfg.modules.django_mcp.tools.base import tool_registry
from .mcp_tools import my_custom_tool
tool_registry.register(my_custom_tool)From the core module (when modifying django_mcp itself), add to handlers/tools.py:
from django_cfg.modules.django_mcp.tools.my_module import my_custom_tool
tool_registry.register(my_custom_tool)From your project’s mcp/tools/ directory — the simplest path, and the one most projects use. Every *.py file there whose name does not start with _ is imported during AppConfig.ready(), so registering at module level is enough:
# <project_root>/mcp/tools/my_tools.py
from django_cfg.modules.django_mcp.tools.base import MCPTool, tool_registry
class MyCustomTool(MCPTool):
name = "my_custom_tool"
description = "Does something specific. Use when the user asks about X."
input_schema = {"type": "object", "properties": {}}
def execute(self, context, arguments) -> str:
return "Result"
tool_registry.register(MyCustomTool())Discovery requires mcp/__init__.py to exist (see Configuration). Prefix a filename with _ to keep it out of discovery — useful for shared helpers.
When a tool module fails to import
A tool file with an import-time error registers nothing, so its tools are simply absent from tools/list. An agent cannot tell that absence apart from “this data does not exist” — it reads as an empty result and the investigation stops, silently, for as long as nobody calls the tool.
Discovery therefore fails loudly in development:
debug=True— startup raisesImproperlyConfiguredlisting every broken module and its exception. All failures are reported together, so you do not fix one per restart.debug=False— the failure is logged atERRORwith the traceback and the module is skipped, so one bad file cannot take production down.
django.core.exceptions.ImproperlyConfigured: 1 MCP tool module(s) in
/app/mcp/tools failed to import:
- my_tools.py: ModuleNotFoundError: No module named 'requests'If a file in mcp/tools/ is not meant to be a tool module, rename it with a leading underscore rather than leaving it broken.
Context differences between subsystems
Tools are called with slightly different context depending on which subsystem calls them:
# MCP protocol server — full MCPContext with request info
result = tool.execute(context, arguments)
# ^^^^^^^ MCPContext: has access_key, tenant, user info
# Admin chat agent — config only
result = tool.execute(context.config, arguments)
# ^^^^^^^^^^^^^^ DjangoMCPModuleConfig onlyThis is a known inconsistency. Tools that need request-level information (tenant, IP address, user identity) cannot be used safely from the admin chat agent until it passes a full MCPContext. Built-in tools work correctly because they only use context.config or ignore context entirely.
PII redaction
MCPTool exposes execute_with_redaction() which applies the configured redactor after execution:
mode = RedactionMode(context.config.redaction.mode.lower())
return redactor.redact_string(result, mode)Redaction is opt-in per tool — ToolsHandler calls execute() directly, not execute_with_redaction(). This is intentional: not all tools produce output that needs redaction, and the check adds overhead.
Built-in patterns cover: email addresses, phone numbers, credit card numbers, SSNs. Add custom patterns via redaction.custom_patterns in the configuration.