Skip to content

Commit d454552

Browse files
committed
Add Django MCP tool get_models
1 parent 3099654 commit d454552

File tree

5 files changed

+246
-6
lines changed

5 files changed

+246
-6
lines changed

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,9 +29,9 @@ dev = [
2929
]
3030

3131
[tool.ruff]
32-
line-length = 100
3332
target-version = "py39"
3433

34+
line-length = 100
3535
# Set what ruff should check for.
3636
# See https://beta.ruff.rs/docs/rules/ for a list of rules.
3737
lint.select = [

src/tidewave/django/__init__.py

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,8 @@
1111
from django.utils.deprecation import MiddlewareMixin
1212
from django.utils.log import CallbackFilter
1313

14-
from tidewave import tools
14+
import tidewave.django.tools as django_tools
15+
import tidewave.tools as tidewave_tools
1516
from tidewave.mcp_handler import MCPHandler
1617
from tidewave.middleware import Middleware as BaseMiddleware
1718
from tidewave.tools.get_logs import file_handler
@@ -60,10 +61,11 @@ def __init__(self, get_response: Callable):
6061
# Create MCP handler with tools
6162
self.mcp_handler = MCPHandler(
6263
[
63-
tools.get_docs,
64-
tools.get_logs,
65-
tools.get_source_location,
66-
tools.project_eval,
64+
django_tools.get_models,
65+
tidewave_tools.get_docs,
66+
tidewave_tools.get_logs,
67+
tidewave_tools.get_source_location,
68+
tidewave_tools.project_eval,
6769
]
6870
)
6971

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
"""
2+
Django-specific MCP tools
3+
"""
4+
5+
from .models import get_models
6+
7+
__all__ = [
8+
"get_models",
9+
]
Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
import inspect
2+
import os
3+
from pathlib import Path
4+
from typing import Optional
5+
6+
from django.apps import apps
7+
8+
9+
def get_models() -> str:
10+
"""
11+
Returns a list of all database-backed models and their file paths in the application.
12+
13+
You should prefer this tool over grepping the file system when you need to find a specific
14+
Django models.
15+
"""
16+
17+
models = apps.get_models()
18+
models = list(filter(lambda m: not m._meta.abstract, models))
19+
20+
if not models:
21+
return "No models found in the Django application"
22+
23+
models.sort(key=lambda m: m.__name__)
24+
25+
result = []
26+
for model in models:
27+
location = _get_relative_source_location(model)
28+
result.append(f"* {model.__name__}" + (f" at {location}" if location else ""))
29+
30+
return "\n".join(result)
31+
32+
33+
def _get_relative_source_location(model) -> Optional[str]:
34+
"""Get relative source location for a Django model."""
35+
try:
36+
file_path = inspect.getsourcefile(model)
37+
line_number = inspect.getsourcelines(model)[1]
38+
39+
if not file_path:
40+
return None
41+
42+
try:
43+
cwd = Path(os.getcwd())
44+
file_path_obj = Path(file_path)
45+
relative_path = file_path_obj.relative_to(cwd)
46+
return f"{relative_path}:{line_number}"
47+
except ValueError:
48+
return f"{file_path}:{line_number}"
49+
50+
except (OSError, TypeError):
51+
return None

tests/test_django_get_models.py

Lines changed: 178 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,178 @@
1+
"""
2+
Tests for Django get_models tool
3+
"""
4+
5+
import re
6+
from pathlib import Path
7+
from unittest.mock import Mock, patch
8+
9+
import django
10+
from django.conf import settings
11+
from django.test import TestCase
12+
13+
from tidewave.django.tools import get_models
14+
from tidewave.django.tools.models import _get_relative_source_location
15+
16+
if not settings.configured:
17+
settings.configure(
18+
DATABASES={
19+
"default": {
20+
"ENGINE": "django.db.backends.sqlite3",
21+
"NAME": ":memory:",
22+
}
23+
},
24+
INSTALLED_APPS=[
25+
"django.contrib.contenttypes",
26+
"django.contrib.auth",
27+
],
28+
SECRET_KEY="test-secret-key",
29+
USE_TZ=True,
30+
)
31+
django.setup()
32+
33+
34+
class TestDjangoGetModels(TestCase):
35+
def test_get_models(self):
36+
"""Test get_models with Django models."""
37+
result = get_models()
38+
39+
# Should contain Django's built-in models
40+
self.assertIn("User", result)
41+
self.assertIn("Group", result)
42+
self.assertIn("Permission", result)
43+
self.assertIn("ContentType", result)
44+
45+
# Should have bullet point format
46+
lines = result.strip().split("\n")
47+
for line in lines:
48+
self.assertTrue(line.startswith("* "))
49+
50+
# Should include source locations if available
51+
self.assertTrue(re.search(r"\* \w+ at .+\.py:\d+", result))
52+
53+
def test_get_models_excludes_abstract_models(self):
54+
"""Test that abstract models are excluded from results."""
55+
# Create a mock abstract model
56+
mock_abstract_model = Mock()
57+
mock_abstract_model.__name__ = "AbstractTestModel"
58+
mock_abstract_model._meta.abstract = True
59+
60+
# Create a mock concrete model
61+
mock_concrete_model = Mock()
62+
mock_concrete_model.__name__ = "ConcreteTestModel"
63+
mock_concrete_model._meta.abstract = False
64+
65+
with patch("django.apps.apps.get_models") as mock_get_models:
66+
mock_get_models.return_value = [mock_abstract_model, mock_concrete_model]
67+
68+
result = get_models()
69+
70+
self.assertIn("ConcreteTestModel", result)
71+
self.assertNotIn("AbstractTestModel", result)
72+
73+
def test_get_models_no_models_found(self):
74+
"""Test behavior when no models are found."""
75+
with patch("django.apps.apps.get_models") as mock_get_models:
76+
mock_get_models.return_value = []
77+
78+
result = get_models()
79+
80+
self.assertEqual(result, "No models found in the Django application")
81+
82+
def test_get_models_sorts_by_model_name(self):
83+
"""Test that models are sorted by name."""
84+
# Create mock models with names that would be unsorted
85+
mock_models = []
86+
for name in ["ZebraModel", "AlphaModel", "BetaModel"]:
87+
mock_model = Mock()
88+
mock_model.__name__ = name
89+
mock_model._meta.abstract = False
90+
mock_models.append(mock_model)
91+
92+
with patch("django.apps.apps.get_models") as mock_get_models:
93+
with patch(
94+
"tidewave.django.tools.models._get_relative_source_location"
95+
) as mock_location:
96+
mock_get_models.return_value = mock_models
97+
mock_location.return_value = "test.py:1"
98+
99+
result = get_models()
100+
101+
lines = result.strip().split("\n")
102+
self.assertIn("AlphaModel", lines[0])
103+
self.assertIn("BetaModel", lines[1])
104+
self.assertIn("ZebraModel", lines[2])
105+
106+
def test_get_relative_source_location_with_relative_path(self):
107+
"""Test _get_relative_source_location with a path that can be made relative."""
108+
109+
mock_model = Mock()
110+
111+
cwd = Path.cwd()
112+
test_file = cwd / "myapp" / "models.py"
113+
114+
with patch("inspect.getsourcefile") as mock_getsourcefile:
115+
with patch("inspect.getsourcelines") as mock_getsourcelines:
116+
mock_getsourcefile.return_value = str(test_file)
117+
mock_getsourcelines.return_value = ([], 42)
118+
119+
result = _get_relative_source_location(mock_model)
120+
121+
self.assertEqual(result, "myapp/models.py:42")
122+
123+
def test_get_relative_source_location_with_absolute_path(self):
124+
"""Test _get_relative_source_location with a path that cannot be made relative."""
125+
126+
mock_model = Mock()
127+
128+
absolute_path = "/usr/lib/python3.x/site-packages/django/contrib/auth/models.py"
129+
130+
with patch("inspect.getsourcefile") as mock_getsourcefile:
131+
with patch("inspect.getsourcelines") as mock_getsourcelines:
132+
mock_getsourcefile.return_value = absolute_path
133+
mock_getsourcelines.return_value = ([], 123)
134+
135+
result = _get_relative_source_location(mock_model)
136+
137+
self.assertEqual(result, f"{absolute_path}:123")
138+
139+
def test_get_relative_source_location_with_no_source(self):
140+
"""Test _get_relative_source_location when source cannot be found."""
141+
142+
mock_model = Mock()
143+
144+
with patch("inspect.getsourcefile") as mock_getsourcefile:
145+
mock_getsourcefile.return_value = None
146+
147+
result = _get_relative_source_location(mock_model)
148+
149+
self.assertIsNone(result)
150+
151+
def test_get_relative_source_location_with_inspect_error(self):
152+
"""Test _get_relative_source_location when inspect raises an error."""
153+
154+
mock_model = Mock()
155+
156+
with patch("inspect.getsourcefile") as mock_getsourcefile:
157+
mock_getsourcefile.side_effect = OSError("Cannot get source file")
158+
159+
result = _get_relative_source_location(mock_model)
160+
161+
self.assertIsNone(result)
162+
163+
def test_model_without_source_location(self):
164+
"""Test handling of models that don't have source locations."""
165+
mock_model = Mock()
166+
mock_model.__name__ = "TestModel"
167+
mock_model._meta.abstract = False
168+
169+
with patch("django.apps.apps.get_models") as mock_get_models:
170+
with patch(
171+
"tidewave.django.tools.models._get_relative_source_location"
172+
) as mock_location:
173+
mock_get_models.return_value = [mock_model]
174+
mock_location.return_value = None
175+
176+
result = get_models()
177+
178+
self.assertEqual(result.strip(), "* TestModel")

0 commit comments

Comments
 (0)