Skip to content

Commit 0a75d65

Browse files
Add comprehensive logging (#5)
1 parent 4874f6a commit 0a75d65

File tree

6 files changed

+264
-6
lines changed

6 files changed

+264
-6
lines changed

CHANGELOG.md

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

1919
## [Unreleased]
2020

21+
### Added
22+
23+
- Comprehensive logging for debugging MCP/LLM interactions
24+
- `--debug` flag for the `mcp_shell` management command to enable detailed logging
25+
- Request and client ID tracking in server logs
26+
2127
### Changed
2228

2329
- **Internal**: Refactored results to use separate dataclasses and a tagged union
Lines changed: 48 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,59 @@
11
from __future__ import annotations
22

3+
import logging
4+
import os
5+
import signal
6+
import sys
37
from typing import Any
48

59
from django.core.management.base import BaseCommand
610

711
from mcp_django_shell.server import mcp
812

13+
logger = logging.getLogger(__name__)
14+
915

1016
class Command(BaseCommand):
17+
help = "Run the MCP Django Shell server"
18+
19+
def add_arguments(self, parser):
20+
parser.add_argument(
21+
"--debug",
22+
action="store_true",
23+
help="Enable debug logging",
24+
)
25+
1126
def handle(self, *args: Any, **options: Any):
12-
mcp.run()
27+
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
57+
58+
finally:
59+
logger.info("MCP Django Shell server stopped")

src/mcp_django_shell/server.py

Lines changed: 56 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,23 @@
11
from __future__ import annotations
22

3+
import logging
4+
5+
from fastmcp import Context
36
from fastmcp import FastMCP
47

58
from .shell import DjangoShell
9+
from .shell import ErrorResult
610

711
mcp = FastMCP(
812
name="Django Shell",
913
instructions="Provides a stateful Django shell environment for executing Python code, managing sessions, and exporting command history.",
1014
)
1115
shell = DjangoShell()
16+
logger = logging.getLogger(__name__)
1217

1318

1419
@mcp.tool
15-
async def django_shell(code: str, timeout: int | None = None) -> str:
20+
async def django_shell(code: str, ctx: Context, timeout: int | None = None) -> str:
1621
"""Execute Python code in a stateful Django shell session.
1722
1823
Django is pre-configured and ready to use with your project. You can import and use any Django
@@ -26,16 +31,63 @@ async def django_shell(code: str, timeout: int | None = None) -> str:
2631
**NOTE**: that only synchronous Django ORM operations are supported - use standard methods like
2732
`.filter()` and `.get()` rather than their async counterparts (`.afilter()`, `.aget()`).
2833
"""
29-
result = await shell.execute(code, timeout=timeout)
30-
return result.output
34+
logger.info(
35+
"django_shell tool called - request_id: %s, client_id: %s, code: %s",
36+
ctx.request_id,
37+
ctx.client_id or "unknown",
38+
(code[:100] + "..." if len(code) > 100 else code).replace("\n", "\\n"),
39+
)
40+
logger.debug(
41+
"Full code for django_shell - request_id: %s: %s", ctx.request_id, code
42+
)
43+
44+
try:
45+
result = await shell.execute(code, timeout=timeout)
46+
47+
logger.debug(
48+
"django_shell execution completed - request_id: %s, result type: %s",
49+
ctx.request_id,
50+
type(result).__name__,
51+
)
52+
if isinstance(result, ErrorResult):
53+
await ctx.debug(
54+
f"Execution failed: {type(result.exception).__name__}",
55+
extra={
56+
"error_type": type(result.exception).__name__,
57+
"code_length": len(code),
58+
},
59+
)
60+
61+
return result.output
62+
63+
except Exception as e:
64+
logger.error(
65+
"Unexpected error in django_shell tool - request_id: %s: %s",
66+
ctx.request_id,
67+
e,
68+
exc_info=True,
69+
)
70+
raise
3171

3272

3373
@mcp.tool
34-
def django_reset() -> str:
74+
async def django_reset(ctx: Context) -> str:
3575
"""
3676
Reset the Django shell session, clearing all variables and history.
3777
3878
Use this when you want to start fresh or if the session state becomes corrupted.
3979
"""
80+
logger.info(
81+
"django_reset tool called - request_id: %s, client_id: %s",
82+
ctx.request_id,
83+
ctx.client_id or "unknown",
84+
)
85+
await ctx.debug("Django shell session reset")
86+
4087
shell.reset()
88+
89+
logger.debug(
90+
"Django shell session reset completed - request_id: %s", ctx.request_id
91+
)
92+
4193
return "Django shell session has been reset. All previously set variables and history cleared."

src/mcp_django_shell/shell.py

Lines changed: 91 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
from __future__ import annotations
22

3+
import logging
34
import traceback
45
from contextlib import redirect_stderr
56
from contextlib import redirect_stdout
@@ -12,19 +13,33 @@
1213

1314
from asgiref.sync import sync_to_async
1415

16+
logger = logging.getLogger(__name__)
17+
1518

1619
class DjangoShell:
1720
def __init__(self):
21+
logger.debug("Initializing %s", self.__class__.__name__)
22+
1823
from django import setup
1924
from django.apps import apps
2025

2126
if not apps.ready: # pragma: no cover
27+
logger.info("Django not initialized, running django.setup()")
28+
2229
setup()
2330

31+
logger.debug("Django setup completed")
32+
else:
33+
logger.debug("Django already initialized, skipping setup")
34+
2435
self.globals: dict[str, Any] = {}
2536
self.history: list[Result] = []
2637

38+
logger.info("Shell initialized successfully")
39+
2740
def reset(self):
41+
logger.info("Shell reset - clearing globals and history")
42+
2843
self.globals = {}
2944
self.history = []
3045

@@ -51,20 +66,44 @@ def _execute(self, code: str, timeout: int | None = None) -> Result:
5166
Note: This synchronous method contains the actual execution logic.
5267
Use `execute()` for async contexts or `_execute()` for sync/testing.
5368
"""
69+
code_preview = (code[:100] + "..." if len(code) > 100 else code).replace(
70+
"\n", "\\n"
71+
)
72+
logger.info("Executing code: %s", code_preview)
73+
5474
stdout = StringIO()
5575
stderr = StringIO()
5676

5777
with redirect_stdout(stdout), redirect_stderr(stderr):
5878
try:
5979
code, setup, code_type = parse_code(code)
6080

81+
logger.debug(
82+
"Execution type: %s, has setup: %s", code_type, bool(setup)
83+
)
84+
logger.debug(
85+
"Code to execute: %s",
86+
code[:200] + "..." if len(code) > 200 else code,
87+
)
88+
6189
# Execute setup, if any (only applicable to expressions)
6290
if setup:
91+
logger.debug(
92+
"Setup code: %s",
93+
setup[:200] + "..." if len(setup) > 200 else setup,
94+
)
95+
6396
exec(setup, self.globals)
6497

6598
match code_type:
6699
case "expression":
67100
value = eval(code, self.globals)
101+
102+
logger.debug(
103+
"Expression executed successfully, result type: %s",
104+
type(value).__name__,
105+
)
106+
68107
return self.save_result(
69108
ExpressionResult(
70109
code=code,
@@ -75,6 +114,9 @@ def _execute(self, code: str, timeout: int | None = None) -> Result:
75114
)
76115
case "statement":
77116
exec(code, self.globals)
117+
118+
logger.debug("Statement executed successfully")
119+
78120
return self.save_result(
79121
StatementResult(
80122
code=code,
@@ -84,6 +126,13 @@ def _execute(self, code: str, timeout: int | None = None) -> Result:
84126
)
85127

86128
except Exception as e:
129+
logger.error(
130+
"Exception during code execution: %s - Code: %s",
131+
f"{type(e).__name__}: {e}",
132+
code_preview,
133+
)
134+
logger.debug("Full traceback for error:", exc_info=True)
135+
87136
return self.save_result(
88137
ErrorResult(
89138
code=code,
@@ -136,6 +185,21 @@ class ExpressionResult:
136185
stderr: str
137186
timestamp: datetime = field(default_factory=datetime.now)
138187

188+
def __post_init__(self):
189+
logger.debug(
190+
"%s created - value type: %s",
191+
self.__class__.__name__,
192+
type(self.value).__name__,
193+
)
194+
logger.debug("%s.value: %s", self.__class__.__name__, repr(self.value)[:200])
195+
if self.stdout:
196+
logger.debug("%s.stdout: %s", self.__class__.__name__, self.stdout[:200])
197+
if self.stderr:
198+
logger.debug("%s.stderr: %s", self.__class__.__name__, self.stderr[:200])
199+
logger.debug(
200+
"%s output (for LLM): %s", self.__class__.__name__, self.output[:500]
201+
)
202+
139203
@property
140204
def output(self) -> str:
141205
value = repr(self.value)
@@ -170,6 +234,16 @@ class StatementResult:
170234
stderr: str
171235
timestamp: datetime = field(default_factory=datetime.now)
172236

237+
def __post_init__(self):
238+
logger.debug("%s created", self.__class__.__name__)
239+
if self.stdout:
240+
logger.debug("%s.stdout: %s", self.__class__.__name__, self.stdout[:200])
241+
if self.stderr:
242+
logger.debug("%s.stderr: %s", self.__class__.__name__, self.stderr[:200])
243+
logger.debug(
244+
"%s output (for LLM): %s", self.__class__.__name__, self.output[:500]
245+
)
246+
173247
@property
174248
def output(self) -> str:
175249
return self.stdout or "OK"
@@ -183,11 +257,27 @@ class ErrorResult:
183257
stderr: str
184258
timestamp: datetime = field(default_factory=datetime.now)
185259

260+
def __post_init__(self):
261+
logger.debug(
262+
"%s created - exception type: %s",
263+
self.__class__.__name__,
264+
type(self.exception).__name__,
265+
)
266+
logger.debug("%s.message: %s", self.__class__.__name__, str(self.exception))
267+
if self.stdout:
268+
logger.debug("%s.stdout: %s", self.__class__.__name__, self.stdout[:200])
269+
if self.stderr:
270+
logger.debug("%s.stderr: %s", self.__class__.__name__, self.stderr[:200])
271+
logger.debug(
272+
"%s output (filtered for LLM): %s",
273+
self.__class__.__name__,
274+
self.output[:500],
275+
)
276+
186277
@property
187278
def output(self) -> str:
188279
error_type = self.exception.__class__.__name__
189280

190-
# Format the stored exception's traceback
191281
tb_str = "".join(
192282
traceback.format_exception(
193283
type(self.exception), self.exception, self.exception.__traceback__

tests/test_server.py

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,14 @@
11
from __future__ import annotations
22

3+
from unittest.mock import AsyncMock
4+
35
import pytest
46
import pytest_asyncio
57
from fastmcp import Client
8+
from fastmcp.exceptions import ToolError
69

710
from mcp_django_shell.server import mcp
11+
from mcp_django_shell.server import shell
812

913
pytestmark = pytest.mark.asyncio
1014

@@ -45,6 +49,16 @@ async def test_django_shell_tool_orm():
4549
assert result.data is not None
4650

4751

52+
async def test_django_shell_tool_unexpected_error(monkeypatch):
53+
monkeypatch.setattr(
54+
shell, "execute", AsyncMock(side_effect=RuntimeError("Unexpected error"))
55+
)
56+
57+
async with Client(mcp) as client:
58+
with pytest.raises(ToolError, match="Unexpected error"):
59+
await client.call_tool("django_shell", {"code": "2 + 2"})
60+
61+
4862
async def test_django_reset_session():
4963
async with Client(mcp) as client:
5064
await client.call_tool("django_shell", {"code": "x = 42"})

0 commit comments

Comments
 (0)