Skip to content

Commit acd5fd1

Browse files
Add management command toolset with command execution & discovery (#60)
1 parent 991509f commit acd5fd1

File tree

8 files changed

+650
-0
lines changed

8 files changed

+650
-0
lines changed

CHANGELOG.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,10 @@ For multi-package releases, use package names as subsections:
2626

2727
## [Unreleased]
2828

29+
### Added
30+
31+
- Added Management toolset with `command` and `list_commands` tools for executing and discovering Django management commands
32+
2933
## [0.12.0]
3034

3135
### Added

README.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -206,6 +206,13 @@ Read-only resources for project exploration without executing code (note that re
206206
| `list_models` | Get detailed information about Django models with optional filtering by app or scope |
207207
| `list_routes` | Introspect Django URL routes with filtering support for HTTP method, route name, or URL pattern |
208208

209+
#### Management
210+
211+
| Tool | Description |
212+
|------|-------------|
213+
| `execute_command` | Execute Django management commands with arguments and options |
214+
| `list_commands` | List all available Django management commands with their source apps |
215+
209216
#### Shell
210217

211218
| Tool | Description |

src/mcp_django/mgmt/__init__.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
from __future__ import annotations
2+
3+
from .server import MANAGEMENT_TOOLSET
4+
from .server import mcp
5+
6+
__all__ = [
7+
"MANAGEMENT_TOOLSET",
8+
"mcp",
9+
]

src/mcp_django/mgmt/core.py

Lines changed: 222 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,222 @@
1+
from __future__ import annotations
2+
3+
import logging
4+
from contextlib import redirect_stderr
5+
from contextlib import redirect_stdout
6+
from dataclasses import dataclass
7+
from dataclasses import field
8+
from datetime import datetime
9+
from io import StringIO
10+
11+
from asgiref.sync import sync_to_async
12+
from django.core.management import call_command
13+
from django.core.management import get_commands
14+
from pydantic import BaseModel
15+
from pydantic import ConfigDict
16+
17+
logger = logging.getLogger(__name__)
18+
19+
20+
@dataclass
21+
class CommandResult:
22+
command: str
23+
args: tuple[str, ...]
24+
options: dict[str, str | int | bool]
25+
stdout: str
26+
stderr: str
27+
timestamp: datetime = field(default_factory=datetime.now)
28+
29+
def __post_init__(self):
30+
logger.debug(
31+
"%s created for command: %s", self.__class__.__name__, self.command
32+
)
33+
if self.stdout:
34+
logger.debug("%s.stdout: %s", self.__class__.__name__, self.stdout[:200])
35+
if self.stderr:
36+
logger.debug("%s.stderr: %s", self.__class__.__name__, self.stderr[:200])
37+
38+
39+
@dataclass
40+
class CommandErrorResult:
41+
command: str
42+
args: tuple[str, ...]
43+
options: dict[str, str | int | bool]
44+
exception: Exception
45+
stdout: str
46+
stderr: str
47+
timestamp: datetime = field(default_factory=datetime.now)
48+
49+
def __post_init__(self):
50+
logger.debug(
51+
"%s created for command: %s - exception type: %s",
52+
self.__class__.__name__,
53+
self.command,
54+
type(self.exception).__name__,
55+
)
56+
logger.debug("%s.message: %s", self.__class__.__name__, str(self.exception))
57+
if self.stdout:
58+
logger.debug("%s.stdout: %s", self.__class__.__name__, self.stdout[:200])
59+
if self.stderr:
60+
logger.debug("%s.stderr: %s", self.__class__.__name__, self.stderr[:200])
61+
62+
63+
Result = CommandResult | CommandErrorResult
64+
65+
66+
class ManagementCommandOutput(BaseModel):
67+
status: str # "success" or "error"
68+
command: str
69+
args: list[str]
70+
options: dict[str, str | int | bool]
71+
stdout: str
72+
stderr: str
73+
exception: ExceptionInfo | None = None
74+
75+
@classmethod
76+
def from_result(cls, result: Result) -> ManagementCommandOutput:
77+
match result:
78+
case CommandResult():
79+
return cls(
80+
status="success",
81+
command=result.command,
82+
args=list(result.args),
83+
options=result.options,
84+
stdout=result.stdout,
85+
stderr=result.stderr,
86+
exception=None,
87+
)
88+
case CommandErrorResult():
89+
return cls(
90+
status="error",
91+
command=result.command,
92+
args=list(result.args),
93+
options=result.options,
94+
stdout=result.stdout,
95+
stderr=result.stderr,
96+
exception=ExceptionInfo(
97+
type=type(result.exception).__name__,
98+
message=str(result.exception),
99+
),
100+
)
101+
102+
103+
class ExceptionInfo(BaseModel):
104+
model_config = ConfigDict(arbitrary_types_allowed=True)
105+
106+
type: str
107+
message: str
108+
109+
110+
class ManagementCommandExecutor:
111+
async def execute(
112+
self,
113+
command: str,
114+
args: list[str] | None = None,
115+
options: dict[str, str | int | bool] | None = None,
116+
) -> Result:
117+
"""Execute a Django management command asynchronously.
118+
119+
Args:
120+
command: The management command name (e.g., 'migrate', 'check')
121+
args: Positional arguments for the command
122+
options: Keyword options for the command
123+
124+
Returns:
125+
CommandResult if successful, CommandErrorResult if an exception occurred
126+
"""
127+
return await sync_to_async(self._execute)(command, args, options)
128+
129+
def _execute(
130+
self,
131+
command: str,
132+
args: list[str] | None = None,
133+
options: dict[str, str | int | bool] | None = None,
134+
) -> Result:
135+
"""Execute a Django management command synchronously.
136+
137+
Captures stdout and stderr from the command execution.
138+
139+
Args:
140+
command: The management command name
141+
args: Positional arguments for the command
142+
options: Keyword options for the command
143+
144+
Returns:
145+
CommandResult if successful, CommandErrorResult if an exception occurred
146+
"""
147+
args = args or []
148+
options = options or {}
149+
150+
args_tuple = tuple(args)
151+
options_dict = dict(options)
152+
153+
logger.info(
154+
"Executing management command: %s with args=%s, options=%s",
155+
command,
156+
args_tuple,
157+
options_dict,
158+
)
159+
160+
stdout = StringIO()
161+
stderr = StringIO()
162+
163+
with redirect_stdout(stdout), redirect_stderr(stderr):
164+
try:
165+
call_command(command, *args_tuple, **options_dict)
166+
167+
logger.debug("Management command executed successfully: %s", command)
168+
169+
return CommandResult(
170+
command=command,
171+
args=args_tuple,
172+
options=options_dict,
173+
stdout=stdout.getvalue(),
174+
stderr=stderr.getvalue(),
175+
)
176+
177+
except Exception as e:
178+
logger.error(
179+
"Exception during management command execution: %s - Command: %s",
180+
f"{type(e).__name__}: {e}",
181+
command,
182+
)
183+
logger.debug("Full traceback for error:", exc_info=True)
184+
185+
return CommandErrorResult(
186+
command=command,
187+
args=args_tuple,
188+
options=options_dict,
189+
exception=e,
190+
stdout=stdout.getvalue(),
191+
stderr=stderr.getvalue(),
192+
)
193+
194+
195+
management_command_executor = ManagementCommandExecutor()
196+
197+
198+
class CommandInfo(BaseModel):
199+
name: str
200+
app_name: str
201+
202+
203+
def get_management_commands() -> list[CommandInfo]:
204+
"""Get list of all available Django management commands.
205+
206+
Returns a list of management commands with their app origins,
207+
sorted alphabetically by command name.
208+
209+
Returns:
210+
List of CommandInfo objects containing command name and source app.
211+
"""
212+
logger.info("Fetching available management commands")
213+
214+
commands = get_commands()
215+
command_list = [
216+
CommandInfo(name=name, app_name=app_name)
217+
for name, app_name in sorted(commands.items())
218+
]
219+
220+
logger.debug("Found %d management commands", len(command_list))
221+
222+
return command_list

src/mcp_django/mgmt/server.py

Lines changed: 133 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,133 @@
1+
from __future__ import annotations
2+
3+
import logging
4+
from typing import Annotated
5+
6+
from fastmcp import Context
7+
from fastmcp import FastMCP
8+
from mcp.types import ToolAnnotations
9+
10+
from .core import CommandInfo
11+
from .core import ManagementCommandOutput
12+
from .core import get_management_commands
13+
from .core import management_command_executor
14+
15+
logger = logging.getLogger(__name__)
16+
17+
mcp = FastMCP(
18+
name="Management",
19+
instructions="Execute and discover Django management commands. Run commands with arguments and options, or list available commands to discover what's available in your project.",
20+
)
21+
22+
MANAGEMENT_TOOLSET = "management"
23+
24+
25+
@mcp.tool(
26+
name="execute_command",
27+
annotations=ToolAnnotations(
28+
title="Execute Django Management Command",
29+
destructiveHint=True,
30+
openWorldHint=True,
31+
),
32+
tags={MANAGEMENT_TOOLSET},
33+
)
34+
async def execute_command(
35+
ctx: Context,
36+
command: Annotated[
37+
str,
38+
"Management command name (e.g., 'migrate', 'check', 'collectstatic')",
39+
],
40+
args: Annotated[
41+
list[str] | None,
42+
"Positional arguments for the command",
43+
] = None,
44+
options: Annotated[
45+
dict[str, str | int | bool] | None,
46+
"Keyword options for the command (use underscores for dashes, e.g., 'run_syncdb' for '--run-syncdb')",
47+
] = None,
48+
) -> ManagementCommandOutput:
49+
"""Execute a Django management command.
50+
51+
Calls Django's call_command() to run management commands. Arguments and options
52+
are passed directly to the command. Command output (stdout/stderr) is captured
53+
and returned.
54+
55+
Examples:
56+
- Check for issues: command="check"
57+
- Show migrations: command="showmigrations", args=["myapp"]
58+
- Migrate with options: command="migrate", options={"verbosity": 2}
59+
- Check with tag: command="check", options={"tag": "security"}
60+
61+
Note: Management commands can modify your database and project state. Use with
62+
caution, especially commands like migrate, flush, loaddata, etc.
63+
"""
64+
logger.info(
65+
"management_command called - request_id: %s, client_id: %s, command: %s, args: %s, options: %s",
66+
ctx.request_id,
67+
ctx.client_id or "unknown",
68+
command,
69+
args,
70+
options,
71+
)
72+
73+
try:
74+
result = await management_command_executor.execute(command, args, options)
75+
output = ManagementCommandOutput.from_result(result)
76+
77+
logger.debug(
78+
"management_command completed - request_id: %s, status: %s",
79+
ctx.request_id,
80+
output.status,
81+
)
82+
83+
if output.status == "error" and output.exception:
84+
await ctx.debug(
85+
f"Command failed: {output.exception.type}: {output.exception.message}"
86+
)
87+
88+
return output
89+
90+
except Exception as e:
91+
logger.error(
92+
"Unexpected error in management_command tool - request_id: %s: %s",
93+
ctx.request_id,
94+
e,
95+
exc_info=True,
96+
)
97+
raise
98+
99+
100+
@mcp.tool(
101+
name="list_commands",
102+
annotations=ToolAnnotations(
103+
title="List Django Management Commands",
104+
readOnlyHint=True,
105+
idempotentHint=True,
106+
),
107+
tags={MANAGEMENT_TOOLSET},
108+
)
109+
def list_commands(ctx: Context) -> list[CommandInfo]:
110+
"""List all available Django management commands.
111+
112+
Returns a list of all management commands available in the current Django
113+
project, including built-in Django commands and custom commands from
114+
installed apps. Each command includes its name and the app that provides it.
115+
116+
Useful for discovering what commands are available before executing them
117+
with the execute_command tool.
118+
"""
119+
logger.info(
120+
"list_management_commands called - request_id: %s, client_id: %s",
121+
ctx.request_id,
122+
ctx.client_id or "unknown",
123+
)
124+
125+
commands = get_management_commands()
126+
127+
logger.debug(
128+
"list_management_commands completed - request_id: %s, commands_count: %d",
129+
ctx.request_id,
130+
len(commands),
131+
)
132+
133+
return commands

0 commit comments

Comments
 (0)