Skip to content

Commit 914f3d8

Browse files
committed
feat: add read-only query model tool
Signed-off-by: Rai Siqueira <rai93siqueira@gmail.com>
1 parent 434a46a commit 914f3d8

6 files changed

Lines changed: 365 additions & 5 deletions

File tree

README.md

Lines changed: 28 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -259,7 +259,28 @@ Reverse a named URL pattern to get its actual URL path. Supports both positional
259259
- `args`: Optional list of positional arguments
260260
- `kwargs`: Optional dict of keyword arguments
261261

262-
### 10. `read_recent_logs`
262+
### 10. `query_model`
263+
Query a Django model with read-only operations using the Django ORM manager. This tool allows safe querying of any Django model with filtering, ordering, and pagination.
264+
265+
**Arguments:**
266+
- `app_label`: The Django app label (e.g., "blog")
267+
- `model_name`: The model name (e.g., "Post")
268+
- `filters`: Optional dict of field lookups (e.g., `{"status": "published", "featured": true}`)
269+
- `order_by`: Optional list of fields to order by (e.g., `["-created_at", "title"]`)
270+
- `limit`: Maximum number of results to return (default: 100, max: 1000)
271+
272+
**Returns:**
273+
- Total count of matching objects
274+
- Number of results returned
275+
- List of model instances as dictionaries with all field values
276+
- For foreign keys, includes both the ID and string representation
277+
278+
**Example Queries:**
279+
- Get all published posts: `filters={"status": "published"}`
280+
- Get featured posts ordered by date: `filters={"featured": true}`, `order_by=["-created_at"]`
281+
- Get recent posts with limit: `order_by=["-created_at"]`, `limit=10`
282+
283+
### 11. `read_recent_logs`
263284
Read recent log entries with optional filtering by log level (DEBUG, INFO, WARNING, ERROR, CRITICAL).
264285

265286
### Prompts
@@ -301,6 +322,9 @@ Once configured, you can ask your AI assistant questions like:
301322
- "What's the value of the DEBUG setting?"
302323
- "What's the URL for blog post with ID 5?"
303324
- "Reverse the 'post_detail' URL pattern with pk=10"
325+
- "Show me all published blog posts"
326+
- "Get the 10 most recent posts ordered by creation date"
327+
- "Find all featured posts in the blog"
304328
- "Show me recent error logs"
305329

306330
**Using Prompts:**
@@ -316,6 +340,9 @@ The project includes a comprehensive test suite and a fixture Django project for
316340
# Test the MCP server with the fixture project
317341
uv run python test_server.py
318342

343+
# Test the query_model tool
344+
uv run python test_query_model.py
345+
319346
# Run the MCP server with the test project
320347
export PYTHONPATH="${PYTHONPATH}:./fixtures/testproject"
321348
uv run django-ai-boost --settings testproject.settings

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[project]
22
name = "django-ai-boost"
3-
version = "0.2.1"
3+
version = "0.3.0"
44
description = "A Model Context Protocol (MCP) server for Django applications, inspired by Laravel Boost"
55
readme = "README.md"
66
authors = [

server.json

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,12 +6,12 @@
66
"url": "https://github.com/vintasoftware/django-ai-boost",
77
"source": "github"
88
},
9-
"version": "0.2.1",
9+
"version": "0.3.0",
1010
"packages": [
1111
{
1212
"registryType": "pypi",
1313
"identifier": "django-ai-boost",
14-
"version": "0.2.1",
14+
"version": "0.3.0",
1515
"transport": {
1616
"type": "stdio"
1717
},

src/django_ai_boost/server_fastmcp.py

Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -429,6 +429,114 @@ async def reverse_url(
429429
return {"error": f"Error reversing URL: {str(e)}"}
430430

431431

432+
@mcp.tool()
433+
async def query_model(
434+
app_label: str,
435+
model_name: str,
436+
filters: dict[str, Any] | None = None,
437+
order_by: list[str] | None = None,
438+
limit: int = 100,
439+
) -> dict[str, Any]:
440+
"""
441+
Query a Django model with read-only operations using the Django ORM manager.
442+
443+
Args:
444+
app_label: The app label (e.g., "blog")
445+
model_name: The model name (e.g., "Post")
446+
filters: Optional dictionary of field lookups (e.g., {"status": "published", "featured": true})
447+
order_by: Optional list of fields to order by (e.g., ["-created_at", "title"])
448+
limit: Maximum number of results to return (default: 100, max: 1000)
449+
450+
Returns:
451+
Dictionary containing query results or error message.
452+
"""
453+
454+
@sync_to_async
455+
def execute_query():
456+
try:
457+
# Get the model
458+
try:
459+
model = apps.get_model(app_label, model_name)
460+
except LookupError:
461+
return {"error": f"Model '{app_label}.{model_name}' not found"}
462+
463+
# Enforce maximum limit for safety
464+
max_limit = 1000
465+
actual_limit = min(limit, max_limit) if limit else 100
466+
467+
# Start with all objects
468+
queryset = model.objects.all()
469+
470+
# Apply filters if provided
471+
if filters:
472+
try:
473+
queryset = queryset.filter(**filters)
474+
except Exception as e:
475+
return {"error": f"Invalid filter parameters: {str(e)}"}
476+
477+
# Apply ordering if provided
478+
if order_by:
479+
try:
480+
queryset = queryset.order_by(*order_by)
481+
except Exception as e:
482+
return {"error": f"Invalid order_by parameters: {str(e)}"}
483+
484+
# Get total count before limiting
485+
total_count = queryset.count()
486+
487+
# Limit results
488+
queryset = queryset[:actual_limit]
489+
490+
# Convert queryset to list of dictionaries
491+
results = []
492+
for obj in queryset:
493+
obj_dict = {}
494+
for field in model._meta.get_fields():
495+
# Skip reverse relations
496+
if field.many_to_many or field.one_to_many:
497+
continue
498+
499+
field_name = field.name
500+
try:
501+
value = getattr(obj, field_name)
502+
503+
# Handle different field types
504+
if value is None:
505+
obj_dict[field_name] = None
506+
elif hasattr(field, "related_model") and field.related_model:
507+
# Foreign key - store the pk
508+
obj_dict[field_name] = value.pk if value else None
509+
obj_dict[f"{field_name}_str"] = (
510+
str(value) if value else None
511+
)
512+
elif isinstance(value, (str, int, float, bool)):
513+
obj_dict[field_name] = value
514+
else:
515+
# For dates, times, and other complex types
516+
obj_dict[field_name] = str(value)
517+
except Exception:
518+
# Skip fields that can't be accessed
519+
continue
520+
521+
results.append(obj_dict)
522+
523+
return {
524+
"app": app_label,
525+
"model": model_name,
526+
"total_count": total_count,
527+
"returned_count": len(results),
528+
"limit": actual_limit,
529+
"filters": filters or {},
530+
"order_by": order_by or [],
531+
"results": results,
532+
}
533+
534+
except Exception as e:
535+
return {"error": f"Error executing query: {str(e)}"}
536+
537+
return await execute_query()
538+
539+
432540
@mcp.prompt()
433541
async def search_django_docs(topic: str) -> str:
434542
"""

0 commit comments

Comments
 (0)