Skip to content

Commit 53012ef

Browse files
Add standalone CLI (#13)
1 parent 6f323c4 commit 53012ef

File tree

8 files changed

+225
-46
lines changed

8 files changed

+225
-46
lines changed

CHANGELOG.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,14 @@ and this project attempts to adhere to [Semantic Versioning](https://semver.org/
1818

1919
## [Unreleased]
2020

21+
### Added
22+
23+
- Standalone CLI via `python -m mcp_django_shell`.
24+
25+
### Deprecated
26+
27+
- Soft-deprecation of the management command `manage.py mcp_shell`. It's now just a wrapper around the CLI, so there's no harm in keeping it, but the recommended usage will be the standalone CLI going forward.
28+
2129
## [0.3.1]
2230

2331
### Removed

README.md

Lines changed: 42 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -47,7 +47,7 @@ cog.outl(f"- Django {', '.join([version for version in DJ_VERSIONS if version !=
4747
uv sync
4848
```
4949

50-
2. Add to your Django project's `INSTALLED_APPS`:
50+
2. (Optional) Add to your Django project's `INSTALLED_APPS` if you want to use the management command:
5151
5252
```python
5353
DEBUG = ...
@@ -56,6 +56,8 @@ cog.outl(f"- Django {', '.join([version for version in DJ_VERSIONS if version !=
5656
INSTALLED_APPS.append("mcp_django_shell")
5757
```
5858
59+
**Note**: You can now run mcp-django-shell without adding it to `INSTALLED_APPS` by using `python -m mcp_django_shell` directly. [See below](#getting-started) for more info.
60+
5961
> [!WARNING]
6062
>
6163
> **Only enable in development!**
@@ -64,7 +66,35 @@ cog.outl(f"- Django {', '.join([version for version in DJ_VERSIONS if version !=
6466
6567
## Getting Started
6668
67-
mcp-django-shell provides a Django management command that MCP clients can connect to. Configure your client using one of the examples below.
69+
Run the MCP server directly from your Django project directory:
70+
71+
```bash
72+
python -m mcp_django_shell
73+
74+
# With explicit settings module
75+
python -m mcp_django_shell --settings myproject.settings
76+
77+
# With debug logging
78+
python -m mcp_django_shell --debug
79+
```
80+
81+
Or using uv:
82+
83+
```bash
84+
uv run -m mcp_django_shell
85+
```
86+
87+
The server automatically detects `DJANGO_SETTINGS_MODULE` from your environment. You can override it with `--settings` or add to your Python path with `--pythonpath`.
88+
89+
There's also a Django management command if you prefer, but that requires adding mcp-django-shell to `INSTALLED_APPS`:
90+
91+
```bash
92+
python manage.py mcp_shell
93+
```
94+
95+
### Client Configuration
96+
97+
Configure your MCP client using one of the examples below. The command is the same for all clients, just expressed in annoyingly different JSON soup.
6898

6999
Don't see your client? [Submit a PR](CONTRIBUTING.md) with setup instructions.
70100
@@ -75,8 +105,11 @@ Don't see your client? [Submit a PR](CONTRIBUTING.md) with setup instructions.
75105
"mcpServers": {
76106
"django_shell": {
77107
"command": "python",
78-
"args": ["manage.py", "mcp_shell"],
79-
"cwd": "/path/to/your/django/project"
108+
"args": ["-m", "mcp_django_shell"],
109+
"cwd": "/path/to/your/django/project",
110+
"env": {
111+
"DJANGO_SETTINGS_MODULE": "myproject.settings"
112+
}
80113
}
81114
}
82115
}
@@ -90,8 +123,11 @@ Don't see your client? [Submit a PR](CONTRIBUTING.md) with setup instructions.
90123
"mcp": {
91124
"django_shell": {
92125
"type": "local",
93-
"command": ["python", "manage.py", "mcp_shell"],
94-
"enabled": true
126+
"command": ["python", "-m", "mcp_django_shell"],
127+
"enabled": true,
128+
"environment": {
129+
"DJANGO_SETTINGS_MODULE": "myproject.settings"
130+
}
95131
}
96132
}
97133
}

pyproject.toml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -110,6 +110,8 @@ fail_under = 100
110110
omit = [
111111
"src/mcp_django_shell/management/commands/mcp_shell.py",
112112
"src/mcp_django_shell/migrations/*",
113+
"src/mcp_django_shell/__main__.py",
114+
"src/mcp_django_shell/_typing.py",
113115
"tests/*"
114116
]
115117
source = ["src/mcp_django_shell"]

src/mcp_django_shell/__main__.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
from __future__ import annotations
2+
3+
from .cli import main
4+
5+
if __name__ == "__main__":
6+
raise SystemExit(main())

src/mcp_django_shell/_typing.py

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
from __future__ import annotations
2+
3+
import sys
4+
5+
if sys.version_info >= (3, 12):
6+
from typing import override as typing_override
7+
else:
8+
from typing_extensions import (
9+
override as typing_override, # pyright: ignore[reportUnreachable]
10+
)
11+
12+
override = typing_override

src/mcp_django_shell/cli.py

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
from __future__ import annotations
2+
3+
import argparse
4+
import logging
5+
import os
6+
import signal
7+
import sys
8+
from collections.abc import Sequence
9+
from typing import Any
10+
11+
logger = logging.getLogger(__name__)
12+
13+
14+
def main(argv: Sequence[str] | None = None) -> int:
15+
parser = argparse.ArgumentParser(description="Run the MCP Django Shell server")
16+
parser.add_argument(
17+
"--settings",
18+
help="Django settings module (overrides DJANGO_SETTINGS_MODULE env var)",
19+
)
20+
parser.add_argument(
21+
"--pythonpath",
22+
help="Python path to add for Django project imports",
23+
)
24+
parser.add_argument(
25+
"--debug",
26+
action="store_true",
27+
help="Enable debug logging",
28+
)
29+
args = parser.parse_args(argv)
30+
31+
debug: bool = args.debug
32+
settings: str | None = args.settings
33+
pythonpath: str | None = args.pythonpath
34+
35+
if debug:
36+
logging.basicConfig(
37+
level=logging.DEBUG,
38+
format="%(asctime)s - %(name)s - %(levelname)s - %(message)s",
39+
)
40+
logger.debug("Debug logging enabled")
41+
42+
if settings:
43+
os.environ["DJANGO_SETTINGS_MODULE"] = settings
44+
45+
if pythonpath:
46+
sys.path.insert(0, pythonpath)
47+
48+
django_settings = os.environ.get("DJANGO_SETTINGS_MODULE")
49+
if not django_settings:
50+
logger.error(
51+
"DJANGO_SETTINGS_MODULE not set. Use --settings or set environment variable."
52+
)
53+
return 1
54+
55+
logger.info("Starting MCP Django Shell server")
56+
logger.debug("Django settings module: %s", django_settings)
57+
58+
def signal_handler(signum: int, _frame: Any): # pragma: no cover
59+
logger.info("Received signal %s, shutting down MCP server", signum)
60+
sys.exit(0)
61+
62+
signal.signal(signal.SIGINT, signal_handler)
63+
signal.signal(signal.SIGTERM, signal_handler)
64+
65+
try:
66+
logger.info("MCP server ready and listening")
67+
68+
from .server import mcp
69+
70+
mcp.run()
71+
72+
except Exception as e:
73+
logger.error("MCP server crashed: %s", e, exc_info=True)
74+
return 1
75+
76+
finally:
77+
logger.info("MCP Django Shell server stopped")
78+
79+
return 0
Lines changed: 12 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -1,59 +1,31 @@
11
from __future__ import annotations
22

3-
import logging
4-
import os
5-
import signal
6-
import sys
3+
from argparse import ArgumentParser
74
from typing import Any
5+
from typing import final
86

97
from django.core.management.base import BaseCommand
108

11-
from mcp_django_shell.server import mcp
12-
13-
logger = logging.getLogger(__name__)
9+
from mcp_django_shell._typing import override
10+
from mcp_django_shell.cli import main
1411

1512

13+
@final
1614
class Command(BaseCommand):
1715
help = "Run the MCP Django Shell server"
1816

19-
def add_arguments(self, parser):
17+
@override
18+
def add_arguments(self, parser: ArgumentParser) -> None:
2019
parser.add_argument(
2120
"--debug",
2221
action="store_true",
2322
help="Enable debug logging",
2423
)
2524

26-
def handle(self, *args: Any, **options: Any):
25+
@override
26+
def handle(self, *args: Any, **options: Any) -> str | None:
27+
argv: list[str] = []
2728
if options.get("debug"):
28-
logging.basicConfig(
29-
level=logging.DEBUG,
30-
format="%(asctime)s - %(name)s - %(levelname)s - %(message)s",
31-
)
32-
logger.debug("Debug logging enabled")
33-
34-
logger.info("Starting MCP Django Shell server")
35-
logger.debug(
36-
"Django settings module: %s",
37-
os.environ.get("DJANGO_SETTINGS_MODULE", "Not set"),
38-
)
39-
40-
def signal_handler(signum, frame):
41-
logger.info("Received signal %s, shutting down MCP server", signum)
42-
43-
sys.exit(0)
44-
45-
signal.signal(signal.SIGINT, signal_handler)
46-
signal.signal(signal.SIGTERM, signal_handler)
47-
48-
try:
49-
logger.info("MCP server ready and listening")
50-
51-
mcp.run()
52-
53-
except Exception as e:
54-
logger.error("MCP server crashed: %s", e, exc_info=True)
55-
56-
raise
29+
argv.append("--debug")
5730

58-
finally:
59-
logger.info("MCP Django Shell server stopped")
31+
return str(main(argv))

tests/test_cli.py

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
from __future__ import annotations
2+
3+
import os
4+
import sys
5+
from unittest.mock import Mock
6+
7+
from mcp_django_shell.cli import main
8+
9+
10+
def test_cli_no_django_settings(monkeypatch, caplog):
11+
monkeypatch.delenv("DJANGO_SETTINGS_MODULE", raising=False)
12+
13+
result = main([])
14+
15+
assert result == 1
16+
assert "DJANGO_SETTINGS_MODULE not set" in caplog.text
17+
18+
19+
def test_cli_with_settings_arg(monkeypatch):
20+
mock_mcp = Mock()
21+
monkeypatch.setattr("mcp_django_shell.server.mcp", mock_mcp)
22+
23+
result = main(["--settings", "myapp.settings"])
24+
25+
assert os.environ["DJANGO_SETTINGS_MODULE"] == "myapp.settings"
26+
mock_mcp.run.assert_called_once()
27+
assert result == 0
28+
29+
30+
def test_cli_server_crash(monkeypatch, caplog):
31+
monkeypatch.setenv("DJANGO_SETTINGS_MODULE", "tests.settings")
32+
33+
mock_mcp = Mock()
34+
mock_mcp.run.side_effect = Exception("Server crashed!")
35+
monkeypatch.setattr("mcp_django_shell.server.mcp", mock_mcp)
36+
37+
result = main([])
38+
39+
assert result == 1
40+
assert "MCP server crashed" in caplog.text
41+
42+
43+
def test_cli_with_pythonpath(monkeypatch):
44+
monkeypatch.setenv("DJANGO_SETTINGS_MODULE", "tests.settings")
45+
46+
mock_mcp = Mock()
47+
monkeypatch.setattr("mcp_django_shell.server.mcp", mock_mcp)
48+
49+
test_path = "/test/path"
50+
result = main(["--pythonpath", test_path])
51+
52+
assert test_path in sys.path
53+
assert result == 0
54+
55+
56+
def test_cli_with_debug(monkeypatch):
57+
monkeypatch.setenv("DJANGO_SETTINGS_MODULE", "tests.settings")
58+
59+
mock_mcp = Mock()
60+
monkeypatch.setattr("mcp_django_shell.server.mcp", mock_mcp)
61+
62+
result = main(["--debug"])
63+
64+
assert result == 0

0 commit comments

Comments
 (0)