├── tests ├── __init__.py ├── shell │ ├── __init__.py │ ├── test_output.py │ ├── test_core.py │ ├── test_export.py │ └── test_server.py ├── toolsets │ ├── __init__.py │ └── test_packages.py ├── models.py ├── settings.py ├── urls.py ├── test_cli.py ├── test_resources.py ├── test_server.py ├── test_management_command.py └── test_routing.py ├── src └── mcp_django │ ├── py.typed │ ├── management │ ├── __init__.py │ └── commands │ │ ├── __init__.py │ │ └── mcp.py │ ├── __main__.py │ ├── apps.py │ ├── shell │ ├── __init__.py │ ├── output.py │ ├── server.py │ └── core.py │ ├── mgmt │ ├── __init__.py │ ├── server.py │ └── core.py │ ├── project │ ├── __init__.py │ ├── resources.py │ ├── server.py │ └── routing.py │ ├── packages │ ├── __init__.py │ ├── server.py │ └── client.py │ ├── __init__.py │ ├── _typing.py │ ├── server.py │ └── cli.py ├── .github ├── CODEOWNERS ├── dependabot.yml └── workflows │ ├── release.yml │ └── test.yml ├── .mcp.json ├── opencode.json ├── .just └── project.just ├── .editorconfig ├── .pre-commit-config.yaml ├── LICENSE ├── Justfile ├── .gitignore ├── RELEASING.md ├── noxfile.py ├── CONTRIBUTING.md ├── .bin └── release.py ├── pyproject.toml ├── README.md └── CHANGELOG.md /tests/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/mcp_django/py.typed: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/shell/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/toolsets/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/mcp_django/management/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/mcp_django/management/commands/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.github/CODEOWNERS: -------------------------------------------------------------------------------- 1 | * @joshuadavidthomas 2 | -------------------------------------------------------------------------------- /src/mcp_django/__main__.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from .cli import main 4 | 5 | if __name__ == "__main__": 6 | raise SystemExit(main()) 7 | -------------------------------------------------------------------------------- /src/mcp_django/apps.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from django.apps import AppConfig 4 | 5 | 6 | class MCPConfig(AppConfig): 7 | name = "mcp_django" 8 | verbose_name = "MCP" 9 | -------------------------------------------------------------------------------- /src/mcp_django/shell/__init__.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from .server import SHELL_TOOLSET 4 | from .server import mcp 5 | 6 | __all__ = [ 7 | "SHELL_TOOLSET", 8 | "mcp", 9 | ] 10 | -------------------------------------------------------------------------------- /src/mcp_django/mgmt/__init__.py: -------------------------------------------------------------------------------- 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 | ] 10 | -------------------------------------------------------------------------------- /src/mcp_django/project/__init__.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from .server import PROJECT_TOOLSET 4 | from .server import mcp 5 | 6 | __all__ = [ 7 | "PROJECT_TOOLSET", 8 | "mcp", 9 | ] 10 | -------------------------------------------------------------------------------- /src/mcp_django/packages/__init__.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from .server import DJANGOPACKAGES_TOOLSET 4 | from .server import mcp 5 | 6 | __all__ = [ 7 | "DJANGOPACKAGES_TOOLSET", 8 | "mcp", 9 | ] 10 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: github-actions 4 | directory: "/" 5 | schedule: 6 | interval: weekly 7 | timezone: America/Chicago 8 | groups: 9 | gha: 10 | patterns: 11 | - "*" 12 | -------------------------------------------------------------------------------- /.mcp.json: -------------------------------------------------------------------------------- 1 | { 2 | "mcpServers": { 3 | "django": { 4 | "command": "uv", 5 | "args": [ 6 | "run", 7 | "-m", 8 | "mcp_django" 9 | ], 10 | "env": { 11 | "DJANGO_SETTINGS_MODULE": "tests.settings" 12 | } 13 | } 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /src/mcp_django/__init__.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import importlib.metadata 4 | 5 | try: 6 | __version__ = importlib.metadata.version(__name__) 7 | except importlib.metadata.PackageNotFoundError: # pragma: no cover 8 | # editable install 9 | __version__ = "0.0.0" 10 | -------------------------------------------------------------------------------- /src/mcp_django/_typing.py: -------------------------------------------------------------------------------- 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 13 | -------------------------------------------------------------------------------- /tests/models.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from django.db import models 4 | 5 | 6 | class AModel(models.Model): 7 | name = models.CharField(max_length=100) 8 | value = models.IntegerField(default=0) 9 | created_at = models.DateTimeField(auto_now_add=True) 10 | 11 | def __str__(self): 12 | return f"Test Model ({self.name})" 13 | -------------------------------------------------------------------------------- /opencode.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://opencode.ai/config.json", 3 | "mcp": { 4 | "django": { 5 | "type": "local", 6 | "command": [ 7 | "uv", 8 | "run", 9 | "-m", 10 | "mcp_django" 11 | ], 12 | "enabled": true, 13 | "environment": { 14 | "DJANGO_SETTINGS_MODULE": "tests.settings" 15 | } 16 | } 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /tests/settings.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | DEBUG = False 4 | 5 | DATABASES = { 6 | "default": { 7 | "ENGINE": "django.db.backends.sqlite3", 8 | "NAME": ":memory:", 9 | } 10 | } 11 | 12 | INSTALLED_APPS = [ 13 | "mcp_django", 14 | "tests", 15 | ] 16 | 17 | ROOT_URLCONF = "tests.urls" 18 | 19 | SECRET_KEY = "test-secret-key" 20 | 21 | USE_TZ = True 22 | -------------------------------------------------------------------------------- /.just/project.just: -------------------------------------------------------------------------------- 1 | set unstable := true 2 | 3 | justfile := justfile_directory() + "/.just/project.just" 4 | 5 | [private] 6 | default: 7 | @just --list --justfile {{ justfile }} 8 | 9 | [private] 10 | fmt: 11 | @just --fmt --justfile {{ justfile }} 12 | 13 | [no-cd] 14 | @bump *ARGS: 15 | uv run {{ justfile_directory() }}/.bin/bump.py {{ ARGS }} 16 | 17 | [no-cd] 18 | @release *ARGS: 19 | uv run {{ justfile_directory() }}/.bin/release.py {{ ARGS }} 20 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | # http://editorconfig.org 2 | [*] 3 | charset = utf-8 4 | end_of_line = lf 5 | indent_style = space 6 | insert_final_newline = true 7 | trim_trailing_whitespace = true 8 | 9 | [{,.}{j,J}ustfile] 10 | indent_size = 4 11 | 12 | [*.{py,rst,ini}] 13 | indent_size = 4 14 | 15 | [*.py] 16 | line_length = 120 17 | multi_line_output = 3 18 | 19 | [*.{css,html,js,json,jsx,md,sass,scss,svelte,toml,ts,tsx,yml,yaml}] 20 | indent_size = 2 21 | 22 | [*.md] 23 | trim_trailing_whitespace = false 24 | 25 | [{Makefile,*.bat}] 26 | indent_style = tab 27 | -------------------------------------------------------------------------------- /src/mcp_django/management/commands/mcp.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from argparse import ArgumentParser 4 | from typing import Any 5 | from typing import final 6 | 7 | from django.core.management.base import BaseCommand 8 | 9 | from mcp_django._typing import override 10 | from mcp_django.cli import main 11 | 12 | 13 | @final 14 | class Command(BaseCommand): 15 | help = "Run the MCP Django server" 16 | 17 | @override 18 | def add_arguments(self, parser: ArgumentParser) -> None: 19 | parser.add_argument( 20 | "--debug", 21 | action="store_true", 22 | help="Enable debug logging", 23 | ) 24 | 25 | @override 26 | def handle(self, *args: Any, **options: Any) -> str | None: 27 | argv: list[str] = [] 28 | if options.get("debug"): 29 | argv.append("--debug") 30 | 31 | return str(main(argv)) 32 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: release 2 | 3 | on: 4 | release: 5 | types: [published] 6 | 7 | jobs: 8 | test: 9 | uses: ./.github/workflows/test.yml 10 | secrets: inherit 11 | 12 | pypi: 13 | runs-on: ubuntu-latest 14 | needs: test 15 | environment: release 16 | permissions: 17 | contents: write 18 | id-token: write 19 | steps: 20 | - uses: actions/checkout@v6 21 | 22 | - name: Install uv 23 | uses: astral-sh/setup-uv@v7 24 | with: 25 | enable-cache: true 26 | 27 | - name: Build package 28 | run: | 29 | uv build 30 | 31 | - name: Upload release assets to GitHub 32 | env: 33 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 34 | run: | 35 | gh release upload ${{ github.event.release.tag_name }} dist/* 36 | 37 | - name: Publish to PyPI 38 | run: | 39 | uv publish 40 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | repos: 2 | - repo: https://github.com/pre-commit/pre-commit-hooks 3 | rev: v6.0.0 4 | hooks: 5 | - id: trailing-whitespace 6 | - id: end-of-file-fixer 7 | - id: check-toml 8 | - id: check-yaml 9 | 10 | - repo: https://github.com/adamchainz/django-upgrade 11 | rev: 1.25.0 12 | hooks: 13 | - id: django-upgrade 14 | args: [--target-version, "4.2"] 15 | 16 | - repo: https://github.com/astral-sh/ruff-pre-commit 17 | rev: v0.12.8 18 | hooks: 19 | - id: ruff 20 | args: [--fix] 21 | - id: ruff-format 22 | 23 | - repo: https://github.com/adamchainz/blacken-docs 24 | rev: 1.19.1 25 | hooks: 26 | - id: blacken-docs 27 | alias: autoformat 28 | additional_dependencies: 29 | - black==22.12.0 30 | 31 | - repo: https://github.com/abravalheri/validate-pyproject 32 | rev: v0.24.1 33 | hooks: 34 | - id: validate-pyproject 35 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2025 Josh Thomas 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 6 | 7 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 8 | 9 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 10 | -------------------------------------------------------------------------------- /Justfile: -------------------------------------------------------------------------------- 1 | set dotenv-load := true 2 | set unstable := true 3 | 4 | mod project ".just/project.just" 5 | 6 | [private] 7 | default: 8 | @just --list --list-submodules 9 | 10 | [private] 11 | cog: 12 | uv run --with cogapp cog -r CONTRIBUTING.md README.md pyproject.toml 13 | 14 | [private] 15 | fmt: 16 | @just --fmt 17 | @just project fmt 18 | 19 | [private] 20 | nox SESSION *ARGS: 21 | uv run nox --session "{{ SESSION }}" -- "{{ ARGS }}" 22 | 23 | bootstrap: 24 | uv python install 25 | uv sync --all-extras --locked 26 | 27 | coverage *ARGS: 28 | @just nox coverage {{ ARGS }} 29 | 30 | lint: 31 | @just nox lint 32 | 33 | lock *ARGS: 34 | uv lock {{ ARGS }} 35 | 36 | manage *COMMAND: 37 | #!/usr/bin/env python 38 | import sys 39 | 40 | try: 41 | from django.conf import settings 42 | from django.core.management import execute_from_command_line 43 | except ImportError as exc: 44 | raise ImportError( 45 | "Couldn't import Django. Are you sure it's installed and " 46 | "available on your PYTHONPATH environment variable? Did you " 47 | "forget to activate a virtual environment?" 48 | ) from exc 49 | 50 | settings.configure(INSTALLED_APPS=["mcp_django_shell"]) 51 | execute_from_command_line(sys.argv + "{{ COMMAND }}".split(" ")) 52 | 53 | test *ARGS: 54 | @just nox test {{ ARGS }} 55 | 56 | testall *ARGS: 57 | @just nox tests {{ ARGS }} 58 | 59 | types *ARGS: 60 | @just nox types {{ ARGS }} 61 | -------------------------------------------------------------------------------- /src/mcp_django/server.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import asyncio 4 | import logging 5 | from typing import Any 6 | 7 | from fastmcp import FastMCP 8 | 9 | from mcp_django.mgmt import MANAGEMENT_TOOLSET 10 | from mcp_django.mgmt import mcp as management_mcp 11 | from mcp_django.packages import DJANGOPACKAGES_TOOLSET 12 | from mcp_django.packages import mcp as packages_mcp 13 | from mcp_django.project import PROJECT_TOOLSET 14 | from mcp_django.project import mcp as project_mcp 15 | from mcp_django.shell import SHELL_TOOLSET 16 | from mcp_django.shell import mcp as shell_mcp 17 | 18 | logger = logging.getLogger(__name__) 19 | 20 | TOOLSETS = { 21 | DJANGOPACKAGES_TOOLSET: packages_mcp, 22 | MANAGEMENT_TOOLSET: management_mcp, 23 | PROJECT_TOOLSET: project_mcp, 24 | SHELL_TOOLSET: shell_mcp, 25 | } 26 | 27 | 28 | class DjangoMCP: 29 | NAME = "Django" 30 | INSTRUCTIONS = "Django ecosystem MCP server providing comprehensive project introspection, stateful code execution, and development tools. Supports exploring project structure, analyzing configurations, executing Python in persistent sessions, and accessing Django ecosystem resources." 31 | 32 | def __init__(self) -> None: 33 | instructions = [self.INSTRUCTIONS] 34 | 35 | instructions.append("## Available Toolsets") 36 | for toolset_server in TOOLSETS.values(): 37 | instructions.append(f"### {toolset_server.name}") 38 | if toolset_server.instructions: 39 | instructions.append(toolset_server.instructions) 40 | 41 | self._server = FastMCP(name=self.NAME, instructions="\n\n".join(instructions)) 42 | 43 | @property 44 | def server(self) -> FastMCP: 45 | return self._server 46 | 47 | async def initialize(self) -> None: 48 | for toolset_prefix, toolset_server in TOOLSETS.items(): 49 | await self._server.import_server(toolset_server, prefix=toolset_prefix) 50 | 51 | def run(self, **kwargs: Any) -> None: # pragma: no cover 52 | asyncio.run(self.initialize()) 53 | self._server.run(**kwargs) 54 | 55 | 56 | mcp = DjangoMCP() 57 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: test 2 | 3 | on: 4 | pull_request: 5 | push: 6 | branches: [main] 7 | workflow_call: 8 | 9 | concurrency: 10 | group: test-${{ github.head_ref }} 11 | cancel-in-progress: true 12 | 13 | env: 14 | PYTHONUNBUFFERED: "1" 15 | FORCE_COLOR: "1" 16 | 17 | jobs: 18 | generate-matrix: 19 | runs-on: ubuntu-latest 20 | outputs: 21 | matrix: ${{ steps.set-matrix.outputs.matrix }} 22 | steps: 23 | - uses: actions/checkout@v6 24 | 25 | - name: Install uv 26 | uses: astral-sh/setup-uv@v7 27 | with: 28 | enable-cache: true 29 | 30 | - id: set-matrix 31 | run: | 32 | uv run nox --session "gha_matrix" 33 | 34 | test: 35 | name: Python ${{ matrix.python-version }}, Django ${{ matrix.django-version }} 36 | runs-on: ubuntu-latest 37 | needs: generate-matrix 38 | strategy: 39 | fail-fast: false 40 | matrix: ${{ fromJSON(needs.generate-matrix.outputs.matrix) }} 41 | steps: 42 | - uses: actions/checkout@v6 43 | 44 | - name: Install uv 45 | uses: astral-sh/setup-uv@v7 46 | with: 47 | enable-cache: true 48 | 49 | - name: Run tests 50 | run: | 51 | uv run nox --session "tests(python='${{ matrix.python-version }}', django='${{ matrix.django-version }}')" 52 | 53 | tests: 54 | runs-on: ubuntu-latest 55 | needs: test 56 | if: always() 57 | steps: 58 | - name: OK 59 | if: ${{ !(contains(needs.*.result, 'failure')) }} 60 | run: exit 0 61 | - name: Fail 62 | if: ${{ contains(needs.*.result, 'failure') }} 63 | run: exit 1 64 | 65 | types: 66 | runs-on: ubuntu-latest 67 | steps: 68 | - uses: actions/checkout@v6 69 | 70 | - name: Install uv 71 | uses: astral-sh/setup-uv@v7 72 | with: 73 | enable-cache: true 74 | 75 | - name: Run type checks 76 | run: | 77 | uv run nox --session "types" 78 | 79 | coverage: 80 | runs-on: ubuntu-latest 81 | steps: 82 | - uses: actions/checkout@v6 83 | 84 | - name: Install uv 85 | uses: astral-sh/setup-uv@v7 86 | with: 87 | enable-cache: true 88 | 89 | - name: Generate code coverage 90 | run: | 91 | uv run nox --session "coverage" 92 | -------------------------------------------------------------------------------- /tests/urls.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from django.urls import include 4 | from django.urls import path 5 | from django.views import View 6 | from django.views.decorators.cache import cache_page 7 | from django.views.decorators.http import require_GET 8 | from django.views.decorators.http import require_http_methods 9 | from django.views.generic import CreateView 10 | from django.views.generic import DeleteView 11 | from django.views.generic import DetailView 12 | 13 | 14 | def dummy_view(request): 15 | pass 16 | 17 | 18 | @require_GET 19 | def get_only_view(request): 20 | pass 21 | 22 | 23 | @require_http_methods(["GET", "POST", "PUT"]) 24 | def multi_method_view(request): 25 | pass 26 | 27 | 28 | @cache_page(60) 29 | @require_GET 30 | def cached_get_view(request): 31 | pass 32 | 33 | 34 | class BasicView(View): 35 | def get(self, request): 36 | pass 37 | 38 | def post(self, request): 39 | pass 40 | 41 | 42 | class ArticleDetail(DetailView): 43 | model = None 44 | 45 | 46 | class ArticleCreate(CreateView): 47 | model = None 48 | 49 | 50 | class ArticleDelete(DeleteView): 51 | model = None 52 | 53 | 54 | blog_patterns = [ 55 | path("posts/", get_only_view, name="post-list"), 56 | ] 57 | 58 | api_v1_users_patterns = [ 59 | path("/", dummy_view, name="user-detail"), 60 | path("/posts/", dummy_view, name="user-posts"), 61 | ] 62 | 63 | extra_patterns = [ 64 | path("extra/", dummy_view, name="extra"), 65 | ] 66 | 67 | api_v1_patterns = [ 68 | path("users/", include((api_v1_users_patterns, "users"), namespace="users")), 69 | path("internal/", include(extra_patterns)), 70 | ] 71 | 72 | urlpatterns = [ 73 | path("", dummy_view, name="home"), 74 | path("get-only/", get_only_view, name="get_only"), 75 | path("multi-method/", multi_method_view, name="multi_method"), 76 | path("cached-get/", cached_get_view, name="cached_get"), 77 | path("basic/", BasicView.as_view(), name="basic_view"), 78 | path("articles//", ArticleDetail.as_view(), name="article_detail"), 79 | path("articles/create/", ArticleCreate.as_view(), name="article_create"), 80 | path("articles//delete/", ArticleDelete.as_view(), name="article_delete"), 81 | path("items//", dummy_view, name="item_by_slug"), 82 | path( 83 | "archive////", 84 | dummy_view, 85 | name="archive_detail", 86 | ), 87 | path("blog/", include((blog_patterns, "blog"), namespace="blog")), 88 | path("api/v1/", include((api_v1_patterns, "v1"), namespace="v1")), 89 | ] 90 | -------------------------------------------------------------------------------- /tests/test_cli.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import os 4 | import sys 5 | from unittest.mock import Mock 6 | 7 | from mcp_django.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.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.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.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.server.mcp", mock_mcp) 61 | 62 | result = main(["--debug"]) 63 | 64 | assert result == 0 65 | 66 | 67 | def test_cli_with_http_transport(monkeypatch): 68 | monkeypatch.setenv("DJANGO_SETTINGS_MODULE", "tests.settings") 69 | 70 | mock_mcp = Mock() 71 | monkeypatch.setattr("mcp_django.server.mcp", mock_mcp) 72 | 73 | result = main( 74 | [ 75 | "--transport", 76 | "http", 77 | "--host", 78 | "127.0.0.1", 79 | "--port", 80 | "8000", 81 | "--path", 82 | "/mcp", 83 | ] 84 | ) 85 | 86 | mock_mcp.run.assert_called_once_with( 87 | transport="http", host="127.0.0.1", port=8000, path="/mcp" 88 | ) 89 | assert result == 0 90 | 91 | 92 | def test_cli_with_sse_transport(monkeypatch): 93 | monkeypatch.setenv("DJANGO_SETTINGS_MODULE", "tests.settings") 94 | 95 | mock_mcp = Mock() 96 | monkeypatch.setattr("mcp_django.server.mcp", mock_mcp) 97 | 98 | result = main(["--transport", "sse", "--host", "0.0.0.0", "--port", "9000"]) 99 | 100 | mock_mcp.run.assert_called_once_with(transport="sse", host="0.0.0.0", port=9000) 101 | assert result == 0 102 | -------------------------------------------------------------------------------- /src/mcp_django/shell/output.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import traceback 4 | from enum import Enum 5 | from types import TracebackType 6 | 7 | from pydantic import BaseModel 8 | from pydantic import ConfigDict 9 | from pydantic import field_serializer 10 | 11 | from .core import ErrorResult 12 | from .core import Result 13 | from .core import StatementResult 14 | 15 | 16 | class DjangoShellOutput(BaseModel): 17 | status: ExecutionStatus 18 | output: Output 19 | stdout: str 20 | stderr: str 21 | 22 | @classmethod 23 | def from_result(cls, result: Result) -> DjangoShellOutput: 24 | output: Output 25 | 26 | match result: 27 | case StatementResult(): 28 | output = StatementOutput() 29 | case ErrorResult(): 30 | exception = ExceptionOutput( 31 | exc_type=type(result.exception), 32 | message=str(result.exception), 33 | traceback=result.exception.__traceback__, 34 | ) 35 | output = ErrorOutput(exception=exception) 36 | 37 | return cls( 38 | status=ExecutionStatus.from_output(output), 39 | output=output, 40 | stdout=result.stdout, 41 | stderr=result.stderr, 42 | ) 43 | 44 | 45 | class ExecutionStatus(str, Enum): 46 | SUCCESS = "success" 47 | ERROR = "error" 48 | 49 | @classmethod 50 | def from_output(cls, output: Output) -> ExecutionStatus: 51 | match output: 52 | case StatementOutput(): 53 | return cls.SUCCESS 54 | case ErrorOutput(): 55 | return cls.ERROR 56 | 57 | 58 | class StatementOutput(BaseModel): 59 | """Output from evaluating a Python statement. 60 | 61 | Statements by definition do not return values, just side effects such as 62 | setting variables or executing functions. 63 | 64 | This is empty for now, but defined to gain type safety (see the `Output` 65 | tagged union below) and as a holder for any potential future metadata. 66 | """ 67 | 68 | 69 | class ErrorOutput(BaseModel): 70 | exception: ExceptionOutput 71 | 72 | 73 | class ExceptionOutput(BaseModel): 74 | model_config = ConfigDict(arbitrary_types_allowed=True) 75 | 76 | exc_type: type[Exception] 77 | message: str 78 | traceback: TracebackType | None 79 | 80 | @field_serializer("exc_type") 81 | def serialize_exception_type(self, exc_type: type[Exception]) -> str: 82 | return exc_type.__name__ 83 | 84 | @field_serializer("traceback") 85 | def serialize_traceback(self, tb: TracebackType | None) -> list[str]: 86 | if tb is None: 87 | return [] 88 | 89 | tb_lines = traceback.format_tb(tb) 90 | relevant_tb_lines = [ 91 | line.strip() 92 | for line in tb_lines 93 | if "mcp_django/shell" not in line 94 | and "mcp_django/code" not in line 95 | and "mcp_django/output" not in line 96 | and line.strip() 97 | ] 98 | 99 | return relevant_tb_lines 100 | 101 | 102 | Output = StatementOutput | ErrorOutput 103 | -------------------------------------------------------------------------------- /src/mcp_django/cli.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import argparse 4 | import logging 5 | import os 6 | import sys 7 | from collections.abc import Sequence 8 | from typing import Any 9 | 10 | logger = logging.getLogger(__name__) 11 | 12 | 13 | def main(argv: Sequence[str] | None = None) -> int: 14 | parser = argparse.ArgumentParser(description="Run the MCP Django Shell server") 15 | parser.add_argument( 16 | "--settings", 17 | help="Django settings module (overrides DJANGO_SETTINGS_MODULE env var)", 18 | ) 19 | parser.add_argument( 20 | "--pythonpath", 21 | help="Python path to add for Django project imports", 22 | ) 23 | parser.add_argument( 24 | "--debug", 25 | action="store_true", 26 | help="Enable debug logging", 27 | ) 28 | parser.add_argument( 29 | "--transport", 30 | default="stdio", 31 | choices=["stdio", "http", "sse"], 32 | help="Transport protocol to use (default: stdio)", 33 | ) 34 | parser.add_argument( 35 | "--host", 36 | default="127.0.0.1", 37 | help="Host to bind to for HTTP/SSE transport (default: 127.0.0.1)", 38 | ) 39 | parser.add_argument( 40 | "--port", 41 | type=int, 42 | default=8000, 43 | help="Port to bind to for HTTP/SSE transport (default: 8000)", 44 | ) 45 | parser.add_argument( 46 | "--path", 47 | default="/mcp", 48 | help="Path for HTTP transport endpoint (default: /mcp)", 49 | ) 50 | args = parser.parse_args(argv) 51 | 52 | debug: bool = args.debug 53 | settings: str | None = args.settings 54 | pythonpath: str | None = args.pythonpath 55 | transport: str = args.transport 56 | host: str = args.host 57 | port: int = args.port 58 | path: str = args.path 59 | 60 | if debug: 61 | logging.basicConfig( 62 | level=logging.DEBUG, 63 | format="%(asctime)s - %(name)s - %(levelname)s - %(message)s", 64 | ) 65 | logger.debug("Debug logging enabled") 66 | 67 | if settings: 68 | os.environ["DJANGO_SETTINGS_MODULE"] = settings 69 | 70 | if pythonpath: 71 | sys.path.insert(0, pythonpath) 72 | 73 | django_settings = os.environ.get("DJANGO_SETTINGS_MODULE") 74 | if not django_settings: 75 | logger.error( 76 | "DJANGO_SETTINGS_MODULE not set. Use --settings or set environment variable." 77 | ) 78 | return 1 79 | 80 | logger.info("Starting MCP Django server") 81 | logger.debug("Django settings module: %s", django_settings) 82 | logger.debug("Transport: %s", transport) 83 | if transport in ["http", "sse"]: 84 | logger.info( 85 | "Server will be available at %s:%s%s", 86 | host, 87 | port, 88 | path if transport == "http" else "", 89 | ) 90 | 91 | try: 92 | logger.info("MCP server ready and listening") 93 | 94 | from .server import mcp 95 | 96 | kwargs: dict[str, Any] = {"transport": transport} 97 | 98 | if transport in ["http", "sse"]: 99 | kwargs["host"] = host 100 | kwargs["port"] = port 101 | 102 | if transport == "http": 103 | kwargs["path"] = path 104 | 105 | mcp.run(**kwargs) 106 | 107 | except Exception as e: 108 | logger.error("MCP server crashed: %s", e, exc_info=True) 109 | return 1 110 | 111 | finally: 112 | logger.info("MCP Django server stopped") 113 | 114 | return 0 115 | -------------------------------------------------------------------------------- /tests/shell/test_output.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import traceback 4 | 5 | from mcp_django.shell.core import ErrorResult 6 | from mcp_django.shell.core import StatementResult 7 | from mcp_django.shell.output import DjangoShellOutput 8 | from mcp_django.shell.output import ErrorOutput 9 | from mcp_django.shell.output import ExceptionOutput 10 | from mcp_django.shell.output import ExecutionStatus 11 | from mcp_django.shell.output import StatementOutput 12 | 13 | 14 | def test_django_shell_output_from_statement_result(): 15 | result = StatementResult( 16 | code="x = 5", 17 | stdout="", 18 | stderr="", 19 | ) 20 | 21 | output = DjangoShellOutput.from_result(result) 22 | 23 | assert output.status == ExecutionStatus.SUCCESS 24 | assert isinstance(output.output, StatementOutput) 25 | 26 | 27 | def test_django_shell_output_from_error_result(): 28 | exc = ZeroDivisionError("division by zero") 29 | 30 | result = ErrorResult( 31 | code="1 / 0", 32 | exception=exc, 33 | stdout="", 34 | stderr="", 35 | ) 36 | 37 | output = DjangoShellOutput.from_result(result) 38 | 39 | assert output.status == ExecutionStatus.ERROR 40 | assert isinstance(output.output, ErrorOutput) 41 | assert output.output.exception.exc_type is ZeroDivisionError 42 | assert "division by zero" in output.output.exception.message 43 | 44 | 45 | def test_exception_output_serialization(): 46 | exc = ValueError("test error") 47 | 48 | exc_output = ExceptionOutput( 49 | exc_type=type(exc), 50 | message=str(exc), 51 | traceback=None, # needs to be None since we didn't actually raise it 52 | ) 53 | 54 | serialized = exc_output.model_dump(mode="json") 55 | 56 | assert serialized["exc_type"] == "ValueError" 57 | assert serialized["message"] == "test error" 58 | assert serialized["traceback"] == [] 59 | 60 | 61 | def test_exception_output_with_real_traceback(): 62 | try: 63 | _ = 1 / 0 64 | except ZeroDivisionError as e: 65 | exc_output = ExceptionOutput( 66 | exc_type=type(e), 67 | message=str(e), 68 | traceback=e.__traceback__, 69 | ) 70 | 71 | serialized = exc_output.model_dump(mode="json") 72 | 73 | assert serialized["exc_type"] == "ZeroDivisionError" 74 | assert "division by zero" in serialized["message"] 75 | assert isinstance(serialized["traceback"], list) 76 | assert len(serialized["traceback"]) > 0 77 | assert any("1 / 0" in line for line in serialized["traceback"]) 78 | assert not any("mcp_django" in line for line in serialized["traceback"]) 79 | 80 | 81 | def test_traceback_filtering(): 82 | # Create a function that will appear in the traceback 83 | def mcp_django_function(): 84 | raise ValueError("test error") 85 | 86 | try: 87 | mcp_django_function() 88 | except ValueError as e: 89 | exc_output = ExceptionOutput( 90 | exc_type=type(e), 91 | message=str(e), 92 | traceback=e.__traceback__, 93 | ) 94 | 95 | assert any( 96 | "mcp_django_function" in line 97 | for line in traceback.format_tb(e.__traceback__) 98 | ) 99 | 100 | serialized = exc_output.model_dump(mode="json") 101 | 102 | assert len(serialized["traceback"]) == 0 or not any( 103 | "mcp_django/shell" in line 104 | or "mcp_django/code" in line 105 | or "mcp_django/output" in line 106 | for line in serialized["traceback"] 107 | ) 108 | -------------------------------------------------------------------------------- /src/mcp_django/packages/server.py: -------------------------------------------------------------------------------- 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 .client import DjangoPackagesClient 11 | from .client import GridResource 12 | from .client import GridSearchResult 13 | from .client import PackageResource 14 | from .client import PackageSearchResult 15 | 16 | logger = logging.getLogger(__name__) 17 | 18 | mcp = FastMCP( 19 | name="djangopackages.org", 20 | instructions="Search and discover reusable Django apps, sites, and tools from the community. Access package metadata including GitHub stars, PyPI versions, documentation links, and comparison grids for evaluating similar packages.", 21 | ) 22 | 23 | DJANGOPACKAGES_TOOLSET = "djangopackages" 24 | 25 | 26 | async def get_grid( 27 | slug: Annotated[ 28 | str, 29 | "The grid slug (e.g., 'rest-frameworks', 'admin-interfaces')", 30 | ], 31 | ) -> GridResource: 32 | """Get a specific comparison grid with all its packages. 33 | 34 | Returns detailed information about a grid including all packages 35 | that belong to it, allowing for easy comparison of similar tools. 36 | """ 37 | async with DjangoPackagesClient() as client: 38 | return await client.get_grid(slug) 39 | 40 | 41 | mcp.resource( 42 | "django://grid/{slug}", 43 | name="Django Grid Details", 44 | annotations={"readOnlyHint": True, "idempotentHint": True}, 45 | tags={DJANGOPACKAGES_TOOLSET}, 46 | )(get_grid) 47 | 48 | mcp.tool( 49 | name="get_grid", 50 | annotations=ToolAnnotations( 51 | title="djangopackages.org Grid Details", 52 | readOnlyHint=True, 53 | idempotentHint=True, 54 | ), 55 | tags={DJANGOPACKAGES_TOOLSET}, 56 | )(get_grid) 57 | 58 | 59 | async def get_package( 60 | slug: Annotated[ 61 | str, 62 | "The package slug (e.g., 'django-debug-toolbar', 'django-rest-framework')", 63 | ], 64 | ) -> PackageResource: 65 | """Get detailed information about a specific Django package. 66 | 67 | Provides comprehensive package metadata including repository stats, 68 | PyPI information, documentation links, and grid memberships. 69 | """ 70 | async with DjangoPackagesClient() as client: 71 | return await client.get_package(slug) 72 | 73 | 74 | mcp.resource( 75 | "django://package/{slug}", 76 | name="Django Package Details", 77 | annotations={"readOnlyHint": True, "idempotentHint": True}, 78 | tags={DJANGOPACKAGES_TOOLSET}, 79 | )(get_package) 80 | 81 | mcp.tool( 82 | name="get_package", 83 | annotations=ToolAnnotations( 84 | title="djangopackages.org Package Details", 85 | readOnlyHint=True, 86 | idempotentHint=True, 87 | ), 88 | tags={DJANGOPACKAGES_TOOLSET}, 89 | )(get_package) 90 | 91 | 92 | @mcp.tool( 93 | annotations=ToolAnnotations( 94 | title="Search djangopackages.org", 95 | readOnlyHint=True, 96 | idempotentHint=True, 97 | ), 98 | tags={DJANGOPACKAGES_TOOLSET}, 99 | ) 100 | async def search( 101 | ctx: Context, 102 | query: Annotated[ 103 | str, 104 | "Search term for packages (e.g., 'authentication', 'REST API', 'admin')", 105 | ], 106 | ) -> list[PackageSearchResult | GridSearchResult]: 107 | """Search djangopackages.org for third-party packages. 108 | 109 | Use this when you need packages for common Django tasks like authentication, 110 | admin interfaces, REST APIs, forms, caching, testing, deployment, etc. 111 | """ 112 | logger.info( 113 | "djangopackages.org search called - request_id: %s, query: %s", 114 | ctx.request_id, 115 | query, 116 | ) 117 | 118 | async with DjangoPackagesClient() as client: 119 | results = await client.search(query=query) 120 | 121 | logger.debug( 122 | "djangopackages.org search completed - request_id: %s, results: %d", 123 | ctx.request_id, 124 | len(results), 125 | ) 126 | 127 | return results 128 | -------------------------------------------------------------------------------- /tests/shell/test_core.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import logging 4 | 5 | import pytest 6 | from django.apps import apps 7 | 8 | from mcp_django.shell.core import DjangoShell 9 | from mcp_django.shell.core import ErrorResult 10 | from mcp_django.shell.core import StatementResult 11 | 12 | 13 | @pytest.fixture 14 | def shell(): 15 | shell = DjangoShell() 16 | yield shell 17 | shell.clear_history() 18 | 19 | 20 | class TestCodeExecution: 21 | def test_execute_simple_statement(self, shell): 22 | result = shell._execute("x = 5") 23 | 24 | assert isinstance(result, StatementResult) 25 | 26 | def test_execute_print_captures_stdout(self, shell): 27 | result = shell._execute('print("Hello, World!")') 28 | 29 | assert isinstance(result, StatementResult) 30 | assert result.stdout == "Hello, World!\n" 31 | 32 | def test_execute_multiline_with_print(self, shell): 33 | code = """\ 34 | x = 5 35 | y = 10 36 | print(f"Sum: {x + y}") 37 | """ 38 | result = shell._execute(code) 39 | 40 | assert isinstance(result, StatementResult) 41 | assert result.stdout == "Sum: 15\n" 42 | 43 | def test_execute_invalid_code_returns_error(self, shell): 44 | result = shell._execute("1 / 0") 45 | 46 | assert isinstance(result, ErrorResult) 47 | 48 | def test_execute_empty_string_returns_ok(self, shell): 49 | result = shell._execute("") 50 | 51 | assert isinstance(result, StatementResult) 52 | 53 | def test_execute_whitespace_only_returns_ok(self, shell): 54 | result = shell._execute(" \n \t ") 55 | 56 | assert isinstance(result, StatementResult) 57 | 58 | @pytest.mark.asyncio 59 | async def test_async_execute_returns_result(self): 60 | shell = DjangoShell() 61 | 62 | result = await shell.execute("x = 5") 63 | 64 | assert isinstance(result, StatementResult) 65 | 66 | 67 | class TestShellState: 68 | def test_init_django_setup_completes(self): 69 | shell = DjangoShell() 70 | 71 | assert apps.ready 72 | assert shell.history == [] 73 | 74 | def test_execution_uses_fresh_globals(self, shell): 75 | """Verify each execution uses fresh globals (stateless).""" 76 | # First execution 77 | result = shell._execute("x = 42") 78 | assert isinstance(result, StatementResult) 79 | 80 | # Second execution should NOT have access to 'x' (fresh globals) 81 | result = shell._execute("print(x)") 82 | assert isinstance(result, ErrorResult) 83 | assert isinstance(result.exception, NameError) 84 | 85 | def test_clear_history_clears_history_only(self, shell): 86 | """Verify clear_history clears the execution history.""" 87 | shell._execute("x = 42") 88 | 89 | assert len(shell.history) == 1 90 | 91 | shell.clear_history() 92 | 93 | assert len(shell.history) == 0 94 | 95 | 96 | class TestLoggingCoverage: 97 | @pytest.fixture(autouse=True) 98 | def debug_loglevel(self, caplog): 99 | caplog.set_level(logging.DEBUG) 100 | yield 101 | 102 | def test_statement_result_with_stdout_and_stderr(self, shell, caplog): 103 | code = """ 104 | import sys 105 | sys.stdout.write("Output message\\n") 106 | sys.stderr.write("Error message\\n") 107 | x = 42 108 | """ 109 | result = shell._execute(code.strip()) 110 | 111 | assert isinstance(result, StatementResult) 112 | assert result.stdout == "Output message\n" 113 | assert result.stderr == "Error message\n" 114 | assert "StatementResult.stdout: Output message" in caplog.text 115 | assert "StatementResult.stderr: Error message" in caplog.text 116 | 117 | def test_error_result_with_stdout_and_stderr(self, shell, caplog): 118 | code = """ 119 | import sys 120 | sys.stdout.write("Before error\\n") 121 | sys.stderr.write("Warning before error\\n") 122 | 1 / 0 123 | """ 124 | result = shell._execute(code.strip()) 125 | 126 | assert isinstance(result, ErrorResult) 127 | assert result.stdout == "Before error\n" 128 | assert result.stderr == "Warning before error\n" 129 | assert "ErrorResult.stdout: Before error" in caplog.text 130 | assert "ErrorResult.stderr: Warning before error" in caplog.text 131 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Created by https://www.toptal.com/developers/gitignore/api/python 2 | # Edit at https://www.toptal.com/developers/gitignore?templates=python 3 | 4 | ### Python ### 5 | # Byte-compiled / optimized / DLL files 6 | __pycache__/ 7 | *.py[cod] 8 | *$py.class 9 | 10 | # C extensions 11 | *.so 12 | 13 | # Distribution / packaging 14 | .Python 15 | build/ 16 | develop-eggs/ 17 | dist/ 18 | downloads/ 19 | eggs/ 20 | .eggs/ 21 | lib/ 22 | lib64/ 23 | parts/ 24 | sdist/ 25 | var/ 26 | wheels/ 27 | share/python-wheels/ 28 | *.egg-info/ 29 | .installed.cfg 30 | *.egg 31 | MANIFEST 32 | 33 | # PyInstaller 34 | # Usually these files are written by a python script from a template 35 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 36 | *.manifest 37 | *.spec 38 | 39 | # Installer logs 40 | pip-log.txt 41 | pip-delete-this-directory.txt 42 | 43 | # Unit test / coverage reports 44 | htmlcov/ 45 | .tox/ 46 | .nox/ 47 | .coverage 48 | .coverage.* 49 | .cache 50 | nosetests.xml 51 | coverage.xml 52 | *.cover 53 | *.py,cover 54 | .hypothesis/ 55 | .pytest_cache/ 56 | cover/ 57 | 58 | # Translations 59 | *.mo 60 | *.pot 61 | 62 | # Django stuff: 63 | *.log 64 | local_settings.py 65 | db.sqlite3 66 | db.sqlite3-journal 67 | 68 | # Flask stuff: 69 | instance/ 70 | .webassets-cache 71 | 72 | # Scrapy stuff: 73 | .scrapy 74 | 75 | # Sphinx documentation 76 | docs/_build/ 77 | 78 | # PyBuilder 79 | .pybuilder/ 80 | target/ 81 | 82 | # Jupyter Notebook 83 | .ipynb_checkpoints 84 | 85 | # IPython 86 | profile_default/ 87 | ipython_config.py 88 | 89 | # pyenv 90 | # For a library or package, you might want to ignore these files since the code is 91 | # intended to run in multiple environments; otherwise, check them in: 92 | # .python-version 93 | 94 | # pipenv 95 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 96 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 97 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 98 | # install all needed dependencies. 99 | #Pipfile.lock 100 | 101 | # poetry 102 | # Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. 103 | # This is especially recommended for binary packages to ensure reproducibility, and is more 104 | # commonly ignored for libraries. 105 | # https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control 106 | #poetry.lock 107 | 108 | # pdm 109 | # Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. 110 | #pdm.lock 111 | # pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it 112 | # in version control. 113 | # https://pdm.fming.dev/#use-with-ide 114 | .pdm.toml 115 | 116 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm 117 | __pypackages__/ 118 | 119 | # Celery stuff 120 | celerybeat-schedule 121 | celerybeat.pid 122 | 123 | # SageMath parsed files 124 | *.sage.py 125 | 126 | # Environments 127 | .env 128 | .venv 129 | env/ 130 | venv/ 131 | ENV/ 132 | env.bak/ 133 | venv.bak/ 134 | 135 | # Spyder project settings 136 | .spyderproject 137 | .spyproject 138 | 139 | # Rope project settings 140 | .ropeproject 141 | 142 | # mkdocs documentation 143 | /site 144 | 145 | # mypy 146 | .mypy_cache/ 147 | .dmypy.json 148 | dmypy.json 149 | 150 | # Pyre type checker 151 | .pyre/ 152 | 153 | # pytype static type analyzer 154 | .pytype/ 155 | 156 | # Cython debug symbols 157 | cython_debug/ 158 | 159 | # PyCharm 160 | # JetBrains specific template is maintained in a separate JetBrains.gitignore that can 161 | # be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore 162 | # and can be added to the global gitignore or merged into this file. For a more nuclear 163 | # option (not recommended) you can uncomment the following to ignore the entire idea folder. 164 | #.idea/ 165 | 166 | ### Python Patch ### 167 | # Poetry local configuration file - https://python-poetry.org/docs/configuration/#local-configuration 168 | poetry.toml 169 | 170 | # ruff 171 | .ruff_cache/ 172 | 173 | # LSP config files 174 | pyrightconfig.json 175 | 176 | # End of https://www.toptal.com/developers/gitignore/api/python 177 | 178 | uv.lock 179 | 180 | # Worktrees 181 | .worktrees/ 182 | -------------------------------------------------------------------------------- /src/mcp_django/mgmt/server.py: -------------------------------------------------------------------------------- 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 134 | -------------------------------------------------------------------------------- /RELEASING.md: -------------------------------------------------------------------------------- 1 | # Releasing a New Version 2 | 3 | When it comes time to cut a new release, follow these steps: 4 | 5 | 1. Create a new git branch off of `main` for the release. 6 | 7 | Prefer the convention `release-`, where `` is the next incremental version number (e.g. `release-v0.1.0` for version 0.1.0). 8 | 9 | ```bash 10 | git checkout -b release-v 11 | ``` 12 | 13 | However, the branch name is not *super* important, as long as it is not `main`. 14 | 15 | 2. Update the version number across the project using the `bumpver` tool. See [this section](#choosing-the-next-version-number) for more details about choosing the correct version number. 16 | 17 | The `pyproject.toml` in the base of the repository contains a `[tool.bumpver]` section that configures the `bumpver` tool to update the version number wherever it needs to be updated and to create a commit with the appropriate commit message. 18 | 19 | You can run `bumpver` directly using `uv run --with bumpver bumpver ` or the more concise `uvx bumpver `. 20 | 21 | Run `bumpver` to update the version number, with the appropriate command line arguments. See the [`bumpver` documentation](https://github.com/mbarkhau/bumpver) for more details. **Note**: For any of the following commands, you can add the command line flag `--dry` to preview the changes without actually making the changes. 22 | 23 | Here are the most common commands you will need to run: 24 | 25 | ```bash 26 | uvx bumpver update --patch # for a patch release 27 | uvx bumpver update --minor # for a minor release 28 | uvx bumpver update --major # for a major release 29 | ``` 30 | 31 | To release a tagged version, such as a beta or release candidate, you can run: 32 | 33 | ```bash 34 | uvx bumpver update --tag=beta 35 | # or 36 | uvx bumpver update --tag=rc 37 | ``` 38 | 39 | Running these commands on a tagged version will increment the tag appropriately, but will not increment the version number. 40 | 41 | To go from a tagged release to a full release, you can run: 42 | 43 | ```bash 44 | uvx bumpver update --tag=final 45 | ``` 46 | 47 | 3. Ensure the [CHANGELOG](CHANGELOG.md) is up to date. If updates are needed, add them now in the release branch. 48 | 49 | 4. Create a pull request from the release branch to `main`. 50 | 51 | 5. Once CI has passed and all the checks are green ✅, merge the pull request. 52 | 53 | 6. Draft a [new release](https://github.com/joshuadavidthomas/mcp-django/releases/new) on GitHub. 54 | 55 | Use the version number with a leading `v` as the tag name (e.g. `v0.1.0`). 56 | 57 | Allow GitHub to generate the release title and release notes, using the 'Generate release notes' button above the text box. If this is a final release coming from a tagged release (or multiple tagged releases), make sure to copy the release notes from the previous tagged release(s) to the new release notes (after the release notes already generated for this final release). 58 | 59 | If this is a tagged release, make sure to check the 'Set as a pre-release' checkbox. 60 | 61 | 7. Once you are satisfied with the release, publish the release. As part of the publication process, GitHub Actions will automatically publish the new version of the package to PyPI. 62 | 63 | ## Scripts 64 | 65 | While the above steps describe the manual release process, this project includes automation scripts to streamline releases: 66 | 67 | ### Using `bump.py` 68 | 69 | The `.bin/bump.py` script automates steps 1-4 above: 70 | 71 | ```bash 72 | # Creates release branch, bumps version, updates CHANGELOG, and creates PR 73 | uv run .bin/bump.py --version patch 74 | uv run .bin/bump.py --version minor 75 | uv run .bin/bump.py --version major 76 | 77 | # With tags 78 | uv run .bin/bump.py --version patch --tag beta 79 | 80 | # Or using just 81 | just bump patch 82 | just bump minor 83 | just bump major 84 | ``` 85 | 86 | ### Using release.py 87 | 88 | After the PR from `bump.py` is merged, the `.bin/release.py` script automates creating the GitHub release: 89 | 90 | ```bash 91 | # Creates GitHub release with auto-generated notes 92 | uv run .bin/release.py 93 | 94 | # Or using just 95 | just release 96 | ``` 97 | 98 | This script will: 99 | 100 | - Verify you're on the main branch 101 | - Check that your local main is up to date 102 | - Extract the version from the latest commit 103 | - Create the GitHub release with generated release notes 104 | 105 | ## Choosing the Next Version Number 106 | 107 | We try our best to adhere to [Semantic Versioning](https://semver.org/), but we do not promise to follow it perfectly (and let's be honest, this is the case with a lot of projects using SemVer). 108 | 109 | In general, use your best judgement when choosing the next version number. If you are unsure, you can always ask for a second opinion from another contributor. 110 | -------------------------------------------------------------------------------- /src/mcp_django/packages/client.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import logging 4 | from enum import Enum 5 | from typing import Annotated 6 | from typing import Any 7 | from typing import Literal 8 | 9 | import httpx 10 | from pydantic import BaseModel 11 | from pydantic import BeforeValidator 12 | from pydantic import Discriminator 13 | from pydantic import TypeAdapter 14 | from pydantic import model_validator 15 | 16 | logger = logging.getLogger(__name__) 17 | 18 | 19 | def extract_slug_from_url(value: str | None) -> str | None: 20 | if value is None: 21 | return None 22 | return value.rstrip("/").split("/")[-1] 23 | 24 | 25 | def extract_slugs_from_urls(value: list[str] | None) -> list[str] | None: 26 | if value is None: 27 | return None 28 | slugs = [extract_slug_from_url(url) for url in value if url] 29 | return [s for s in slugs if s is not None] 30 | 31 | 32 | def parse_participant_list(value: str | list[str] | None) -> int | None: 33 | if value is None: 34 | return None 35 | participants = value.split(",") if isinstance(value, str) else value 36 | return len([p.strip() for p in participants if p.strip()]) 37 | 38 | 39 | CategorySlug = Annotated[str, BeforeValidator(extract_slug_from_url)] 40 | GridSlugs = Annotated[list[str] | None, BeforeValidator(extract_slugs_from_urls)] 41 | PackageSlugs = Annotated[list[str] | int, BeforeValidator(extract_slugs_from_urls)] 42 | ParticipantCount = Annotated[int | None, BeforeValidator(parse_participant_list)] 43 | 44 | 45 | class PackageResource(BaseModel): 46 | category: CategorySlug 47 | slug: str 48 | title: str 49 | description: str | None = None 50 | documentation_url: str | None = None 51 | grids: GridSlugs = None 52 | last_updated: str | None = None 53 | participants: ParticipantCount = None 54 | pypi_url: str | None = None 55 | pypi_version: str | None = None 56 | repo_description: str | None = None 57 | repo_forks: int | None = None 58 | repo_url: str | None = None 59 | repo_watchers: int = 0 60 | 61 | @model_validator(mode="before") 62 | @classmethod 63 | def transform_v3_api_response(cls, data: Any) -> Any: 64 | if "modified" in data: 65 | data["last_updated"] = data.pop("modified") 66 | 67 | if not data.get("description"): 68 | data["description"] = data.get("repo_description") 69 | 70 | return data 71 | 72 | 73 | class GridResource(BaseModel): 74 | title: str 75 | slug: str 76 | description: str 77 | packages: PackageSlugs 78 | 79 | 80 | class SearchItemType(str, Enum): 81 | GRID = "grid" 82 | PACKAGE = "package" 83 | 84 | 85 | class PackageSearchResult(BaseModel): 86 | item_type: Literal[SearchItemType.PACKAGE] = SearchItemType.PACKAGE 87 | slug: str 88 | title: str 89 | description: str | None = None 90 | repo_watchers: int = 0 91 | repo_forks: int = 0 92 | participants: ParticipantCount = None 93 | last_committed: str | None = None 94 | last_released: str | None = None 95 | 96 | 97 | class GridSearchResult(BaseModel): 98 | item_type: Literal[SearchItemType.GRID] = SearchItemType.GRID 99 | slug: str 100 | title: str 101 | description: str | None = None 102 | 103 | 104 | SearchResultList = TypeAdapter( 105 | list[Annotated[PackageSearchResult | GridSearchResult, Discriminator("item_type")]] 106 | ) 107 | 108 | 109 | class DjangoPackagesClient: 110 | BASE_URL_V3 = "https://djangopackages.org/api/v3" 111 | BASE_URL_V4 = "https://djangopackages.org/api/v4" 112 | TIMEOUT = 30.0 113 | 114 | def __init__(self): 115 | self.client = httpx.AsyncClient( 116 | timeout=self.TIMEOUT, 117 | headers={"Content-Type": "application/json"}, 118 | ) 119 | logger.debug("Django Packages client initialized") 120 | 121 | async def __aenter__(self): 122 | return self 123 | 124 | async def __aexit__(self, *args: Any): 125 | await self.client.aclose() 126 | 127 | async def _request(self, method: str, url: str, **kwargs: Any) -> httpx.Response: 128 | response = await self.client.request(method, url, **kwargs) 129 | response.raise_for_status() 130 | return response 131 | 132 | async def search( 133 | self, 134 | query: str, 135 | ) -> list[PackageSearchResult | GridSearchResult]: 136 | logger.debug("Searching: query=%s", query) 137 | response = await self._request( 138 | "GET", f"{self.BASE_URL_V4}/search/", params={"q": query} 139 | ) 140 | results = SearchResultList.validate_json(response.content) 141 | logger.debug("Search complete: returned=%d", len(results)) 142 | return results 143 | 144 | async def get_package(self, slug_or_id: str) -> PackageResource: 145 | logger.debug("Fetching package: %s", slug_or_id) 146 | response = await self._request( 147 | "GET", f"{self.BASE_URL_V3}/packages/{slug_or_id}/" 148 | ) 149 | return PackageResource.model_validate_json(response.content) 150 | 151 | async def get_grid(self, slug_or_id: str) -> GridResource: 152 | logger.debug("Fetching grid: %s", slug_or_id) 153 | response = await self._request("GET", f"{self.BASE_URL_V3}/grids/{slug_or_id}/") 154 | return GridResource.model_validate_json(response.content) 155 | -------------------------------------------------------------------------------- /src/mcp_django/shell/server.py: -------------------------------------------------------------------------------- 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 django_shell 11 | from .output import DjangoShellOutput 12 | from .output import ErrorOutput 13 | 14 | logger = logging.getLogger(__name__) 15 | 16 | mcp = FastMCP( 17 | name="Shell", 18 | instructions="Execute Python code in a stateless Django shell. Each execution uses fresh state - no variables or imports persist between calls. This ensures code changes always take effect. Use for ORM queries, model exploration, and testing. Export session history to save your work. Only synchronous operations supported.", 19 | ) 20 | 21 | SHELL_TOOLSET = "shell" 22 | 23 | 24 | @mcp.tool( 25 | annotations=ToolAnnotations( 26 | title="Django Shell", destructiveHint=True, openWorldHint=True 27 | ), 28 | tags={SHELL_TOOLSET}, 29 | ) 30 | async def execute( 31 | ctx: Context, 32 | code: Annotated[ 33 | str, 34 | "Python code to be executed inside the Django shell session", 35 | ], 36 | ) -> DjangoShellOutput | str: 37 | """Execute Python code in a stateless Django shell session. 38 | 39 | Django is pre-configured and ready to use with your project. You can import and use any Django 40 | models, utilities, or Python libraries as needed. Each execution uses fresh state, so code changes 41 | always take effect immediately. 42 | 43 | Useful exploration commands: 44 | - To explore available models, use `django.apps.apps.get_models()`. 45 | - For configuration details, use `django.conf.settings`. 46 | 47 | **NOTE**: that only synchronous Django ORM operations are supported - use standard methods like 48 | `.filter()` and `.get()` rather than their async counterparts (`.afilter()`, `.aget()`). 49 | """ 50 | 51 | logger.info( 52 | "django_shell execute action called - request_id: %s, client_id: %s, code: %s", 53 | ctx.request_id, 54 | ctx.client_id or "unknown", 55 | (code[:100] + "..." if len(code) > 100 else code).replace("\n", "\\n"), 56 | ) 57 | logger.debug( 58 | "Full code for django_shell - request_id: %s: %s", ctx.request_id, code 59 | ) 60 | 61 | try: 62 | result = await django_shell.execute(code) 63 | output = DjangoShellOutput.from_result(result) 64 | 65 | logger.debug( 66 | "django_shell execution completed - request_id: %s, result type: %s", 67 | ctx.request_id, 68 | type(result).__name__, 69 | ) 70 | if isinstance(output.output, ErrorOutput): 71 | await ctx.debug(f"Execution failed: {output.output.exception.message}") 72 | 73 | return output 74 | 75 | except Exception as e: 76 | logger.error( 77 | "Unexpected error in django_shell tool - request_id: %s: %s", 78 | ctx.request_id, 79 | e, 80 | exc_info=True, 81 | ) 82 | raise 83 | 84 | 85 | @mcp.tool( 86 | annotations=ToolAnnotations( 87 | title="Export Django Shell History", 88 | openWorldHint=True, 89 | ), 90 | tags={SHELL_TOOLSET}, 91 | ) 92 | async def export_history( 93 | ctx: Context, 94 | filename: Annotated[ 95 | str | None, 96 | "Optional filename to save to (relative to project dir). If None, returns script as string.", 97 | ] = None, 98 | ) -> str: 99 | """Export shell session history as a Python script. 100 | 101 | Returns a Python script containing all successfully executed code from the 102 | current session. Failed execution attempts are excluded. Useful for saving 103 | debugging sessions or creating reproducible scripts from interactive exploration. 104 | 105 | The exported script will deduplicate import statements at the top of the script. 106 | Execution results and output are not included in the export. 107 | 108 | If filename is provided, the script is saved to a file in the project directory. 109 | If no filename is provided, the script content is returned as a string. 110 | """ 111 | logger.info( 112 | "export_history called - request_id: %s, client_id: %s, filename: %s", 113 | ctx.request_id, 114 | ctx.client_id or "unknown", 115 | filename or "None", 116 | ) 117 | 118 | try: 119 | result = django_shell.export_history(filename=filename) 120 | 121 | if filename: 122 | await ctx.debug(f"Exported history to {filename}") 123 | else: 124 | await ctx.debug("Exported history as string") 125 | 126 | logger.debug( 127 | "export_history completed - request_id: %s", 128 | ctx.request_id, 129 | ) 130 | 131 | return result 132 | 133 | except Exception as e: 134 | logger.error( 135 | "Error exporting history - request_id: %s: %s", 136 | ctx.request_id, 137 | e, 138 | exc_info=True, 139 | ) 140 | raise 141 | 142 | 143 | @mcp.tool( 144 | annotations=ToolAnnotations( 145 | title="Clear Django Shell History", 146 | destructiveHint=True, 147 | ), 148 | tags={SHELL_TOOLSET}, 149 | ) 150 | async def clear_history(ctx: Context) -> str: 151 | """Clear the Django shell session history. 152 | 153 | Use this when you want to start with a clean history for the next export, 154 | or when the history has become cluttered with exploratory code. This is 155 | similar to the old reset() but only clears the execution history, not the 156 | execution state (which is already fresh for each call). 157 | """ 158 | logger.info( 159 | "clear_history called - request_id: %s, client_id: %s", 160 | ctx.request_id, 161 | ctx.client_id or "unknown", 162 | ) 163 | 164 | await ctx.debug("Django shell history cleared") 165 | 166 | django_shell.clear_history() 167 | 168 | logger.debug( 169 | "clear_history completed - request_id: %s", 170 | ctx.request_id, 171 | ) 172 | 173 | return "Django shell history has been cleared." 174 | -------------------------------------------------------------------------------- /tests/toolsets/test_packages.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import httpx 4 | import pytest 5 | import pytest_asyncio 6 | from fastmcp import Client 7 | 8 | from mcp_django.packages.client import extract_slug_from_url 9 | from mcp_django.packages.client import extract_slugs_from_urls 10 | from mcp_django.packages.client import parse_participant_list 11 | from mcp_django.server import mcp 12 | 13 | 14 | @pytest_asyncio.fixture(autouse=True) 15 | async def initialize_mcp_server(): 16 | await mcp.initialize() 17 | 18 | 19 | def test_extract_slug_from_url_with_none(): 20 | assert extract_slug_from_url(None) is None 21 | 22 | 23 | def test_extract_slugs_from_urls_with_none(): 24 | assert extract_slugs_from_urls(None) is None 25 | 26 | 27 | def test_parse_participant_list_with_none(): 28 | assert parse_participant_list(None) is None 29 | 30 | 31 | @pytest.fixture 32 | def mock_packages_grid_detail_api(respx_mock): 33 | grid_data = { 34 | "slug": "rest-frameworks", 35 | "title": "REST frameworks", 36 | "description": "Packages for building REST APIs", 37 | "packages": [ 38 | "/api/v3/packages/package-1/", 39 | "/api/v3/packages/package-2/", 40 | ], 41 | "is_locked": False, 42 | "header": False, 43 | } 44 | 45 | respx_mock.get("https://djangopackages.org/api/v3/grids/rest-frameworks/").mock( 46 | return_value=httpx.Response(200, json=grid_data) 47 | ) 48 | 49 | return grid_data 50 | 51 | 52 | @pytest.mark.asyncio 53 | async def test_get_grid_resource(mock_packages_grid_detail_api): 54 | async with Client(mcp.server) as client: 55 | contents = await client.read_resource( 56 | "django://djangopackages/grid/rest-frameworks" 57 | ) 58 | assert isinstance(contents, list) 59 | assert len(contents) > 0 60 | 61 | 62 | @pytest.mark.asyncio 63 | async def test_get_grid_tool(mock_packages_grid_detail_api): 64 | async with Client(mcp.server) as client: 65 | result = await client.call_tool( 66 | "djangopackages_get_grid", {"slug": "rest-frameworks"} 67 | ) 68 | assert result.data is not None 69 | 70 | 71 | @pytest.fixture 72 | def mock_packages_package_detail_api(respx_mock): 73 | package_data = { 74 | "slug": "django-debug-toolbar", 75 | "title": "django-debug-toolbar", 76 | "category": "/api/v3/categories/apps/", 77 | "grids": [ 78 | "/api/v3/grids/grid-21/", 79 | "/api/v3/grids/grid-11/", 80 | ], 81 | "modified": "2024-06-01T07:50:28", 82 | "repo_url": "https://github.com/jazzband/django-debug-toolbar", 83 | "pypi_version": "4.3.0", 84 | "pypi_url": "http://pypi.python.org/pypi/django-debug-toolbar", 85 | "documentation_url": "https://readthedocs.org/projects/django-debug-toolbar", 86 | "repo_forks": 1027, 87 | "repo_description": "A configurable set of panels that display various debug information", 88 | "repo_watchers": 7937, 89 | "participants": "user-1,user-2", 90 | } 91 | 92 | respx_mock.get( 93 | "https://djangopackages.org/api/v3/packages/django-debug-toolbar/" 94 | ).mock(return_value=httpx.Response(200, json=package_data)) 95 | 96 | return package_data 97 | 98 | 99 | @pytest.mark.asyncio 100 | async def test_get_package_resource(mock_packages_package_detail_api): 101 | async with Client(mcp.server) as client: 102 | contents = await client.read_resource( 103 | "django://djangopackages/package/django-debug-toolbar" 104 | ) 105 | assert isinstance(contents, list) 106 | assert len(contents) > 0 107 | 108 | 109 | @pytest.mark.asyncio 110 | async def test_get_package_tool(mock_packages_package_detail_api): 111 | async with Client(mcp.server) as client: 112 | result = await client.call_tool( 113 | "djangopackages_get_package", {"slug": "django-debug-toolbar"} 114 | ) 115 | assert result.data is not None 116 | 117 | 118 | @pytest.mark.asyncio 119 | async def test_search_djangopackages_tool(respx_mock): 120 | search_data = [ 121 | { 122 | "id": 1, 123 | "title": "django-allauth", 124 | "slug": "django-allauth", 125 | "description": "Integrated set of Django applications addressing authentication", 126 | "category": "App", 127 | "item_type": "package", 128 | "pypi_url": "https://pypi.org/project/django-allauth/", 129 | "repo_url": "https://github.com/pennersr/django-allauth", 130 | "documentation_url": "https://docs.allauth.org/", 131 | "repo_watchers": 8500, 132 | "last_committed": "2024-01-15T10:30:00", 133 | "last_released": None, 134 | }, 135 | { 136 | "id": 2, 137 | "title": "django-oauth-toolkit", 138 | "slug": "django-oauth-toolkit", 139 | "description": "OAuth2 goodies for Django", 140 | "category": "App", 141 | "item_type": "package", 142 | "pypi_url": "https://pypi.org/project/django-oauth-toolkit/", 143 | "repo_url": "https://github.com/jazzband/django-oauth-toolkit", 144 | "documentation_url": "https://django-oauth-toolkit.readthedocs.io/", 145 | "repo_watchers": 2900, 146 | "last_committed": None, 147 | "last_released": "2024-01-10T14:20:00", 148 | }, 149 | { 150 | "id": 3, 151 | "title": "Authentication", 152 | "slug": "authentication", 153 | "description": "This is a grid of all packages for user authentication.", 154 | "item_type": "grid", 155 | }, 156 | ] 157 | 158 | respx_mock.get("https://djangopackages.org/api/v4/search/").mock( 159 | return_value=httpx.Response(200, json=search_data) 160 | ) 161 | async with Client(mcp.server) as client: 162 | result = await client.call_tool( 163 | "djangopackages_search", {"query": "authentication"} 164 | ) 165 | assert result.data is not None 166 | assert len(result.data) > 0 167 | -------------------------------------------------------------------------------- /noxfile.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import json 4 | import os 5 | import re 6 | from pathlib import Path 7 | 8 | import nox 9 | 10 | nox.options.default_venv_backend = "uv|virtualenv" 11 | nox.options.reuse_existing_virtualenvs = True 12 | 13 | PY310 = "3.10" 14 | PY311 = "3.11" 15 | PY312 = "3.12" 16 | PY313 = "3.13" 17 | PY314 = "3.14" 18 | PY_VERSIONS = [PY310, PY311, PY312, PY313, PY314] 19 | PY_DEFAULT = PY_VERSIONS[0] 20 | PY_LATEST = PY_VERSIONS[-1] 21 | 22 | DJ42 = "4.2" 23 | DJ51 = "5.1" 24 | DJ52 = "5.2" 25 | DJ60 = "6.0a1" 26 | DJMAIN = "main" 27 | DJMAIN_MIN_PY = PY312 28 | DJ_VERSIONS = [DJ42, DJ51, DJ52, DJ60, DJMAIN] 29 | DJ_LTS = [ 30 | version for version in DJ_VERSIONS if version.endswith(".2") and version != DJMAIN 31 | ] 32 | DJ_DEFAULT = DJ_LTS[0] 33 | DJ_LATEST = DJ_VERSIONS[-2] 34 | 35 | 36 | def version(ver: str) -> tuple[int, ...]: 37 | """Convert a string version to a tuple of ints, e.g. "3.10" -> (3, 10)""" 38 | return tuple(map(int, ver.split("."))) 39 | 40 | 41 | def display_version(raw: str) -> str: 42 | match = re.match(r"\d+(?:\.\d+)?", raw) 43 | return match.group(0) if match else raw 44 | 45 | 46 | def should_skip(python: str, django: str) -> bool: 47 | """Return True if the test should be skipped""" 48 | 49 | if django == DJMAIN and version(python) < version(DJMAIN_MIN_PY): 50 | # Django main requires Python 3.10+ 51 | return True 52 | 53 | if django == DJ60 and version(python) < version(PY312): 54 | # Django 6.0 requires Python 3.12+ 55 | return True 56 | 57 | if django == DJ52 and version(python) < version(PY310): 58 | # Django 5.2 requires Python 3.10+ 59 | return True 60 | 61 | if django == DJ51 and version(python) < version(PY310): 62 | # Django 5.1 requires Python 3.10+ 63 | return True 64 | 65 | return False 66 | 67 | 68 | @nox.session 69 | def test(session): 70 | session.notify(f"tests(python='{PY_DEFAULT}', django='{DJ_DEFAULT}')") 71 | 72 | 73 | @nox.session 74 | @nox.parametrize( 75 | "python,django", 76 | [ 77 | (python, django) 78 | for python in PY_VERSIONS 79 | for django in DJ_VERSIONS 80 | if not should_skip(python, django) 81 | ], 82 | ) 83 | def tests(session, django): 84 | session.run_install( 85 | "uv", 86 | "sync", 87 | "--all-extras", 88 | "--frozen", 89 | "--inexact", 90 | "--no-install-package", 91 | "django", 92 | "--python", 93 | session.python, 94 | env={"UV_PROJECT_ENVIRONMENT": session.virtualenv.location}, 95 | ) 96 | 97 | if django == DJMAIN: 98 | session.install( 99 | "django @ https://github.com/django/django/archive/refs/heads/main.zip" 100 | ) 101 | else: 102 | session.install(f"django=={django}") 103 | 104 | command = ["python", "-m", "pytest"] 105 | if session.posargs: 106 | args = [] 107 | for arg in session.posargs: 108 | args.extend(arg.split(" ")) 109 | command.extend(args) 110 | session.run(*command) 111 | 112 | 113 | @nox.session 114 | def coverage(session): 115 | session.run_install( 116 | "uv", 117 | "sync", 118 | "--all-extras", 119 | "--frozen", 120 | "--python", 121 | PY_DEFAULT, 122 | env={"UV_PROJECT_ENVIRONMENT": session.virtualenv.location}, 123 | ) 124 | 125 | try: 126 | command = ["python", "-m", "pytest", "--cov", "--cov-report="] 127 | if session.posargs: 128 | args = [] 129 | for arg in session.posargs: 130 | args.extend(arg.split(" ")) 131 | command.extend(args) 132 | session.run(*command) 133 | finally: 134 | # 0 -> OK 135 | # 2 -> code coverage percent unmet 136 | success_codes = [0, 2] 137 | 138 | report_cmd = ["python", "-m", "coverage", "report", "--show-missing"] 139 | session.run(*report_cmd, success_codes=success_codes) 140 | 141 | if summary := os.getenv("GITHUB_STEP_SUMMARY"): 142 | report_cmd.extend(["--skip-covered", "--skip-empty", "--format=markdown"]) 143 | 144 | with Path(summary).open("a") as output_buffer: 145 | output_buffer.write("") 146 | output_buffer.write("### Coverage\n\n") 147 | output_buffer.flush() 148 | session.run( 149 | *report_cmd, stdout=output_buffer, success_codes=success_codes 150 | ) 151 | else: 152 | session.run( 153 | "python", 154 | "-m", 155 | "coverage", 156 | "html", 157 | "--skip-covered", 158 | "--skip-empty", 159 | success_codes=success_codes, 160 | ) 161 | 162 | 163 | @nox.session 164 | def types(session): 165 | session.run_install( 166 | "uv", 167 | "sync", 168 | "--all-extras", 169 | "--group", 170 | "types", 171 | "--frozen", 172 | "--python", 173 | PY_LATEST, 174 | env={"UV_PROJECT_ENVIRONMENT": session.virtualenv.location}, 175 | ) 176 | 177 | command = ["python", "-m", "mypy", "."] 178 | if session.posargs and all(arg for arg in session.posargs): 179 | command.append(*session.posargs) 180 | session.run(*command) 181 | 182 | 183 | @nox.session 184 | def lint(session): 185 | session.run( 186 | "uv", 187 | "run", 188 | "--with", 189 | "pre-commit-uv", 190 | "--python", 191 | PY_LATEST, 192 | "pre-commit", 193 | "run", 194 | "--all-files", 195 | ) 196 | 197 | 198 | @nox.session 199 | def gha_matrix(session): 200 | sessions = session.run("nox", "-l", "--json", silent=True) 201 | matrix = { 202 | "include": [ 203 | { 204 | "python-version": session["python"], 205 | "django-version": session["call_spec"]["django"], 206 | } 207 | for session in json.loads(sessions) 208 | if session["name"] == "tests" 209 | ] 210 | } 211 | with Path(os.environ["GITHUB_OUTPUT"]).open("a") as fh: 212 | print(f"matrix={matrix}", file=fh) 213 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing 2 | 3 | All contributions are welcome! Besides code contributions, this includes things like documentation improvements, bug reports, and feature requests. 4 | 5 | You should first check if there is a [GitHub issue](https://github.com/joshuadavidthomas/mcp-django/issues) already open or related to what you would like to contribute. If there is, please comment on that issue to let others know you are working on it. If there is not, please open a new issue to discuss your contribution. 6 | 7 | Not all contributions need to start with an issue, such as typo fixes in documentation or version bumps to Python or Django that require no internal code changes, but generally, it is a good idea to open an issue first. 8 | 9 | We adhere to Django's Code of Conduct in all interactions and expect all contributors to do the same. Please read the [Code of Conduct](https://www.djangoproject.com/conduct/) before contributing. 10 | 11 | ## Requirements 12 | 13 | - [uv](https://github.com/astral-sh/uv) - Modern Python toolchain that handles: 14 | - Python version management and installation 15 | - Virtual environment creation and management 16 | - Fast, reliable dependency resolution and installation 17 | - Reproducible builds via lockfile 18 | - [direnv](https://github.com/direnv/direnv) (Optional) - Automatic environment variable loading 19 | - [just](https://github.com/casey/just) (Optional) - Command runner for development tasks 20 | 21 | ### `Justfile` 22 | 23 | The repository includes a `Justfile` that provides all common development tasks with a consistent interface. Running `just` without arguments shows all available commands and their descriptions. 24 | 25 | 47 | ```bash 48 | $ just 49 | $ # just --list --list-submodules 50 | 51 | Available recipes: 52 | bootstrap 53 | coverage *ARGS 54 | lint 55 | lock *ARGS 56 | manage *COMMAND 57 | test *ARGS 58 | testall *ARGS 59 | types *ARGS 60 | project: 61 | bump *ARGS 62 | release *ARGS 63 | ``` 64 | 65 | 66 | All commands below will contain the full command as well as its `just` counterpart. 67 | 68 | ## Setup 69 | 70 | The following instructions will use `uv` and assume a Unix-like operating system (Linux or macOS). 71 | 72 | Windows users will need to adjust commands accordingly, though the core workflow remains the same. 73 | 74 | Alternatively, any Python package manager that supports installing from `pyproject.toml` ([PEP 621](https://peps.python.org/pep-0621/)) can be used. If not using `uv`, ensure you have Python installed from [python.org](https://www.python.org/) or another source such as [`pyenv`](https://github.com/pyenv/pyenv). 75 | 76 | 1. Fork the repository and clone it locally. 77 | 78 | 2. Use `uv` to bootstrap your development environment. 79 | 80 | ```bash 81 | uv python install 82 | uv sync --locked 83 | # just bootstrap 84 | ``` 85 | 86 | This will install the correct Python version, create and configure a virtual environment, and install all dependencies. 87 | 88 | ## Tests 89 | 90 | The project uses [`pytest`](https://docs.pytest.org/) for testing and [`nox`](https://nox.thea.codes/) to run the tests in multiple environments. 91 | 92 | To run the test suite against the default versions of Python (lower bound of supported versions) and Django (lower bound of LTS versions): 93 | 94 | ```bash 95 | uv run nox --session test 96 | # just test 97 | ``` 98 | 99 | To run the test suite against the entire matrix of supported versions of Python and Django: 100 | 101 | ```bash 102 | uv run nox --session tests 103 | # just testall 104 | ``` 105 | 106 | Both can be passed additional arguments that will be provided to `pytest`. 107 | 108 | ```bash 109 | uv run nox --session test -- -v --last-failed 110 | uv run nox --session tests -- --failed-first --maxfail=1 111 | # just test -v --last-failed 112 | # just testall --failed-first --maxfail=1 113 | ``` 114 | 115 | ### Coverage 116 | 117 | The project uses [`coverage.py`](https://github.com/nedbat/coverage.py) to measure code coverage and aims to maintain 100% coverage across the codebase. 118 | 119 | To run the test suite and measure code coverage: 120 | 121 | ```bash 122 | uv run nox --session coverage 123 | # just coverage 124 | ``` 125 | 126 | All pull requests must include tests to maintain 100% coverage. Coverage configuration can be found in the `[tools.coverage.*]` sections of [`pyproject.toml`](pyproject.toml). 127 | 128 | ## Linting and Formatting 129 | 130 | This project enforces code quality standards using [`pre-commit`](https://github.com/pre-commit/pre-commit). 131 | 132 | To run all formatters and linters: 133 | 134 | ```bash 135 | uv run nox --session lint 136 | # just lint 137 | ``` 138 | 139 | The following checks are run: 140 | 141 | - [ruff](https://github.com/astral-sh/ruff) - Fast Python linter and formatter 142 | - Code formatting for Python files in documentation ([blacken-docs](https://github.com/adamchainz/blacken-docs)) 143 | - Django compatibility checks ([django-upgrade](https://github.com/adamchainz/django-upgrade)) 144 | - TOML and YAML validation 145 | - Basic file hygiene (trailing whitespace, file endings) 146 | 147 | To enable pre-commit hooks after cloning: 148 | 149 | ```bash 150 | uv run --with pre-commit pre-commit install 151 | ``` 152 | 153 | Configuration for these tools can be found in: 154 | 155 | - [`.pre-commit-config.yaml`](.pre-commit-config.yaml) - Pre-commit hook configuration 156 | - [`pyproject.toml`](pyproject.toml) - Ruff and other tool settings 157 | 158 | ## Continuous Integration 159 | 160 | This project uses GitHub Actions for CI/CD. The workflows can be found in [`.github/workflows/`](.github/workflows/). 161 | 162 | - [`test.yml`](.github/workflows/test.yml) - Runs on pushes to the `main` branch and on all PRs 163 | - Tests across Python/Django version matrix 164 | - Static type checking 165 | - Coverage reporting 166 | - [`release.yml`](.github/workflows/release.yml) - Runs on GitHub release creation 167 | - Runs the [`test.yml`](.github/workflows/test.yml) workflow 168 | - Builds package 169 | - Publishes to PyPI 170 | 171 | PRs must pass all CI checks before being merged. 172 | -------------------------------------------------------------------------------- /.bin/release.py: -------------------------------------------------------------------------------- 1 | # /// script 2 | # requires-python = ">=3.10" 3 | # dependencies = [ 4 | # "rich", 5 | # "typer", 6 | # ] 7 | # /// 8 | from __future__ import annotations 9 | 10 | import re 11 | import subprocess 12 | from pathlib import Path 13 | from typing import Annotated 14 | 15 | import typer 16 | from rich.console import Console 17 | 18 | cli = typer.Typer() 19 | console = Console() 20 | 21 | 22 | def run( 23 | cmd: list[str], 24 | *, 25 | dry_run: bool = False, 26 | force_run: bool = False, 27 | ) -> str: 28 | command_str = " ".join(cmd) 29 | console.print( 30 | f"would run command: {command_str}" 31 | if dry_run and not force_run 32 | else f"running command: {command_str}" 33 | ) 34 | 35 | if dry_run and not force_run: 36 | return "" 37 | 38 | try: 39 | return subprocess.check_output(cmd, text=True, stderr=subprocess.STDOUT).strip() 40 | except subprocess.CalledProcessError as e: 41 | console.print(f"[red]{cmd[0]} failed: {e.output}[/red]") 42 | raise typer.Exit(1) from e 43 | 44 | 45 | def get_calver() -> str: 46 | """Read CalVer version from .github/VERSION file.""" 47 | version_file = Path(".github/VERSION") 48 | if not version_file.exists(): 49 | console.print("[red]No .github/VERSION file found. Run bump.py first.[/red]") 50 | raise typer.Exit(1) 51 | 52 | calver = version_file.read_text().strip() 53 | console.print(f"[dim]Found CalVer: {calver}[/dim]") 54 | return calver 55 | 56 | 57 | def get_workspace_packages() -> list[str]: 58 | """Get list of workspace packages from packages/ directory.""" 59 | packages_dir = Path("packages") 60 | if not packages_dir.exists(): 61 | return [] 62 | 63 | packages = [] 64 | for pkg_dir in packages_dir.iterdir(): 65 | if pkg_dir.is_dir() and (pkg_dir / "pyproject.toml").exists(): 66 | packages.append(pkg_dir.name) 67 | return sorted(packages) 68 | 69 | 70 | def get_package_versions() -> dict[str, str]: 71 | """Get current versions of all packages using uv.""" 72 | packages = {} 73 | 74 | # Get root package version 75 | console.print("[dim]Getting package versions...[/dim]") 76 | output = run(["uv", "version"], force_run=True) 77 | # Parse output like "mcp-django 0.2.0" or just "0.2.0" 78 | if match := re.search(r"(?:mcp-django\s+)?([\d.]+(?:[-.\w]*)?)", output): 79 | packages["mcp-django"] = match.group(1) 80 | console.print(f" mcp-django: {match.group(1)}") 81 | 82 | # Get workspace package versions 83 | for package in get_workspace_packages(): 84 | output = run(["uv", "version", "--package", package], force_run=True) 85 | # Parse output like "mcp-django-shell 0.9.0" or just "0.9.0" 86 | if match := re.search(r"(?:[\w-]+\s+)?([\d.]+(?:[-.\w]*)?)", output): 87 | packages[package] = match.group(1) 88 | console.print(f" {package}: {match.group(1)}") 89 | 90 | return packages 91 | 92 | 93 | @cli.command() 94 | def release( 95 | dry_run: Annotated[ 96 | bool, typer.Option("--dry-run", "-d", help="Show commands without executing") 97 | ] = False, 98 | force: Annotated[ 99 | bool, typer.Option("--force", "-f", help="Skip safety checks") 100 | ] = False, 101 | ): 102 | """Create a new release with CalVer and package-specific tags.""" 103 | 104 | # Safety checks 105 | current_branch = run(["git", "branch", "--show-current"], force_run=True).strip() 106 | if current_branch != "main" and not force: 107 | console.print( 108 | f"[red]Must be on main branch to create release (currently on {current_branch})[/red]" 109 | ) 110 | raise typer.Exit(1) 111 | 112 | if run(["git", "status", "--porcelain"], force_run=True) and not force: 113 | console.print( 114 | "[red]Working directory is not clean. Commit or stash changes first.[/red]" 115 | ) 116 | raise typer.Exit(1) 117 | 118 | run(["git", "fetch", "origin", "main"], dry_run=dry_run) 119 | local_sha = run(["git", "rev-parse", "@"], force_run=True).strip() 120 | remote_sha = run(["git", "rev-parse", "@{u}"], force_run=True).strip() 121 | if local_sha != remote_sha and not force: 122 | console.print( 123 | "[red]Local main is not up to date with remote. Pull changes first.[/red]" 124 | ) 125 | raise typer.Exit(1) 126 | 127 | # Get CalVer from VERSION file 128 | calver = get_calver() 129 | 130 | # Check if CalVer release already exists (with v prefix) 131 | calver_tag = f"v{calver}" 132 | try: 133 | run(["gh", "release", "view", calver_tag], force_run=True) 134 | if not force: 135 | console.print(f"[red]Release {calver_tag} already exists![/red]") 136 | raise typer.Exit(1) 137 | except Exception: 138 | pass # Release doesn't exist, good to proceed 139 | 140 | # Get current package versions 141 | packages = get_package_versions() 142 | 143 | # Show what we're about to release 144 | console.print(f"\n[bold]Creating release {calver}[/bold]") 145 | for package, version in packages.items(): 146 | console.print(f" [cyan]{package}:[/cyan] {version}") 147 | 148 | # Confirm with user 149 | if not force and not dry_run: 150 | typer.confirm("\nProceed with release?", abort=True) 151 | 152 | # Create tags 153 | console.print("\n[bold]Creating tags...[/bold]") 154 | tags = [] 155 | 156 | # CalVer tag with v prefix 157 | calver_tag = f"v{calver}" 158 | tags.append(calver_tag) 159 | console.print(f" [green]✓[/green] {calver_tag}") 160 | 161 | # Package-specific tags 162 | for package, version in packages.items(): 163 | tag = f"{package}-v{version}" 164 | tags.append(tag) 165 | console.print(f" [green]✓[/green] {tag}") 166 | 167 | # Create all tags locally 168 | for tag in tags: 169 | run(["git", "tag", tag], dry_run=dry_run) 170 | 171 | # Push all tags at once 172 | console.print("\n[bold]Pushing tags to origin...[/bold]") 173 | run(["git", "push", "origin"] + tags, dry_run=dry_run) 174 | 175 | # Create GitHub release with CalVer tag 176 | console.print(f"\n[bold]Creating GitHub release {calver_tag}...[/bold]") 177 | run(["gh", "release", "create", calver_tag, "--generate-notes"], dry_run=dry_run) 178 | 179 | # Success message 180 | console.print(f"\n[bold green]✓ Released {calver_tag}![/bold green]") 181 | console.print( 182 | "\n[dim]The CI/CD pipeline will now build and publish packages to PyPI.[/dim]" 183 | ) 184 | 185 | 186 | if __name__ == "__main__": 187 | cli() 188 | -------------------------------------------------------------------------------- /src/mcp_django/mgmt/core.py: -------------------------------------------------------------------------------- 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 223 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | requires = ["uv_build>=0.7.21,<0.8"] 3 | build-backend = "uv_build" 4 | 5 | [dependency-groups] 6 | dev = [ 7 | "coverage[toml]>=7.6.4", 8 | "django-coverage-plugin>=3.1.0", 9 | "nox[uv]>=2024.10.9", 10 | "pytest>=8.4.1", 11 | "pytest-asyncio>=1.1.0", 12 | "pytest-cov>=6.2.1", 13 | "pytest-django>=4.10.0", 14 | "pytest-randomly>=3.16.0", 15 | "pytest-xdist>=3.8.0", 16 | "respx>=0.21.1", 17 | "ruff>=0.12.9", 18 | ] 19 | types = [ 20 | "django-stubs>=5.1.1", 21 | "django-stubs-ext>=5.1.1", 22 | "mypy>=1.13.0", 23 | ] 24 | 25 | [project] 26 | authors = [ 27 | { name = "Josh Thomas", email = "josh@joshthomas.dev" } 28 | ] 29 | classifiers = [ 30 | "Development Status :: 4 - Beta", 31 | "Framework :: Django", 32 | # [[[cog 33 | # import subprocess 34 | # import cog 35 | # 36 | # from noxfile import DJ_VERSIONS 37 | # from noxfile import display_version 38 | # 39 | # for version in DJ_VERSIONS: 40 | # if version == "main": 41 | # continue 42 | # cog.outl(f' "Framework :: Django :: {display_version(version)}",') 43 | # ]]] --> 44 | "Framework :: Django :: 4.2", 45 | "Framework :: Django :: 5.1", 46 | "Framework :: Django :: 5.2", 47 | "Framework :: Django :: 6.0", 48 | # [[[end]]] 49 | "License :: OSI Approved :: MIT License", 50 | "Operating System :: OS Independent", 51 | "Programming Language :: Python", 52 | "Programming Language :: Python :: 3", 53 | "Programming Language :: Python :: 3 :: Only", 54 | # [[[cog 55 | # import subprocess 56 | # import cog 57 | # 58 | # from noxfile import PY_VERSIONS 59 | # 60 | # for version in PY_VERSIONS: 61 | # cog.outl(f' "Programming Language :: Python :: {version}",') 62 | # ]]] --> 63 | "Programming Language :: Python :: 3.10", 64 | "Programming Language :: Python :: 3.11", 65 | "Programming Language :: Python :: 3.12", 66 | "Programming Language :: Python :: 3.13", 67 | "Programming Language :: Python :: 3.14", 68 | # [[[end]]] 69 | "Programming Language :: Python :: Implementation :: CPython" 70 | ] 71 | dependencies = [ 72 | "django>4.2", 73 | "fastmcp>=2.11.3", 74 | "httpx>=0.28.1", 75 | "platformdirs>=4.5.0", 76 | "pydantic[email]>=2.11.7", 77 | ] 78 | description = "A Model Context Protocol (MCP) server for Django integration with LLM assistants." 79 | license = { file = "LICENSE" } 80 | name = "mcp-django" 81 | readme = "README.md" 82 | # [[[cog 83 | # import cog 84 | # 85 | # from noxfile import PY_DEFAULT 86 | # 87 | # cog.outl(f'requires-python = ">={PY_DEFAULT}"') 88 | # ]]] --> 89 | requires-python = ">=3.10" 90 | # [[[end]]] 91 | version = "0.13.0" 92 | 93 | [project.urls] 94 | Documentation = "https://github.com/joshuadavidthomas/mcp-django#README" 95 | Issues = "https://github.com/joshuadavidthomas/mcp-django/issues" 96 | Source = "https://github.com/joshuadavidthomas/mcp-django" 97 | 98 | [[tool.basedpyright.executionEnvironments]] 99 | reportAny = "none" 100 | reportArgumentType = "none" 101 | reportAssignmentType = "none" 102 | reportAttributeAccessIssue = "none" 103 | reportCallIssue = "none" 104 | reportExplicitAny = "none" 105 | reportGeneralTypeIssues = "none" 106 | reportIncompatibleMethodOverride = "none" 107 | reportIncompatibleVariableOverride = "none" 108 | reportIndexIssue = "none" 109 | reportInvalidTypeArguments = "none" 110 | reportMissingParameterType = "none" 111 | reportMissingTypeArgument = "none" 112 | reportOperatorIssue = "none" 113 | reportOptionalCall = "none" 114 | reportOptionalContextManager = "none" 115 | reportOptionalIterable = "none" 116 | reportOptionalMemberAccess = "none" 117 | reportOptionalOperand = "none" 118 | reportOptionalSubscript = "none" 119 | reportPropertyTypeMismatch = "none" 120 | reportReturnType = "none" 121 | reportUnknownArgumentType = "none" 122 | reportUnknownLambdaType = "none" 123 | reportUnknownMemberType = "none" 124 | reportUnknownParameterType = "none" 125 | reportUnknownVariableType = "none" 126 | reportUntypedBaseClass = "none" 127 | reportUntypedClassDecorator = "none" 128 | reportUntypedFunctionDecorator = "none" 129 | reportUnusedCallResult = "none" 130 | root = "tests" 131 | 132 | [tool.coverage.paths] 133 | source = [ 134 | "packages/*/src", 135 | "src" 136 | ] 137 | 138 | [tool.coverage.report] 139 | exclude_lines = [ 140 | "pragma: no cover", 141 | "if DEBUG:", 142 | "if not DEBUG:", 143 | "if settings.DEBUG:", 144 | "if TYPE_CHECKING:", 145 | 'def __str__\(self\)\s?\-?\>?\s?\w*\:' 146 | ] 147 | fail_under = 100 148 | 149 | [tool.coverage.run] 150 | omit = [ 151 | "*/migrations/*", 152 | "src/mcp_django/management/commands/mcp.py", 153 | "src/mcp_django/__main__.py", 154 | "src/mcp_django/_typing.py", 155 | "tests/*" 156 | ] 157 | source = [ 158 | "src/mcp_django" 159 | ] 160 | 161 | [tool.django-stubs] 162 | django_settings_module = "tests.settings" 163 | strict_settings = false 164 | 165 | [tool.mypy] 166 | check_untyped_defs = true 167 | exclude = [ 168 | ".venv", 169 | "docs", 170 | "migrations", 171 | "tests", 172 | "venv" 173 | ] 174 | mypy_path = "src/" 175 | no_implicit_optional = true 176 | plugins = [ 177 | "mypy_django_plugin.main" 178 | ] 179 | warn_redundant_casts = true 180 | warn_unused_configs = true 181 | warn_unused_ignores = true 182 | 183 | [[tool.mypy.overrides]] 184 | ignore_errors = true 185 | ignore_missing_imports = true 186 | module = [ 187 | "*.migrations.*", 188 | "docs.*", 189 | "tests.*" 190 | ] 191 | 192 | [tool.mypy_django_plugin] 193 | ignore_missing_model_attributes = true 194 | 195 | [tool.pytest.ini_options] 196 | DJANGO_SETTINGS_MODULE = "tests.settings" 197 | addopts = "--create-db -n auto --dist loadfile --doctest-modules" 198 | asyncio_default_fixture_loop_scope = "function" 199 | filterwarnings = [ 200 | "ignore:Overriding setting DATABASES can lead to unexpected behavior.", 201 | ] 202 | norecursedirs = ".* bin build dist *.egg htmlcov logs node_modules templates venv" 203 | python_files = "tests.py test_*.py *_tests.py" 204 | pythonpath = [ 205 | "src", 206 | "." 207 | ] 208 | testpaths = ["tests"] 209 | 210 | [tool.ruff] 211 | # Exclude a variety of commonly ignored directories. 212 | exclude = [ 213 | ".bzr", 214 | ".direnv", 215 | ".eggs", 216 | ".git", 217 | ".github", 218 | ".hg", 219 | ".mypy_cache", 220 | ".ruff_cache", 221 | ".svn", 222 | ".tox", 223 | ".venv", 224 | "__pypackages__", 225 | "_build", 226 | "build", 227 | "dist", 228 | "migrations", 229 | "node_modules", 230 | "venv" 231 | ] 232 | extend-include = ["*.pyi?"] 233 | indent-width = 4 234 | # Same as Black. 235 | line-length = 88 236 | 237 | [tool.ruff.format] 238 | # Like Black, indent with spaces, rather than tabs. 239 | indent-style = "space" 240 | # Like Black, automatically detect the appropriate line ending. 241 | line-ending = "auto" 242 | # Like Black, use double quotes for strings. 243 | quote-style = "double" 244 | 245 | [tool.ruff.lint] 246 | # Allow unused variables when underscore-prefixed. 247 | dummy-variable-rgx = "^(_+|(_+[a-zA-Z0-9_]*[a-zA-Z0-9]+?))$" 248 | # Allow autofix for all enabled rules (when `--fix`) is provided. 249 | fixable = ["A", "B", "C", "D", "E", "F", "I"] 250 | ignore = ["E501", "E741"] # temporary 251 | select = [ 252 | "B", # flake8-bugbear 253 | "E", # Pycodestyle 254 | "F", # Pyflakes 255 | "I", # isort 256 | "UP" # pyupgrade 257 | ] 258 | unfixable = [] 259 | 260 | [tool.ruff.lint.isort] 261 | force-single-line = true 262 | known-first-party = [ 263 | "mcp_django", 264 | "tests" 265 | ] 266 | required-imports = ["from __future__ import annotations"] 267 | 268 | [tool.ruff.lint.per-file-ignores] 269 | # Tests can use magic values, assertions, and relative imports 270 | "tests/**/*" = ["PLR2004", "S101", "TID252"] 271 | 272 | [tool.ruff.lint.pyupgrade] 273 | # Preserve types, even if a file imports `from __future__ import annotations`. 274 | keep-runtime-typing = true 275 | 276 | [tool.uv] 277 | required-version = ">=0.7" 278 | -------------------------------------------------------------------------------- /src/mcp_django/project/resources.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import inspect 4 | import os 5 | import sys 6 | import sysconfig 7 | from pathlib import Path 8 | from typing import Any 9 | from typing import Literal 10 | 11 | import django 12 | from django.apps import AppConfig 13 | from django.apps import apps 14 | from django.conf import settings 15 | from django.contrib.auth import get_user_model 16 | from django.db import models 17 | from pydantic import BaseModel 18 | from pydantic import field_serializer 19 | 20 | 21 | def get_source_file_path(obj: Any) -> Path: 22 | target = obj if inspect.isclass(obj) else obj.__class__ 23 | try: 24 | return Path(inspect.getfile(target)) 25 | except (TypeError, OSError): 26 | return Path("unknown") 27 | 28 | 29 | def is_first_party_app(app_config: AppConfig) -> bool: 30 | """Check if an app is first-party (project code) vs third-party (installed package). 31 | 32 | Uses Python's sysconfig to determine installation paths, which properly handles 33 | all installation scenarios (pip, conda, virtualenv, etc.) and is platform-independent. 34 | 35 | Args: 36 | app_config: Django AppConfig to check 37 | 38 | Returns: 39 | True if the app is first-party project code, False if third-party or stdlib 40 | """ 41 | try: 42 | # Defensive check for malformed app configs (should never happen in practice) 43 | if app_config.module is None: # pragma: no cover 44 | return False 45 | 46 | app_path = Path(inspect.getfile(app_config.module)).resolve() 47 | 48 | for lib_path in [ 49 | Path(sysconfig.get_path("purelib")), # site-packages (pure Python) 50 | Path(sysconfig.get_path("platlib")), # site-packages (platform-specific) 51 | Path(sysconfig.get_path("stdlib")), # standard library 52 | ]: 53 | try: 54 | if app_path.is_relative_to(lib_path): 55 | return False 56 | 57 | # Windows-specific: paths on different drives (e.g., C:\ vs D:\) 58 | except ValueError: # pragma: no cover 59 | pass 60 | 61 | return True 62 | 63 | # Defensive error handling for built-in modules or broken app configs 64 | except (TypeError, OSError): # pragma: no cover 65 | return False 66 | 67 | 68 | def filter_models( 69 | models: list[Any], 70 | include: list[str] | None = None, 71 | scope: Literal["project", "all"] = "project", 72 | ) -> list[Any]: 73 | """Filter Django models by app inclusion or scope. 74 | 75 | Args: 76 | models: List of Django model classes to filter 77 | include: Specific app labels to include (overrides scope) 78 | scope: "project" for project models, "all" for everything 79 | 80 | Returns: 81 | Filtered list of model classes 82 | """ 83 | filtered_models = [] 84 | 85 | for model in models: 86 | app_label = model._meta.app_label 87 | 88 | # If include is specified, only filter by include (ignore scope) 89 | if include is not None: 90 | if app_label in include: 91 | filtered_models.append(model) 92 | # Otherwise, filter by scope 93 | elif scope == "project" and is_first_party_app(apps.get_app_config(app_label)): 94 | filtered_models.append(model) 95 | elif scope == "all": 96 | filtered_models.append(model) 97 | 98 | return filtered_models 99 | 100 | 101 | class ProjectResource(BaseModel): 102 | python: PythonResource 103 | django: DjangoResource 104 | 105 | @classmethod 106 | def from_env(cls) -> ProjectResource: 107 | py = PythonResource.from_sys() 108 | dj = DjangoResource.from_django() 109 | return ProjectResource(python=py, django=dj) 110 | 111 | 112 | class PythonResource(BaseModel): 113 | base_prefix: Path 114 | executable: Path 115 | path: list[Path] 116 | platform: str 117 | prefix: Path 118 | version_info: tuple[ 119 | int, int, int, Literal["alpha", "beta", "candidate", "final"], int 120 | ] 121 | 122 | @classmethod 123 | def from_sys(cls) -> PythonResource: 124 | return cls( 125 | base_prefix=Path(sys.base_prefix), 126 | executable=Path(sys.executable), 127 | path=[Path(p) for p in sys.path], 128 | platform=sys.platform, 129 | prefix=Path(sys.prefix), 130 | version_info=sys.version_info, 131 | ) 132 | 133 | 134 | class DjangoResource(BaseModel): 135 | apps: list[str] 136 | auth_user_model: str | None 137 | base_dir: Path 138 | databases: dict[str, dict[str, str]] 139 | debug: bool 140 | settings_module: str 141 | version: tuple[int, int, int, Literal["alpha", "beta", "rc", "final"], int] 142 | 143 | @classmethod 144 | def from_django(cls) -> DjangoResource: 145 | app_names = [app_config.name for app_config in apps.get_app_configs()] 146 | 147 | databases = { 148 | db_alias: { 149 | "engine": db_config.get("ENGINE", ""), 150 | "name": str(db_config.get("NAME", "")), 151 | } 152 | for db_alias, db_config in settings.DATABASES.items() 153 | } 154 | 155 | if "django.contrib.auth" in app_names: 156 | user_model = get_user_model() 157 | auth_user_model = f"{user_model.__module__}.{user_model.__name__}" 158 | else: 159 | auth_user_model = None 160 | 161 | return cls( 162 | apps=app_names, 163 | auth_user_model=auth_user_model, 164 | base_dir=Path(getattr(settings, "BASE_DIR", Path.cwd())), 165 | databases=databases, 166 | debug=settings.DEBUG, 167 | settings_module=os.environ.get("DJANGO_SETTINGS_MODULE", ""), 168 | version=django.VERSION, 169 | ) 170 | 171 | 172 | class AppResource(BaseModel): 173 | name: str 174 | label: str 175 | path: Path 176 | models: list[ModelResource] 177 | 178 | @classmethod 179 | def from_app(cls, app: AppConfig) -> AppResource: 180 | appconfig = get_source_file_path(app) 181 | app_path = appconfig.parent if appconfig != Path("unknown") else Path("unknown") 182 | 183 | app_models = ( 184 | [ 185 | ModelResource.from_model(model) 186 | for model in app.models.values() 187 | if not model._meta.auto_created 188 | ] 189 | if app.models 190 | else [] 191 | ) 192 | 193 | return cls(name=app.name, label=app.label, path=app_path, models=app_models) 194 | 195 | @field_serializer("models") 196 | def serialize_models(self, models: list[ModelResource]) -> list[str]: 197 | return [model.model_dump()["model_class"] for model in models] 198 | 199 | 200 | class ModelResource(BaseModel): 201 | model_class: type[models.Model] 202 | import_path: str 203 | source_path: Path 204 | fields: dict[str, str] 205 | 206 | @classmethod 207 | def from_model(cls, model: type[models.Model]): 208 | field_types = { 209 | field.name: field.__class__.__name__ for field in model._meta.fields 210 | } 211 | 212 | return cls( 213 | model_class=model, 214 | import_path=f"{model.__module__}.{model.__name__}", 215 | source_path=get_source_file_path(model), 216 | fields=field_types, 217 | ) 218 | 219 | @field_serializer("model_class") 220 | def serialize_model_class(self, klass: type[models.Model]) -> str: 221 | return klass.__name__ 222 | 223 | 224 | class SettingResource(BaseModel): 225 | key: str 226 | value: Any 227 | value_type: str 228 | 229 | @field_serializer("value") 230 | def serialize_value(self, value: Any) -> Any: 231 | # Handle common Django types that need conversion 232 | if isinstance(value, Path): 233 | return str(value) 234 | if isinstance(value, type): # Class objects 235 | return f"{value.__module__}.{value.__name__}" 236 | return value 237 | -------------------------------------------------------------------------------- /tests/test_resources.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import sys 4 | from pathlib import Path 5 | 6 | from django.apps import apps 7 | from django.conf import settings 8 | from django.test import override_settings 9 | 10 | from mcp_django.project.resources import AppResource 11 | from mcp_django.project.resources import DjangoResource 12 | from mcp_django.project.resources import ModelResource 13 | from mcp_django.project.resources import ProjectResource 14 | from mcp_django.project.resources import PythonResource 15 | from mcp_django.project.resources import SettingResource 16 | from mcp_django.project.resources import get_source_file_path 17 | from mcp_django.project.resources import is_first_party_app 18 | from tests.models import AModel 19 | 20 | 21 | def test_get_source_file_path_with_class(): 22 | result = get_source_file_path(AModel) 23 | assert isinstance(result, Path) 24 | assert result != Path("unknown") 25 | 26 | 27 | def test_get_source_file_path_with_instance(): 28 | result = get_source_file_path(AModel()) 29 | assert isinstance(result, Path) 30 | assert result != Path("unknown") 31 | 32 | 33 | def test_get_source_file_path_unknown(): 34 | # Built-in types like int don't have source files, so this should trigger the exception path 35 | result = get_source_file_path(42) 36 | assert isinstance(result, Path) 37 | assert result == Path("unknown") 38 | 39 | 40 | def test_get_source_file_path_valueerror(monkeypatch): 41 | mock_obj = object() 42 | 43 | monkeypatch.setattr( 44 | "mcp_django.project.resources.inspect.getfile", 45 | lambda obj: "/usr/lib/python3.12/os.py", 46 | ) 47 | monkeypatch.setattr( 48 | "mcp_django.project.resources.Path.cwd", 49 | lambda: Path("/completely/different/path"), 50 | ) 51 | 52 | result = get_source_file_path(mock_obj) 53 | assert str(result) == "/usr/lib/python3.12/os.py" 54 | 55 | 56 | def test_is_first_party_app_first_party(): 57 | """Test that project apps are correctly identified as first-party.""" 58 | tests_app = apps.get_app_config("tests") 59 | result = is_first_party_app(tests_app) 60 | assert result is True 61 | 62 | 63 | @override_settings( 64 | INSTALLED_APPS=settings.INSTALLED_APPS 65 | + ["django.contrib.auth", "django.contrib.contenttypes"] 66 | ) 67 | def test_is_first_party_app_third_party(): 68 | """Test that Django built-in apps are correctly identified as third-party.""" 69 | auth_app = apps.get_app_config("auth") 70 | result = is_first_party_app(auth_app) 71 | assert result is False 72 | 73 | 74 | def test_project_resource_from_env(): 75 | result = ProjectResource.from_env() 76 | 77 | assert isinstance(result.python, PythonResource) 78 | assert isinstance(result.django, DjangoResource) 79 | 80 | data = result.model_dump() 81 | assert "python" in data 82 | assert "django" in data 83 | 84 | 85 | def test_python_resource_from_sys(): 86 | result = PythonResource.from_sys() 87 | 88 | assert result.base_prefix == Path(sys.base_prefix) 89 | assert result.executable == Path(sys.executable) 90 | assert result.path == [Path(p) for p in sys.path] 91 | assert result.platform == sys.platform 92 | assert result.prefix == Path(sys.prefix) 93 | assert result.version_info == sys.version_info 94 | 95 | 96 | @override_settings( 97 | INSTALLED_APPS=settings.INSTALLED_APPS 98 | + [ 99 | "django.contrib.auth", 100 | "django.contrib.contenttypes", 101 | ] 102 | ) 103 | def test_django_resource_from_django(): 104 | result = DjangoResource.from_django() 105 | 106 | assert isinstance(result.apps, list) 107 | assert len(result.apps) > 0 108 | assert "django.contrib.auth" in result.apps 109 | assert result.auth_user_model is not None # Should have auth user model 110 | assert isinstance(result.base_dir, Path) 111 | assert isinstance(result.databases, dict) 112 | assert isinstance(result.debug, bool) 113 | assert isinstance(result.settings_module, str) 114 | assert isinstance(result.version, tuple) 115 | 116 | data = result.model_dump() 117 | 118 | assert "apps" in data 119 | assert "databases" in data 120 | 121 | 122 | def test_django_resource_without_auth(): 123 | result = DjangoResource.from_django() 124 | assert result.auth_user_model is None 125 | 126 | 127 | def test_django_resource_without_base_dir(monkeypatch): 128 | monkeypatch.delattr(settings, "BASE_DIR", raising=False) 129 | resource = DjangoResource.from_django() 130 | assert resource.base_dir == Path.cwd() 131 | 132 | 133 | @override_settings( 134 | DATABASES={ 135 | "sqlite": { 136 | "ENGINE": "django.db.backends.sqlite3", 137 | "NAME": Path("/tmp/db.sqlite3"), 138 | }, 139 | "postgres": {"ENGINE": "django.db.backends.postgresql", "NAME": "mydb"}, 140 | } 141 | ) 142 | def test_django_resource_mixed_databases(): 143 | resource = DjangoResource.from_django() 144 | assert isinstance(resource.databases["sqlite"]["name"], str) 145 | assert isinstance(resource.databases["postgres"]["name"], str) 146 | 147 | 148 | @override_settings(DATABASES={"default": {"ENGINE": "django.db.backends.sqlite3"}}) 149 | def test_django_resource_missing_db_name(): 150 | resource = DjangoResource.from_django() 151 | assert resource.databases["default"]["name"] == "" 152 | 153 | 154 | def test_app_resource_from_app(): 155 | tests_app = apps.get_app_config("tests") 156 | 157 | result = AppResource.from_app(tests_app) 158 | 159 | assert result.name == "tests" 160 | assert result.label == "tests" 161 | assert isinstance(result.path, Path) 162 | assert isinstance(result.models, list) 163 | assert len(result.models) > 0 164 | 165 | data = result.model_dump() 166 | 167 | assert isinstance(data["models"], list) 168 | assert all(isinstance(model_class, str) for model_class in data["models"]) 169 | assert len(data["models"]) > 0 170 | 171 | 172 | def test_model_resource_from_model(): 173 | result = ModelResource.from_model(AModel) 174 | 175 | assert result.model_class == AModel 176 | assert result.import_path == "tests.models.AModel" 177 | assert isinstance(result.source_path, Path) 178 | assert isinstance(result.fields, dict) 179 | assert "name" in result.fields 180 | assert "value" in result.fields 181 | assert "created_at" in result.fields 182 | 183 | data = result.model_dump() 184 | 185 | assert data["model_class"] == "AModel" 186 | 187 | 188 | def test_setting_resource_with_bool(): 189 | result = SettingResource(key="DEBUG", value=False, value_type="bool") 190 | 191 | assert result.key == "DEBUG" 192 | assert result.value is False 193 | assert result.value_type == "bool" 194 | 195 | data = result.model_dump() 196 | assert data["value"] is False 197 | 198 | 199 | def test_setting_resource_with_list(): 200 | apps = ["django.contrib.auth", "myapp"] 201 | result = SettingResource(key="INSTALLED_APPS", value=apps, value_type="list") 202 | 203 | assert result.key == "INSTALLED_APPS" 204 | assert result.value == apps 205 | assert result.value_type == "list" 206 | 207 | 208 | def test_setting_resource_with_dict(): 209 | databases = {"default": {"ENGINE": "django.db.backends.sqlite3"}} 210 | result = SettingResource(key="DATABASES", value=databases, value_type="dict") 211 | 212 | assert result.key == "DATABASES" 213 | assert result.value == databases 214 | assert result.value_type == "dict" 215 | 216 | 217 | def test_setting_resource_serializes_path(): 218 | from pathlib import Path 219 | 220 | base_dir = Path("/home/user/project") 221 | result = SettingResource(key="BASE_DIR", value=base_dir, value_type="PosixPath") 222 | 223 | data = result.model_dump() 224 | assert data["value"] == "/home/user/project" 225 | assert isinstance(data["value"], str) 226 | 227 | 228 | def test_setting_resource_serializes_class(): 229 | # Use AModel from tests since it doesn't require extra apps installed 230 | result = SettingResource(key="SOME_MODEL_CLASS", value=AModel, value_type="type") 231 | 232 | data = result.model_dump() 233 | assert data["value"] == "tests.models.AModel" 234 | assert isinstance(data["value"], str) 235 | -------------------------------------------------------------------------------- /tests/shell/test_export.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import os 4 | from pathlib import Path 5 | 6 | import pytest 7 | 8 | from mcp_django.shell.core import DjangoShell 9 | 10 | 11 | @pytest.fixture 12 | def shell(): 13 | shell = DjangoShell() 14 | yield shell 15 | shell.clear_history() 16 | 17 | 18 | class TestExportHistory: 19 | def test_export_empty_history(self, shell): 20 | """Export with no history returns empty comment.""" 21 | result = shell.export_history() 22 | assert "No history" in result 23 | 24 | def test_export_basic_code(self, shell): 25 | """Export basic execution to script.""" 26 | shell._execute("x = 2 + 2") 27 | 28 | script = shell.export_history() 29 | 30 | assert "# Django Shell Session Export" in script 31 | assert "# Step 1" in script 32 | assert "x = 2 + 2" in script 33 | 34 | def test_export_excludes_output(self, shell): 35 | """Export does not include execution results.""" 36 | shell._execute("print(2 + 2)") 37 | 38 | script = shell.export_history() 39 | 40 | assert "# → 4" not in script 41 | assert "print(2 + 2)" in script 42 | 43 | def test_export_excludes_errors(self, shell): 44 | """Export excludes error results.""" 45 | shell._execute("1 / 0") 46 | 47 | script = shell.export_history() 48 | 49 | # Should have header but no steps 50 | assert "# Django Shell Session Export" in script 51 | assert "1 / 0" not in script 52 | 53 | def test_export_continuous_step_numbers(self, shell): 54 | """Export has continuous step numbers even when errors are skipped.""" 55 | # Execute: success, error, success 56 | shell._execute("x = 2 + 2") 57 | 58 | shell._execute("1 / 0") 59 | 60 | shell._execute("y = 3 + 3") 61 | 62 | script = shell.export_history() 63 | 64 | # Should have Step 1 and Step 2 (not Step 1 and Step 3) 65 | assert "# Step 1" in script 66 | assert "# Step 2" in script 67 | assert "# Step 3" not in script 68 | assert "x = 2 + 2" in script 69 | assert "1 / 0" not in script 70 | assert "y = 3 + 3" in script 71 | 72 | def test_export_deduplicates_imports(self, shell): 73 | """Export always consolidates imports at the top.""" 74 | # Execute code with same import twice (without DB access) 75 | code1 = "from datetime import datetime\nx = datetime.now()" 76 | shell._execute(code1) 77 | 78 | code2 = "from datetime import datetime\ny = datetime.now()" 79 | shell._execute(code2) 80 | 81 | script = shell.export_history() 82 | 83 | # Import should appear at top before steps 84 | lines = script.split("\n") 85 | 86 | # Find where steps start and where consolidated imports are 87 | first_step_idx = next(i for i, l in enumerate(lines) if "# Step 1" in l) 88 | 89 | # The consolidated import should be before the first step 90 | consolidated_section = "\n".join(lines[:first_step_idx]) 91 | assert "from datetime import datetime" in consolidated_section 92 | 93 | # Steps should still have the full code (imports aren't removed from steps) 94 | steps_section = "\n".join(lines[first_step_idx:]) 95 | assert "from datetime import datetime" in steps_section 96 | 97 | def test_export_to_file(self, shell, tmp_path): 98 | """Export saves to file when filename provided.""" 99 | shell._execute("x = 2 + 2") 100 | 101 | # Use temp directory 102 | old_cwd = Path.cwd() 103 | os.chdir(tmp_path) 104 | 105 | try: 106 | result = shell.export_history(filename="test_export") 107 | 108 | # Should return confirmation 109 | assert "Exported" in result 110 | assert "test_export.py" in result 111 | 112 | # File should exist 113 | filepath = tmp_path / "test_export.py" 114 | assert filepath.exists() 115 | 116 | # File should contain code 117 | content = filepath.read_text() 118 | assert "x = 2 + 2" in content 119 | finally: 120 | os.chdir(old_cwd) 121 | 122 | def test_export_rejects_absolute_paths(self, shell): 123 | """Export rejects absolute paths for security.""" 124 | shell._execute("x = 2 + 2") 125 | 126 | with pytest.raises(ValueError, match="Absolute paths not allowed"): 127 | shell.export_history(filename="/tmp/evil.py") 128 | 129 | def test_export_adds_py_extension(self, shell, tmp_path): 130 | """Export adds .py extension if not present.""" 131 | shell._execute("x = 2 + 2") 132 | 133 | old_cwd = Path.cwd() 134 | os.chdir(tmp_path) 135 | 136 | try: 137 | shell.export_history(filename="test_export") 138 | 139 | # Should create test_export.py 140 | filepath = tmp_path / "test_export.py" 141 | assert filepath.exists() 142 | finally: 143 | os.chdir(old_cwd) 144 | 145 | def test_export_excludes_stdout(self, shell): 146 | """Export does not include stdout output.""" 147 | shell._execute('print("Hello, World!")') 148 | 149 | script = shell.export_history() 150 | 151 | assert "# Hello, World!" not in script 152 | assert 'print("Hello, World!")' in script 153 | 154 | def test_export_multiple_steps(self, shell): 155 | """Export handles multiple execution steps.""" 156 | # Execute multiple times 157 | for i in range(3): 158 | shell._execute(f"x{i} = {i} + {i}") 159 | 160 | script = shell.export_history() 161 | 162 | # Should have all three steps 163 | assert "# Step 1" in script 164 | assert "# Step 2" in script 165 | assert "# Step 3" in script 166 | assert "x0 = 0 + 0" in script 167 | assert "x1 = 1 + 1" in script 168 | assert "x2 = 2 + 2" in script 169 | 170 | def test_export_to_file_with_long_output(self, shell, tmp_path): 171 | """Export truncates preview for files with more than 20 lines.""" 172 | shell._execute("x = 2 + 2") 173 | 174 | # Execute enough times to create > 20 lines (header + steps) 175 | for i in range(10): 176 | shell._execute(f"x{i} = {i}") 177 | 178 | old_cwd = Path.cwd() 179 | os.chdir(tmp_path) 180 | 181 | try: 182 | result = shell.export_history(filename="test_long") 183 | 184 | # Should mention truncation 185 | assert "more lines" in result 186 | finally: 187 | os.chdir(old_cwd) 188 | 189 | def test_export_with_invalid_syntax_in_history(self, shell): 190 | """Export handles code with syntax errors gracefully.""" 191 | from mcp_django.shell.core import StatementResult 192 | 193 | # Manually add a result with code that can't be parsed 194 | # (This simulates a defensive case that shouldn't normally happen) 195 | invalid_result = StatementResult( 196 | code="if x == 1:", # Missing body, invalid syntax 197 | stdout="", 198 | stderr="", 199 | ) 200 | shell.history.append(invalid_result) 201 | 202 | # Should not crash, just include the code as-is 203 | script = shell.export_history() 204 | 205 | assert "if x == 1:" in script 206 | 207 | 208 | class TestClearHistory: 209 | def test_clear_history_clears_entries(self, shell): 210 | """Clear history removes all entries.""" 211 | shell._execute("x = 2 + 2") 212 | shell._execute("x = 2 + 2") 213 | 214 | assert len(shell.history) == 2 215 | 216 | shell.clear_history() 217 | 218 | assert len(shell.history) == 0 219 | 220 | def test_clear_history_allows_fresh_export(self, shell): 221 | """Clear history allows clean export after messy exploration.""" 222 | # Messy exploration 223 | shell._execute("1 / 0") 224 | shell._execute("1 / 0") 225 | 226 | # Clear 227 | shell.clear_history() 228 | 229 | # Clean solution 230 | shell._execute("x = 2 + 2") 231 | 232 | # Export should only have clean solution 233 | script = shell.export_history() 234 | assert "1 / 0" not in script 235 | assert "x = 2 + 2" in script 236 | -------------------------------------------------------------------------------- /tests/shell/test_server.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from unittest.mock import AsyncMock 4 | 5 | import pytest 6 | import pytest_asyncio 7 | from django.conf import settings 8 | from django.test import override_settings 9 | from fastmcp import Client 10 | from fastmcp.exceptions import ToolError 11 | 12 | from mcp_django.server import mcp 13 | from mcp_django.shell.core import django_shell 14 | from mcp_django.shell.output import ExecutionStatus 15 | 16 | pytestmark = pytest.mark.asyncio 17 | 18 | 19 | @pytest_asyncio.fixture(autouse=True) 20 | async def initialize_and_clear(): 21 | await mcp.initialize() 22 | async with Client(mcp.server) as client: 23 | await client.call_tool("shell_clear_history") 24 | 25 | 26 | async def test_shell_execute(): 27 | async with Client(mcp.server) as client: 28 | result = await client.call_tool("shell_execute", {"code": "print(2 + 2)"}) 29 | assert result.data["status"] == ExecutionStatus.SUCCESS 30 | assert result.data["stdout"] == "4\n" 31 | 32 | 33 | @override_settings( 34 | INSTALLED_APPS=settings.INSTALLED_APPS 35 | + [ 36 | "django.contrib.auth", 37 | "django.contrib.contenttypes", 38 | ] 39 | ) 40 | async def test_shell_execute_orm(): 41 | async with Client(mcp.server) as client: 42 | result = await client.call_tool( 43 | "shell_execute", 44 | { 45 | "code": "from django.contrib.auth import get_user_model; get_user_model().__name__" 46 | }, 47 | ) 48 | assert result.data["status"] == ExecutionStatus.SUCCESS 49 | 50 | 51 | async def test_shell_execute_with_imports(): 52 | async with Client(mcp.server) as client: 53 | result = await client.call_tool( 54 | "shell_execute", 55 | {"code": "import os\nprint(os.path.join('test', 'path'))"}, 56 | ) 57 | assert result.data["status"] == ExecutionStatus.SUCCESS 58 | assert result.data["stdout"] == "test/path\n" 59 | 60 | 61 | async def test_shell_execute_without_imports(): 62 | async with Client(mcp.server) as client: 63 | result = await client.call_tool("shell_execute", {"code": "print(2 + 2)"}) 64 | assert result.data["status"] == ExecutionStatus.SUCCESS 65 | assert result.data["stdout"] == "4\n" 66 | 67 | 68 | async def test_shell_execute_with_multiple_imports(): 69 | async with Client(mcp.server) as client: 70 | result = await client.call_tool( 71 | "shell_execute", 72 | { 73 | "code": "import datetime\nimport math\ndatetime.datetime.now().year + math.floor(math.pi)", 74 | }, 75 | ) 76 | assert result.data["status"] == ExecutionStatus.SUCCESS 77 | 78 | 79 | async def test_shell_execute_imports_error(): 80 | async with Client(mcp.server) as client: 81 | result = await client.call_tool( 82 | "shell_execute", 83 | {"code": "import nonexistent_module"}, 84 | ) 85 | assert result.data["status"] == ExecutionStatus.ERROR 86 | assert "ModuleNotFoundError" in str( 87 | result.data["output"]["exception"]["exc_type"] 88 | ) 89 | 90 | 91 | async def test_shell_execute_stateless(): 92 | """Test that each execution uses fresh globals (stateless).""" 93 | async with Client(mcp.server) as client: 94 | # First call imports and uses os 95 | result1 = await client.call_tool( 96 | "shell_execute", 97 | {"code": "import os\nprint(os.path.join('test', 'first'))"}, 98 | ) 99 | assert result1.data["status"] == ExecutionStatus.SUCCESS 100 | 101 | # Second call should NOT have os available (fresh globals) 102 | result2 = await client.call_tool( 103 | "shell_execute", 104 | {"code": "print(os.path.join('test', 'second'))"}, # No import! 105 | ) 106 | assert result2.data["status"] == ExecutionStatus.ERROR 107 | assert "NameError" in str(result2.data["output"]["exception"]["exc_type"]) 108 | 109 | 110 | async def test_shell_execute_error_output(): 111 | async with Client(mcp.server) as client: 112 | result = await client.call_tool("shell_execute", {"code": "1 / 0"}) 113 | 114 | assert result.data["status"] == ExecutionStatus.ERROR.value 115 | assert "ZeroDivisionError" in str( 116 | result.data["output"]["exception"]["exc_type"] 117 | ) 118 | assert "division by zero" in result.data["output"]["exception"]["message"] 119 | assert len(result.data["output"]["exception"]["traceback"]) > 0 120 | assert not any( 121 | "mcp_django/shell" in line 122 | for line in result.data["output"]["exception"]["traceback"] 123 | ) 124 | 125 | 126 | async def test_shell_execute_unexpected_error(monkeypatch): 127 | monkeypatch.setattr( 128 | django_shell, "execute", AsyncMock(side_effect=RuntimeError("Unexpected error")) 129 | ) 130 | 131 | async with Client(mcp.server) as client: 132 | with pytest.raises(ToolError, match="Unexpected error"): 133 | await client.call_tool("shell_execute", {"code": "2 + 2"}) 134 | 135 | 136 | async def test_shell_export_history_to_string(): 137 | """Test that export_history returns script as string.""" 138 | async with Client(mcp.server) as client: 139 | # Execute some code to create history 140 | await client.call_tool("shell_execute", {"code": "print(2 + 2)"}) 141 | await client.call_tool("shell_execute", {"code": "x = 5"}) 142 | 143 | # Export history 144 | result = await client.call_tool("shell_export_history") 145 | script = result.content[0].text 146 | 147 | # Verify script content 148 | assert "# Django Shell Session Export" in script 149 | assert "print(2 + 2)" in script 150 | assert "x = 5" in script 151 | 152 | 153 | async def test_shell_export_history_excludes_errors(): 154 | """Test that export_history excludes errors.""" 155 | async with Client(mcp.server) as client: 156 | # Execute code with error 157 | await client.call_tool("shell_execute", {"code": "1 / 0"}) 158 | 159 | # Export - errors should be excluded 160 | result = await client.call_tool("shell_export_history") 161 | script = result.content[0].text 162 | assert "1 / 0" not in script 163 | 164 | 165 | async def test_shell_export_history_to_file(tmp_path): 166 | """Test that export_history can save to file.""" 167 | import os 168 | old_cwd = os.getcwd() 169 | os.chdir(tmp_path) 170 | 171 | try: 172 | async with Client(mcp.server) as client: 173 | # Execute some code 174 | await client.call_tool("shell_execute", {"code": "x = 42"}) 175 | 176 | # Export to file 177 | result = await client.call_tool("shell_export_history", {"filename": "test_script"}) 178 | output = result.content[0].text 179 | 180 | # Should mention the file 181 | assert "test_script.py" in output 182 | assert "Exported" in output 183 | 184 | # File should exist 185 | assert (tmp_path / "test_script.py").exists() 186 | finally: 187 | os.chdir(old_cwd) 188 | 189 | 190 | async def test_shell_export_history_error_handling(): 191 | """Test that export_history handles exceptions gracefully.""" 192 | from unittest.mock import patch 193 | async with Client(mcp.server) as client: 194 | # Execute some code 195 | await client.call_tool("shell_execute", {"code": "x = 1"}) 196 | 197 | # Mock export_history to raise an exception 198 | with patch.object(django_shell, "export_history", side_effect=ValueError("Test error")): 199 | with pytest.raises(ToolError, match="Test error"): 200 | await client.call_tool("shell_export_history") 201 | 202 | 203 | async def test_shell_clear_history(): 204 | """Test that clear_history clears the execution history.""" 205 | async with Client(mcp.server) as client: 206 | # Execute some code to create history 207 | await client.call_tool("shell_execute", {"code": "print(2 + 2)"}) 208 | await client.call_tool("shell_execute", {"code": "print(3 + 3)"}) 209 | 210 | # Verify history exists 211 | assert len(django_shell.history) == 2 212 | 213 | # Clear history 214 | result = await client.call_tool("shell_clear_history") 215 | assert "cleared" in result.content[0].text.lower() 216 | 217 | # Verify history is empty 218 | assert len(django_shell.history) == 0 219 | -------------------------------------------------------------------------------- /src/mcp_django/shell/core.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import ast 4 | import logging 5 | from contextlib import redirect_stderr 6 | from contextlib import redirect_stdout 7 | from dataclasses import dataclass 8 | from dataclasses import field 9 | from datetime import datetime 10 | from io import StringIO 11 | from pathlib import Path 12 | 13 | import django 14 | from asgiref.sync import sync_to_async 15 | from django.apps import apps 16 | 17 | logger = logging.getLogger(__name__) 18 | 19 | 20 | class DjangoShell: 21 | def __init__(self): 22 | logger.debug("Initializing %s", self.__class__.__name__) 23 | 24 | if not apps.ready: # pragma: no cover 25 | logger.info("Django not initialized, running django.setup()") 26 | 27 | django.setup() 28 | 29 | logger.debug("Django setup completed") 30 | else: 31 | logger.debug("Django already initialized, skipping setup") 32 | 33 | self.history: list[Result] = [] 34 | 35 | logger.info("Shell initialized successfully") 36 | 37 | def clear_history(self): 38 | """Clear the execution history. 39 | 40 | Removes all entries from the shell history. Useful for starting fresh 41 | or removing exploratory code before exporting. 42 | """ 43 | logger.info("Clearing shell history - previous entries: %s", len(self.history)) 44 | self.history = [] 45 | 46 | def export_history( 47 | self, 48 | filename: str | None = None, 49 | ) -> str: 50 | """Export shell session history as a Python script. 51 | 52 | Generates a Python script containing all successfully executed code 53 | from the session. Failed executions are excluded. Import statements 54 | are deduplicated and placed at the top. Output and execution results 55 | are not included in the export. 56 | 57 | Args: 58 | filename: Relative path to save the script. If None, returns the 59 | script content as a string. Absolute paths are rejected. 60 | 61 | Returns: 62 | The Python script as a string if filename is None, otherwise a 63 | confirmation message with preview of the exported file. 64 | 65 | Raises: 66 | ValueError: If an absolute path is provided for filename. 67 | """ 68 | logger.info( 69 | "Exporting history - entries: %s, filename: %s", 70 | len(self.history), 71 | filename or "None", 72 | ) 73 | 74 | if not self.history: 75 | return "# No history to export\n" 76 | 77 | imports_set = set() 78 | steps = [] 79 | 80 | successful_codes = [ 81 | result.code for result in self.history if not isinstance(result, ErrorResult) 82 | ] 83 | 84 | for step_num, code in enumerate(successful_codes, start=1): 85 | try: 86 | tree = ast.parse(code) 87 | for node in ast.walk(tree): 88 | if isinstance(node, (ast.Import, ast.ImportFrom)): 89 | imports_set.add(ast.unparse(node)) 90 | except SyntaxError: 91 | pass 92 | 93 | steps.append(f"# Step {step_num}") 94 | steps.append(code) 95 | steps.append("") 96 | 97 | script_parts = [ 98 | "# Django Shell Session Export", 99 | f"# Generated: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}", 100 | "", 101 | ] 102 | 103 | if imports_set: 104 | script_parts.extend(sorted(imports_set)) 105 | script_parts.append("") 106 | 107 | script_parts.extend(steps) 108 | 109 | script = "\n".join(script_parts) 110 | 111 | if filename: 112 | # Security: only allow relative paths 113 | if Path(filename).is_absolute(): 114 | raise ValueError("Absolute paths not allowed for security reasons") 115 | 116 | if not filename.endswith(".py"): 117 | filename += ".py" 118 | 119 | filepath = Path(filename) 120 | filepath.write_text(script) 121 | 122 | logger.info("Exported history to file: %s", filepath) 123 | 124 | line_count = len(script.split("\n")) 125 | preview_lines = script.split("\n")[:20] 126 | preview = "\n".join(preview_lines) 127 | if line_count > 20: 128 | preview += f"\n... ({line_count - 20} more lines)" 129 | 130 | return f"Exported {line_count} lines to {filename}\n\n{preview}" 131 | 132 | return script 133 | 134 | async def execute(self, code: str) -> Result: 135 | """Execute Python code in the Django shell context (async). 136 | 137 | Async wrapper around the synchronous _execute() method. Delegates 138 | execution to a thread pool via sync_to_async to avoid Django's 139 | SynchronousOnlyOperation errors when called from async contexts. 140 | 141 | Args: 142 | code: Python code to execute. 143 | 144 | Returns: 145 | StatementResult or ErrorResult depending on execution outcome. 146 | """ 147 | 148 | return await sync_to_async(self._execute)(code) 149 | 150 | def _execute(self, code: str) -> Result: 151 | """Execute Python code in the Django shell context (synchronous). 152 | 153 | Executes code in a fresh global namespace for stateless behavior, 154 | ensuring code changes take effect and no stale modules persist between 155 | executions. Captures stdout/stderr and saves results to history. 156 | 157 | Args: 158 | code: Python code to execute. 159 | 160 | Returns: 161 | StatementResult if execution succeeds. 162 | ErrorResult if execution raises an exception. 163 | """ 164 | 165 | code_preview = (code[:100] + "..." if len(code) > 100 else code).replace( 166 | "\n", "\\n" 167 | ) 168 | logger.info("Executing code: %s", code_preview) 169 | 170 | stdout = StringIO() 171 | stderr = StringIO() 172 | 173 | with redirect_stdout(stdout), redirect_stderr(stderr): 174 | try: 175 | logger.debug( 176 | "Code to execute: %s", 177 | code[:200] + "..." if len(code) > 200 else code, 178 | ) 179 | 180 | exec(code, {}) 181 | 182 | logger.debug("Code executed successfully") 183 | 184 | return self.save_result( 185 | StatementResult( 186 | code=code, 187 | stdout=stdout.getvalue(), 188 | stderr=stderr.getvalue(), 189 | ) 190 | ) 191 | 192 | except Exception as e: 193 | logger.error( 194 | "Exception during code execution: %s - Code: %s", 195 | f"{type(e).__name__}: {e}", 196 | code_preview, 197 | ) 198 | logger.debug("Full traceback for error:", exc_info=True) 199 | 200 | return self.save_result( 201 | ErrorResult( 202 | code=code, 203 | exception=e, 204 | stdout=stdout.getvalue(), 205 | stderr=stderr.getvalue(), 206 | ) 207 | ) 208 | 209 | def save_result(self, result: Result) -> Result: 210 | self.history.append(result) 211 | return result 212 | 213 | 214 | @dataclass 215 | class StatementResult: 216 | code: str 217 | stdout: str 218 | stderr: str 219 | timestamp: datetime = field(default_factory=datetime.now) 220 | 221 | def __post_init__(self): 222 | logger.debug("%s created", self.__class__.__name__) 223 | if self.stdout: 224 | logger.debug("%s.stdout: %s", self.__class__.__name__, self.stdout[:200]) 225 | if self.stderr: 226 | logger.debug("%s.stderr: %s", self.__class__.__name__, self.stderr[:200]) 227 | 228 | 229 | @dataclass 230 | class ErrorResult: 231 | code: str 232 | exception: Exception 233 | stdout: str 234 | stderr: str 235 | timestamp: datetime = field(default_factory=datetime.now) 236 | 237 | def __post_init__(self): 238 | logger.debug( 239 | "%s created - exception type: %s", 240 | self.__class__.__name__, 241 | type(self.exception).__name__, 242 | ) 243 | logger.debug("%s.message: %s", self.__class__.__name__, str(self.exception)) 244 | if self.stdout: 245 | logger.debug("%s.stdout: %s", self.__class__.__name__, self.stdout[:200]) 246 | if self.stderr: 247 | logger.debug("%s.stderr: %s", self.__class__.__name__, self.stderr[:200]) 248 | 249 | 250 | Result = StatementResult | ErrorResult 251 | 252 | django_shell = DjangoShell() 253 | -------------------------------------------------------------------------------- /tests/test_server.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from enum import Enum 4 | 5 | import pytest 6 | import pytest_asyncio 7 | from django.conf import settings 8 | from django.test import override_settings 9 | from fastmcp import Client 10 | 11 | from mcp_django.server import mcp 12 | 13 | pytestmark = pytest.mark.asyncio 14 | 15 | 16 | class Tool(str, Enum): 17 | SHELL = "shell" 18 | LIST_ROUTES = "project_list_routes" 19 | 20 | 21 | @pytest_asyncio.fixture(autouse=True) 22 | async def initialize_and_clear(): 23 | await mcp.initialize() 24 | async with Client(mcp.server) as client: 25 | await client.call_tool("shell_clear_history") 26 | 27 | 28 | async def test_instructions_exist(): 29 | instructions = mcp.server.instructions 30 | 31 | assert instructions is not None 32 | assert len(instructions) > 100 33 | assert "Django ecosystem" in instructions 34 | assert "## Available Toolsets" in instructions 35 | assert "### Shell" in instructions 36 | assert "### djangopackages.org" in instructions 37 | 38 | 39 | async def test_tool_listing(): 40 | async with Client(mcp.server) as client: 41 | tools = await client.list_tools() 42 | tool_names = [tool.name for tool in tools] 43 | 44 | for tool_name in [ 45 | "djangopackages_get_grid", 46 | "djangopackages_get_package", 47 | "djangopackages_search", 48 | "management_execute_command", 49 | "management_list_commands", 50 | "project_get_project_info", 51 | "project_list_apps", 52 | "project_list_models", 53 | "project_list_routes", 54 | "project_get_setting", 55 | "shell_execute", 56 | "shell_clear_history", 57 | "shell_export_history", 58 | ]: 59 | assert tool_name in tool_names 60 | 61 | 62 | async def test_get_apps_resource(): 63 | async with Client(mcp.server) as client: 64 | result = await client.read_resource("django://project/apps") 65 | assert result is not None 66 | assert len(result) > 0 67 | 68 | 69 | async def test_get_models_resource(): 70 | async with Client(mcp.server) as client: 71 | result = await client.read_resource("django://project/models") 72 | assert result is not None 73 | assert len(result) > 0 74 | 75 | 76 | async def test_get_project_info_tool(): 77 | async with Client(mcp.server) as client: 78 | result = await client.call_tool("project_get_project_info", {}) 79 | 80 | assert result.data is not None 81 | assert hasattr(result.data, "python") 82 | assert hasattr(result.data, "django") 83 | assert result.data.python is not None 84 | assert result.data.django is not None 85 | assert result.data.django.version is not None 86 | 87 | 88 | @override_settings( 89 | INSTALLED_APPS=settings.INSTALLED_APPS 90 | + [ 91 | "django.contrib.auth", 92 | "django.contrib.contenttypes", 93 | ] 94 | ) 95 | async def test_get_project_info_tool_with_auth(): 96 | async with Client(mcp.server) as client: 97 | result = await client.call_tool("project_get_project_info", {}) 98 | 99 | assert result.data is not None 100 | assert result.data.django.auth_user_model is not None 101 | 102 | 103 | async def test_list_routes_tool_returns_routes(): 104 | async with Client(mcp.server) as client: 105 | result = await client.call_tool("project_list_routes", {}) 106 | 107 | assert isinstance(result.data, list) 108 | assert len(result.data) > 0 109 | 110 | 111 | async def test_list_routes_tool_with_filters(): 112 | async with Client(mcp.server) as client: 113 | all_routes = await client.call_tool("project_list_routes", {}) 114 | 115 | get_routes = await client.call_tool("project_list_routes", {"method": "GET"}) 116 | assert len(get_routes.data) > 0 117 | assert len(get_routes.data) <= len(all_routes.data) 118 | 119 | if all_routes.data: 120 | pattern_routes = await client.call_tool( 121 | "project_list_routes", {"pattern": all_routes.data[0]["pattern"][:3]} 122 | ) 123 | assert isinstance(pattern_routes.data, list) 124 | 125 | 126 | async def test_list_apps_tool(): 127 | async with Client(mcp.server) as client: 128 | result = await client.call_tool("project_list_apps", {}) 129 | 130 | assert isinstance(result.data, list) 131 | assert len(result.data) > 0 132 | # Should have at least the 'tests' app 133 | app_labels = [app["label"] for app in result.data] 134 | assert "tests" in app_labels 135 | 136 | 137 | async def test_list_models_tool(): 138 | async with Client(mcp.server) as client: 139 | result = await client.call_tool("project_list_models", {}) 140 | 141 | assert isinstance(result.data, list) 142 | assert len(result.data) > 0 143 | # Should have at least AModel from tests 144 | model_names = [model["model_class"] for model in result.data] 145 | assert "AModel" in model_names 146 | 147 | 148 | async def test_list_models_with_scope_project(): 149 | async with Client(mcp.server) as client: 150 | result = await client.call_tool("project_list_models", {"scope": "project"}) 151 | 152 | assert isinstance(result.data, list) 153 | assert len(result.data) > 0 154 | 155 | # Should have project models, e.g. our test models 156 | model_names = [model["model_class"] for model in result.data] 157 | assert "AModel" in model_names 158 | 159 | # Should NOT have Django models (they're in site-packages) 160 | # This test might be limited if Django apps aren't installed 161 | assert "User" not in model_names or "auth" not in model_names 162 | 163 | 164 | @override_settings( 165 | INSTALLED_APPS=settings.INSTALLED_APPS 166 | + [ 167 | "django.contrib.auth", 168 | "django.contrib.contenttypes", 169 | ] 170 | ) 171 | async def test_list_models_with_scope_all(): 172 | """Test that scope='all' returns all models including Django contrib.""" 173 | async with Client(mcp.server) as client: 174 | result = await client.call_tool("project_list_models", {"scope": "all"}) 175 | 176 | assert isinstance(result.data, list) 177 | assert len(result.data) > 0 178 | 179 | model_names = [model["model_class"] for model in result.data] 180 | 181 | assert "AModel" in model_names 182 | assert "User" in model_names 183 | assert "ContentType" in model_names 184 | 185 | 186 | @override_settings( 187 | INSTALLED_APPS=settings.INSTALLED_APPS 188 | + [ 189 | "django.contrib.auth", 190 | "django.contrib.contenttypes", 191 | ] 192 | ) 193 | async def test_list_models_with_include(): 194 | """Test that include parameter filters to specific apps.""" 195 | async with Client(mcp.server) as client: 196 | result = await client.call_tool( 197 | "project_list_models", {"include": ["auth", "tests"]} 198 | ) 199 | 200 | assert isinstance(result.data, list) 201 | assert len(result.data) > 0 202 | 203 | model_names = [model["model_class"] for model in result.data] 204 | app_labels = [model["import_path"].split(".")[0] for model in result.data] 205 | 206 | # should include auth and tests models 207 | assert "AModel" in model_names 208 | assert "User" in model_names 209 | 210 | # should not have anything else, e.g. from the contenttypes app 211 | assert "ContentType" not in model_names 212 | 213 | for label in app_labels: 214 | assert label in ["tests", "django"], f"Unexpected app: {label}" 215 | 216 | 217 | @override_settings( 218 | INSTALLED_APPS=settings.INSTALLED_APPS 219 | + [ 220 | "django.contrib.auth", 221 | "django.contrib.contenttypes", 222 | ] 223 | ) 224 | async def test_list_models_include_overrides_scope(): 225 | async with Client(mcp.server) as client: 226 | # Even with scope='project', include should override 227 | result = await client.call_tool( 228 | "project_list_models", {"include": ["auth"], "scope": "project"} 229 | ) 230 | 231 | assert isinstance(result.data, list) 232 | assert len(result.data) > 0 233 | 234 | model_names = [model["model_class"] for model in result.data] 235 | 236 | # Should ONLY have auth models (include overrides scope) 237 | assert "User" in model_names 238 | assert "Group" in model_names 239 | 240 | # Should NOT have project models despite scope='project' 241 | assert "AModel" not in model_names 242 | 243 | 244 | async def test_get_setting_tool(): 245 | async with Client(mcp.server) as client: 246 | result = await client.call_tool("project_get_setting", {"key": "DEBUG"}) 247 | 248 | assert result.data is not None 249 | assert result.data.key == "DEBUG" 250 | assert result.data.value_type == "bool" 251 | assert isinstance(result.data.value, bool) 252 | 253 | 254 | async def test_get_app_resource(): 255 | async with Client(mcp.server) as client: 256 | result = await client.read_resource("django://project/app/tests") 257 | 258 | assert result is not None 259 | assert len(result) > 0 260 | 261 | 262 | async def test_get_app_models_resource(): 263 | async with Client(mcp.server) as client: 264 | result = await client.read_resource("django://project/app/tests/models") 265 | 266 | assert result is not None 267 | assert len(result) > 0 268 | 269 | 270 | async def test_get_model_resource(): 271 | async with Client(mcp.server) as client: 272 | result = await client.read_resource("django://project/model/tests/AModel") 273 | 274 | assert result is not None 275 | assert len(result) > 0 276 | 277 | 278 | async def test_get_route_by_pattern_resource(): 279 | async with Client(mcp.server) as client: 280 | result = await client.read_resource("django://project/route/home") 281 | 282 | assert result is not None 283 | assert len(result) > 0 284 | 285 | 286 | async def test_get_setting_resource(): 287 | async with Client(mcp.server) as client: 288 | result = await client.read_resource("django://project/setting/DEBUG") 289 | 290 | assert result is not None 291 | assert len(result) > 0 292 | -------------------------------------------------------------------------------- /tests/test_management_command.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import pytest 4 | import pytest_asyncio 5 | from fastmcp import Client 6 | 7 | from mcp_django.mgmt import core 8 | from mcp_django.mgmt.core import CommandErrorResult 9 | from mcp_django.server import mcp 10 | 11 | pytestmark = [pytest.mark.asyncio, pytest.mark.django_db] 12 | 13 | 14 | @pytest_asyncio.fixture(autouse=True) 15 | async def initialize_server(): 16 | """Initialize the MCP server before tests.""" 17 | await mcp.initialize() 18 | 19 | 20 | async def test_management_command_check(): 21 | """Test running the 'check' command (safe, read-only).""" 22 | async with Client(mcp.server) as client: 23 | result = await client.call_tool( 24 | "management_execute_command", 25 | { 26 | "command": "check", 27 | }, 28 | ) 29 | 30 | assert result.data is not None 31 | assert result.data.status == "success" 32 | assert result.data.command == "check" 33 | assert result.data.args == [] 34 | assert result.data.exception is None 35 | # The check command should produce some output to stderr 36 | assert ( 37 | "System check identified" in result.data.stderr or result.data.stderr == "" 38 | ) 39 | 40 | 41 | async def test_management_command_with_args(): 42 | """Test running a command with positional arguments.""" 43 | async with Client(mcp.server) as client: 44 | result = await client.call_tool( 45 | "management_execute_command", 46 | { 47 | "command": "showmigrations", 48 | "args": ["tests"], 49 | }, 50 | ) 51 | 52 | assert result.data is not None 53 | assert result.data.status == "success" 54 | assert result.data.command == "showmigrations" 55 | assert result.data.args == ["tests"] 56 | assert result.data.exception is None 57 | 58 | 59 | async def test_management_command_with_options(): 60 | """Test running a command with keyword options.""" 61 | async with Client(mcp.server) as client: 62 | result = await client.call_tool( 63 | "management_execute_command", 64 | { 65 | "command": "check", 66 | "options": {"verbosity": 2}, 67 | }, 68 | ) 69 | 70 | assert result.data is not None 71 | assert result.data.status == "success" 72 | assert result.data.command == "check" 73 | assert result.data.exception is None 74 | 75 | 76 | async def test_management_command_with_args_and_options(): 77 | """Test running a command with both args and options.""" 78 | async with Client(mcp.server) as client: 79 | result = await client.call_tool( 80 | "management_execute_command", 81 | { 82 | "command": "showmigrations", 83 | "args": ["tests"], 84 | "options": {"verbosity": 0}, 85 | }, 86 | ) 87 | 88 | assert result.data is not None 89 | assert result.data.status == "success" 90 | assert result.data.command == "showmigrations" 91 | assert result.data.args == ["tests"] 92 | assert result.data.exception is None 93 | 94 | 95 | async def test_management_command_invalid_command(): 96 | """Test running an invalid/non-existent command.""" 97 | async with Client(mcp.server) as client: 98 | result = await client.call_tool( 99 | "management_execute_command", 100 | { 101 | "command": "this_command_does_not_exist", 102 | }, 103 | ) 104 | 105 | assert result.data is not None 106 | assert result.data.status == "error" 107 | assert result.data.command == "this_command_does_not_exist" 108 | assert result.data.exception is not None 109 | assert result.data.exception.type in [ 110 | "CommandError", 111 | "ManagementUtilityError", 112 | ] 113 | assert "Unknown command" in result.data.exception.message 114 | 115 | 116 | async def test_management_command_makemigrations_dry_run(): 117 | """Test running makemigrations with --dry-run (safe, read-only).""" 118 | async with Client(mcp.server) as client: 119 | result = await client.call_tool( 120 | "management_execute_command", 121 | { 122 | "command": "makemigrations", 123 | "options": {"dry_run": True, "verbosity": 0}, 124 | }, 125 | ) 126 | 127 | assert result.data is not None 128 | assert result.data.status == "success" 129 | assert result.data.command == "makemigrations" 130 | assert result.data.args == [] 131 | assert result.data.exception is None 132 | 133 | 134 | async def test_management_command_diffsettings(): 135 | """Test running diffsettings command (read-only introspection).""" 136 | async with Client(mcp.server) as client: 137 | result = await client.call_tool( 138 | "management_execute_command", 139 | { 140 | "command": "diffsettings", 141 | "options": {"all": True}, 142 | }, 143 | ) 144 | 145 | assert result.data is not None 146 | assert result.data.status == "success" 147 | assert result.data.command == "diffsettings" 148 | # Should output settings 149 | assert len(result.data.stdout) > 0 150 | 151 | 152 | async def test_management_command_stdout_capture(): 153 | """Test that stdout is properly captured from commands.""" 154 | async with Client(mcp.server) as client: 155 | result = await client.call_tool( 156 | "management_execute_command", 157 | { 158 | "command": "check", 159 | "options": {"verbosity": 2}, 160 | }, 161 | ) 162 | 163 | assert result.data is not None 164 | assert result.data.status == "success" 165 | # With higher verbosity, check should produce output 166 | assert isinstance(result.data.stdout, str) 167 | assert isinstance(result.data.stderr, str) 168 | 169 | 170 | async def test_management_command_list_in_main_server(): 171 | """Test that management_command tool is listed in main server tools.""" 172 | async with Client(mcp.server) as client: 173 | tools = await client.list_tools() 174 | tool_names = [tool.name for tool in tools] 175 | 176 | assert "management_execute_command" in tool_names 177 | 178 | # Find the tool and check its metadata 179 | mgmt_tool = next( 180 | tool for tool in tools if tool.name == "management_execute_command" 181 | ) 182 | assert mgmt_tool.description is not None 183 | assert "management command" in mgmt_tool.description.lower() 184 | 185 | 186 | async def test_list_management_commands(): 187 | """Test listing all available management commands.""" 188 | async with Client(mcp.server) as client: 189 | result = await client.call_tool("management_list_commands", {}) 190 | 191 | assert result.data is not None 192 | assert isinstance(result.data, list) 193 | assert len(result.data) > 0 194 | 195 | # Check that we have some standard Django commands 196 | command_names = [cmd["name"] for cmd in result.data] 197 | assert "check" in command_names 198 | assert "migrate" in command_names 199 | assert "showmigrations" in command_names 200 | 201 | # Verify structure of command info 202 | first_cmd = result.data[0] 203 | assert "name" in first_cmd 204 | assert "app_name" in first_cmd 205 | assert isinstance(first_cmd["name"], str) 206 | assert isinstance(first_cmd["app_name"], str) 207 | 208 | 209 | async def test_list_management_commands_includes_custom_commands(): 210 | """Test that custom management commands are included in the list.""" 211 | async with Client(mcp.server) as client: 212 | result = await client.call_tool("management_list_commands", {}) 213 | 214 | assert result.data is not None 215 | command_names = [cmd["name"] for cmd in result.data] 216 | 217 | # The mcp command from this project should be in the list 218 | assert "mcp" in command_names 219 | 220 | 221 | async def test_list_management_commands_sorted(): 222 | """Test that management commands are sorted alphabetically.""" 223 | async with Client(mcp.server) as client: 224 | result = await client.call_tool("management_list_commands", {}) 225 | 226 | assert result.data is not None 227 | command_names = [cmd["name"] for cmd in result.data] 228 | 229 | # Verify the list is sorted 230 | assert command_names == sorted(command_names) 231 | 232 | 233 | async def test_management_command_unexpected_exception(monkeypatch): 234 | """Test handling of unexpected exceptions in execute_command tool.""" 235 | 236 | # Mock the executor to raise an unexpected exception 237 | async def mock_execute(*args, **kwargs): 238 | raise RuntimeError("Unexpected error in executor") 239 | 240 | monkeypatch.setattr(core.management_command_executor, "execute", mock_execute) 241 | 242 | async with Client(mcp.server) as client: 243 | # The tool should re-raise unexpected exceptions 244 | with pytest.raises(Exception): # Will be wrapped by FastMCP 245 | await client.call_tool( 246 | "management_execute_command", 247 | { 248 | "command": "check", 249 | }, 250 | ) 251 | 252 | 253 | async def test_command_error_result_with_stdout_stderr(): 254 | """Test CommandErrorResult __post_init__ with stdout and stderr for coverage.""" 255 | # Create a CommandErrorResult with both stdout and stderr 256 | # This triggers lines 58 and 60 in mgmt/core.py 257 | result = CommandErrorResult( 258 | command="test_command", 259 | args=("arg1",), 260 | options={"opt": "value"}, 261 | exception=Exception("Test exception"), 262 | stdout="Some stdout output", 263 | stderr="Some stderr output", 264 | ) 265 | 266 | # Verify the result was created correctly 267 | assert result.command == "test_command" 268 | assert result.stdout == "Some stdout output" 269 | assert result.stderr == "Some stderr output" 270 | assert str(result.exception) == "Test exception" 271 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # mcp-django 2 | 3 | 17 | [![PyPI - mcp-django](https://img.shields.io/pypi/v/mcp-django?label=mcp-django)](https://pypi.org/project/mcp-django/) 18 | ![PyPI - Python Version](https://img.shields.io/pypi/pyversions/mcp-django) 19 | ![Django Version](https://img.shields.io/badge/django-4.2%20%7C%205.1%20%7C%205.2%20%7C%206.0%20%7C%20main-%2344B78B?labelColor=%23092E20) 20 | 21 | 22 | A Model Context Protocol (MCP) server providing Django project exploration resources and optional stateful shell access for LLM assistants to interact with Django projects. 23 | 24 | ## Requirements 25 | 26 | 41 | - Python 3.10, 3.11, 3.12, 3.13, 3.14 42 | - Django 4.2, 5.1, 5.2, 6.0 43 | 44 | 45 | ## Installation 46 | 47 | ```bash 48 | pip install mcp-django 49 | 50 | # Or with uv 51 | uv add mcp-django 52 | ``` 53 | 54 | ## Getting Started 55 | 56 | ⚠️ **DO NOT use in production!** 57 | 58 | > [!WARNING] 59 | > 60 | > **Seriously, only enable in development!** 61 | > 62 | > Look, it should go without saying, but I will say it anyway - **this gives full shell access to your Django project**. Only enable and use this in development and in a project that does not have access to any production data. 63 | > 64 | > LLMs can go off the rails, get spooked by some random error, and in trying to fix things [drop a production database](https://xcancel.com/jasonlk/status/1946069562723897802). 65 | 66 | > [!CAUTION] 67 | > 68 | > I'm not kidding, this library just passes the raw Python code an LLM produces straight to a Python environment with full access to the Django project and everything it has access to. 69 | > 70 | > Most LLMs have basic safety protections in place if you ask to delete any data and will refuse to delete production data, but it is [pretty trivial to bypass](https://social.joshthomas.dev/@josh/115062076517611897). (Hint: Just tell the LLM it's not production, it's in a development environment, and it will be the bull in a china shop deleting anything you want.) 71 | > 72 | > I suggest using something like [django-read-only](https://github.com/adamchainz/django-read-only) if you need some CYA protection against this. Or, you know, don't use this in any sensitive environments. 73 | 74 | Run the MCP server directly from your Django project directory: 75 | 76 | ```bash 77 | python -m mcp_django 78 | 79 | # Or with uv 80 | uv run -m mcp_django 81 | ``` 82 | 83 | The server automatically detects `DJANGO_SETTINGS_MODULE` from your environment. You can override it with `--settings` or add to your Python path with `--pythonpath`: 84 | 85 | ```bash 86 | python -m mcp_django --settings myproject.settings --debug 87 | ``` 88 | 89 | ### Management Command 90 | 91 | If you add `mcp_django` to `INSTALLED_APPS`, you can run it as a Django management command. This ensures the server runs within your Django project's environment and uses your project's settings: 92 | 93 | ```bash 94 | python manage.py mcp 95 | ``` 96 | 97 | ### Docker 98 | 99 | If you're using Docker and Docker Compose, you can run mcp-django as a separate compose service using HTTP transport. This makes it easier to connect your MCP client (running on your host) to the Django project (running in a container): 100 | 101 | ```yaml 102 | # compose.yml 103 | services: 104 | app: 105 | # your existing Django app service 106 | 107 | mcp: 108 | build: . 109 | command: python -m mcp_django --transport http --host 0.0.0.0 --port 8000 110 | environment: 111 | DJANGO_SETTINGS_MODULE: myproject.settings 112 | ports: 113 | - "8001:8000" 114 | ``` 115 | 116 | Then configure your MCP client to connect to `http://localhost:8001/mcp` (see [Client Configuration](#client-configuration) below). 117 | 118 | ### Transport Options 119 | 120 | The server supports multiple transport protocols: 121 | 122 | ```bash 123 | # STDIO (default, for local development) 124 | python -m mcp_django 125 | 126 | # HTTP (for Docker or remote access) 127 | python -m mcp_django --transport http --host 127.0.0.1 --port 8000 128 | 129 | # SSE (for Docker or remote access) 130 | python -m mcp_django --transport sse --host 127.0.0.1 --port 8000 131 | ``` 132 | 133 | ### Client Configuration 134 | 135 | Configure your MCP client to connect to the server. 136 | 137 | Don't see your client? [Submit a PR](CONTRIBUTING.md) with setup instructions. 138 | 139 | #### Opencode 140 | 141 | For **local development**, use `type: local` with the command: 142 | 143 | ```json 144 | { 145 | "$schema": "https://opencode.ai/config.json", 146 | "mcp": { 147 | "django": { 148 | "type": "local", 149 | "command": ["python", "-m", "mcp_django"], 150 | "enabled": true, 151 | "environment": { 152 | "DJANGO_SETTINGS_MODULE": "myproject.settings" 153 | } 154 | } 155 | } 156 | } 157 | ``` 158 | 159 | For **Docker development**, use `type: remote` with the URL: 160 | 161 | ```json 162 | { 163 | "$schema": "https://opencode.ai/config.json", 164 | "mcp": { 165 | "django": { 166 | "type": "remote", 167 | "url": "http://localhost:8001/mcp", 168 | "enabled": true 169 | } 170 | } 171 | } 172 | ``` 173 | 174 | #### Claude Code 175 | 176 | For **local development**, use the command configuration: 177 | 178 | ```json 179 | { 180 | "mcpServers": { 181 | "django": { 182 | "command": "python", 183 | "args": ["-m", "mcp_django"], 184 | "cwd": "/path/to/your/django/project", 185 | "env": { 186 | "DJANGO_SETTINGS_MODULE": "myproject.settings" 187 | } 188 | } 189 | } 190 | } 191 | ``` 192 | 193 | For **Docker development** with HTTP/SSE transport, configuration varies by Claude Code version - consult the MCP client documentation for remote server setup. 194 | 195 | ## Features 196 | 197 | mcp-django provides an MCP server with Django project exploration resources and tools for LLM assistants. 198 | 199 | It wouldn't be an MCP server README without a gratuitous list of features punctuated by emojis, so: 200 | 201 | - 🔍 **Project exploration** - MCP resources for discovering apps, models, and configuration 202 | - 📦 **Package discovery** - Search and browse Django Packages for third-party packages 203 | - 🚀 **Zero configuration** - No schemas, no settings, just Django 204 | - 🐚 **Stateless shell** - `shell` executes Python code with fresh state each call 205 | - 🔄 **Always fresh** - Code changes take effect immediately, no stale modules 206 | - 📤 **Export sessions** - Save debugging sessions as Python scripts 207 | - 🧹 **Clear history** - Start fresh when exploration gets messy 208 | - 🤖 **LLM-friendly** - Designed for LLM assistants that already know Python 209 | - 🌐 **Multiple transports** - STDIO, HTTP, SSE support 210 | 211 | Inspired by Armin Ronacher's [Your MCP Doesn't Need 30 Tools: It Needs Code](https://lucumr.pocoo.org/2025/8/18/code-mcps/). 212 | 213 | ### Resources 214 | 215 | Read-only resources for project exploration without executing code (note that resource support varies across MCP clients): 216 | 217 | #### Project 218 | 219 | | Resource | Description | 220 | |----------|-------------| 221 | | `django://app/{app_label}` | Details for a specific Django app | 222 | | `django://app/{app_label}/models` | All models in a specific app | 223 | | `django://apps` | All installed Django applications with their models | 224 | | `django://model/{app_label}/{model_name}` | Detailed information about a specific model | 225 | | `django://models` | Project models with import paths and field types (first-party only) | 226 | | `django://route/{pattern*}` | Routes matching a specific URL pattern | 227 | | `django://setting/{key}` | Get a specific Django setting value | 228 | 229 | #### djangopackages.org 230 | 231 | | Resource | Description | 232 | |----------|-------------| 233 | | `django://package/{slug}` | Detailed information about a specific package | 234 | | `django://grid/{slug}` | Comparison grid with packages (e.g., "rest-frameworks") | 235 | 236 | ### Tools 237 | 238 | #### Project 239 | 240 | | Tool | Description | 241 | |------|-------------| 242 | | `get_project_info` | Get comprehensive project information including Python environment and Django configuration | 243 | | `get_setting` | Get a Django setting value by key | 244 | | `list_apps` | List all installed Django applications with their models | 245 | | `list_models` | Get detailed information about Django models with optional filtering by app or scope | 246 | | `list_routes` | Introspect Django URL routes with filtering support for HTTP method, route name, or URL pattern | 247 | 248 | #### Management 249 | 250 | | Tool | Description | 251 | |------|-------------| 252 | | `execute_command` | Execute Django management commands with arguments and options | 253 | | `list_commands` | List all available Django management commands with their source apps | 254 | 255 | #### Shell 256 | 257 | | Tool | Description | 258 | |------|-------------| 259 | | `execute` | Execute Python code in a stateless Django shell | 260 | | `export_history` | Export session history as a Python script | 261 | | `clear_history` | Clear the session history for a fresh start | 262 | 263 | #### djangopackages.org 264 | 265 | | Tool | Description | 266 | |------|-------------| 267 | | `get_grid` | Get a specific comparison grid with all its packages | 268 | | `get_package` | Get detailed information about a specific Django package | 269 | | `search` | Search djangopackages.org for third-party packages | 270 | 271 | ## Development 272 | 273 | For detailed instructions on setting up a development environment and contributing to this project, see [CONTRIBUTING.md](CONTRIBUTING.md). 274 | 275 | For release procedures, see [RELEASING.md](RELEASING.md). 276 | 277 | ## License 278 | 279 | mcp-django is licensed under the MIT license. See the [`LICENSE`](LICENSE) file for more information. 280 | -------------------------------------------------------------------------------- /src/mcp_django/project/server.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import logging 4 | from typing import Annotated 5 | from typing import Literal 6 | 7 | from django.apps import apps 8 | from django.conf import settings 9 | from fastmcp import Context 10 | from fastmcp import FastMCP 11 | from mcp.types import ToolAnnotations 12 | 13 | from .resources import AppResource 14 | from .resources import ModelResource 15 | from .resources import ProjectResource 16 | from .resources import SettingResource 17 | from .resources import filter_models 18 | from .routing import RouteSchema 19 | from .routing import ViewMethod 20 | from .routing import filter_routes 21 | from .routing import get_all_routes 22 | 23 | logger = logging.getLogger(__name__) 24 | 25 | mcp = FastMCP( 26 | name="Project", 27 | instructions="Inspect Django project structure, configuration, and URL routing. Access project metadata, installed apps, models, settings, and route definitions for understanding your Django application's architecture.", 28 | ) 29 | 30 | PROJECT_TOOLSET = "project" 31 | 32 | 33 | @mcp.tool( 34 | name="get_project_info", 35 | annotations=ToolAnnotations( 36 | title="Django Project Information", 37 | readOnlyHint=True, 38 | idempotentHint=True, 39 | ), 40 | tags={PROJECT_TOOLSET}, 41 | ) 42 | def get_project_info() -> ProjectResource: 43 | """Get comprehensive project information including Python environment and Django configuration. 44 | 45 | Use this to understand the project's runtime environment, installed apps, and database 46 | configuration. 47 | """ 48 | return ProjectResource.from_env() 49 | 50 | 51 | @mcp.resource( 52 | "django://app/{app_label}", 53 | name="Django App Details", 54 | annotations={"readOnlyHint": True, "idempotentHint": True}, 55 | tags={PROJECT_TOOLSET}, 56 | ) 57 | def get_app( 58 | app_label: Annotated[ 59 | str, "Django app label (e.g., 'auth', 'contenttypes', 'myapp')" 60 | ], 61 | ) -> AppResource: 62 | """Get details for a specific Django app.""" 63 | return AppResource.from_app(apps.get_app_config(app_label)) 64 | 65 | 66 | @mcp.resource( 67 | "django://app/{app_label}/models", 68 | name="Django App Models", 69 | annotations={"readOnlyHint": True, "idempotentHint": True}, 70 | tags={PROJECT_TOOLSET}, 71 | ) 72 | def get_app_models( 73 | app_label: Annotated[ 74 | str, "Django app label (e.g., 'auth', 'contenttypes', 'myapp')" 75 | ], 76 | ) -> list[ModelResource]: 77 | """Get all models for a specific Django app.""" 78 | app_config = apps.get_app_config(app_label) 79 | return [ 80 | ModelResource.from_model(model) 81 | for model in app_config.get_models() 82 | if not model._meta.auto_created 83 | ] 84 | 85 | 86 | def list_apps() -> list[AppResource]: 87 | """Get a list of all installed Django applications with their models. 88 | 89 | Use this to explore the project structure and available models without executing code. 90 | """ 91 | return [AppResource.from_app(app) for app in apps.get_app_configs()] 92 | 93 | 94 | mcp.resource( 95 | "django://apps", 96 | name="Installed Django Apps", 97 | annotations={"readOnlyHint": True, "idempotentHint": True}, 98 | tags={PROJECT_TOOLSET}, 99 | )(list_apps) 100 | 101 | mcp.tool( 102 | name="list_apps", 103 | annotations=ToolAnnotations( 104 | title="List Django Apps", 105 | readOnlyHint=True, 106 | idempotentHint=True, 107 | ), 108 | tags={PROJECT_TOOLSET}, 109 | )(list_apps) 110 | 111 | 112 | @mcp.resource( 113 | "django://model/{app_label}/{model_name}", 114 | name="Model Details", 115 | annotations={"readOnlyHint": True, "idempotentHint": True}, 116 | tags={PROJECT_TOOLSET}, 117 | ) 118 | def get_model( 119 | app_label: Annotated[ 120 | str, "Django app label (e.g., 'auth', 'contenttypes', 'myapp')" 121 | ], 122 | model_name: Annotated[str, "Model name (e.g., 'User', 'Group', 'Permission')"], 123 | ) -> ModelResource: 124 | """Get details for a specific Django model.""" 125 | model = apps.get_model(app_label, model_name) 126 | return ModelResource.from_model(model) 127 | 128 | 129 | def list_models( 130 | ctx: Context, 131 | include: Annotated[ 132 | list[str] | None, 133 | "Specific app labels to include (e.g., ['auth', 'myapp']). When provided, only models from these exact apps are returned (overrides scope).", 134 | ] = None, 135 | scope: Annotated[ 136 | Literal["project", "all"], 137 | "Filter scope when include is not specified: 'project' (default) for your project's models only, 'all' for everything including Django and third-party packages.", 138 | ] = "project", 139 | ) -> list[ModelResource]: 140 | """Get detailed information about Django models with optional filtering. 141 | 142 | By default returns only models from your project directory. Use include for 143 | specific apps or scope='all' for everything including Django and third-party packages. 144 | When include is provided, it overrides the scope parameter. 145 | 146 | Use this for quick model introspection without shell access. 147 | """ 148 | logger.info( 149 | "list_models called - request_id: %s, client_id: %s, include: %s, scope: %s", 150 | ctx.request_id, 151 | ctx.client_id or "unknown", 152 | include, 153 | scope, 154 | ) 155 | 156 | all_models = list(apps.get_models()) 157 | total_count = len(all_models) 158 | 159 | filtered_models = filter_models(all_models, include=include, scope=scope) 160 | result = [ModelResource.from_model(model) for model in filtered_models] 161 | 162 | logger.debug( 163 | "list_models completed - request_id: %s, total_models: %d, returned_models: %d", 164 | ctx.request_id, 165 | total_count, 166 | len(result), 167 | ) 168 | 169 | return result 170 | 171 | 172 | # TODO: Uncomment once upstream bug is fixed 173 | # Resource templates with only optional query parameters don't work when called without 174 | # query params. 175 | # Ref: https://github.com/jlowin/fastmcp/pull/2323 176 | 177 | # mcp.resource( 178 | # "django://models{?include,scope}", 179 | # name="Django Models", 180 | # annotations={"readOnlyHint": True, "idempotentHint": True}, 181 | # tags={PROJECT_TOOLSET}, 182 | # )(list_models) 183 | 184 | mcp.tool( 185 | name="list_models", 186 | annotations=ToolAnnotations( 187 | title="List Django Models", 188 | readOnlyHint=True, 189 | idempotentHint=True, 190 | ), 191 | tags={PROJECT_TOOLSET}, 192 | )(list_models) 193 | 194 | 195 | @mcp.resource( 196 | "django://models", 197 | name="Django Models", 198 | annotations={"readOnlyHint": True, "idempotentHint": True}, 199 | tags={PROJECT_TOOLSET}, 200 | ) 201 | def list_models_resource() -> list[ModelResource]: 202 | """Resource endpoint without query parameters - uses default filtering (project scope).""" 203 | 204 | # TODO: Replace with query-param template once upstream bug is fixed. 205 | # This static resource provides access with default settings until the bug is fixed. 206 | # Use the list_models() tool for filtering options. 207 | # Ref: https://github.com/jlowin/fastmcp/pull/2323 208 | 209 | all_models = list(apps.get_models()) 210 | filtered_models = filter_models(all_models, include=None, scope="project") 211 | return [ModelResource.from_model(model) for model in filtered_models] 212 | 213 | 214 | @mcp.tool( 215 | name="list_routes", 216 | annotations=ToolAnnotations( 217 | title="List Django Routes", readOnlyHint=True, idempotentHint=True 218 | ), 219 | tags={PROJECT_TOOLSET}, 220 | ) 221 | async def list_routes( 222 | ctx: Context, 223 | method: Annotated[ 224 | ViewMethod | None, 225 | "Filter routes by HTTP method (e.g., 'GET', 'POST'). Uses contains matching - returns routes that support this method.", 226 | ] = None, 227 | name: Annotated[ 228 | str | None, 229 | "Filter routes by name. Uses contains matching - returns routes whose name contains this string.", 230 | ] = None, 231 | pattern: Annotated[ 232 | str | None, 233 | "Filter routes by URL pattern. Uses contains matching - returns routes whose pattern contains this string.", 234 | ] = None, 235 | ) -> list[RouteSchema]: 236 | """List all Django URL routes with optional filtering. 237 | 238 | Returns comprehensive route information including URL patterns, view details, 239 | HTTP methods, namespaces, and URL parameters. All filters use contains matching 240 | and are AND'd together. 241 | """ 242 | logger.info( 243 | "list_routes called - request_id: %s, client_id: %s, method: %s, name: %s, pattern: %s", 244 | ctx.request_id, 245 | ctx.client_id or "unknown", 246 | method, 247 | name, 248 | pattern, 249 | ) 250 | 251 | routes = get_all_routes() 252 | total_count = len(routes) 253 | 254 | if any([method, name, pattern]): 255 | routes = filter_routes(routes, method=method, name=name, pattern=pattern) 256 | 257 | logger.debug( 258 | "list_routes completed - request_id: %s, total_routes: %d, returned_routes: %d", 259 | ctx.request_id, 260 | total_count, 261 | len(routes), 262 | ) 263 | 264 | return routes 265 | 266 | 267 | @mcp.resource( 268 | "django://route/{pattern*}", 269 | name="Route by Pattern", 270 | annotations={"readOnlyHint": True, "idempotentHint": True}, 271 | tags={PROJECT_TOOLSET}, 272 | ) 273 | async def get_route_by_pattern( 274 | pattern: Annotated[ 275 | str, "URL pattern to search for (e.g., 'admin', 'api', 'users')" 276 | ], 277 | ) -> list[RouteSchema]: 278 | """Get routes matching a specific URL pattern.""" 279 | all_routes = get_all_routes() 280 | return filter_routes(all_routes, pattern=pattern) 281 | 282 | 283 | def get_setting( 284 | key: Annotated[ 285 | str, "Django setting key (e.g., 'DEBUG', 'DATABASES', 'INSTALLED_APPS')" 286 | ], 287 | ) -> SettingResource: 288 | """Get a Django setting by key. 289 | 290 | Returns the setting value along with type information. Raises AttributeError 291 | if the setting does not exist. 292 | """ 293 | value = getattr(settings, key) # Will raise AttributeError if missing 294 | return SettingResource(key=key, value=value, value_type=type(value).__name__) 295 | 296 | 297 | mcp.resource( 298 | "django://setting/{key}", 299 | name="Django Setting", 300 | annotations={"readOnlyHint": True, "idempotentHint": True}, 301 | tags={PROJECT_TOOLSET}, 302 | )(get_setting) 303 | 304 | mcp.tool( 305 | name="get_setting", 306 | annotations=ToolAnnotations( 307 | title="Get Django Setting", 308 | readOnlyHint=True, 309 | idempotentHint=True, 310 | ), 311 | tags={PROJECT_TOOLSET}, 312 | )(get_setting) 313 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | All notable changes to this project will be documented in this file. 4 | 5 | The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), 6 | and this project attempts to adhere to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). 7 | 8 | 26 | 27 | ## [Unreleased] 28 | 29 | ## [0.13.0] 30 | 31 | ### Added 32 | 33 | - Added Management toolset with `command` and `list_commands` tools for executing and discovering Django management commands 34 | 35 | ## [0.12.0] 36 | 37 | ### Added 38 | 39 | - Added `export_history` tool for exporting shell session history as a Python script with deduplicated imports 40 | - Added `clear_history` tool for clearing shell session history 41 | - Added `include` and `scope` parameters to `list_models` tool for filtering Django models 42 | 43 | ### Changed 44 | 45 | - **BREAKING**: Shell execution is now stateless. Each tool call uses fresh globals and variables/imports no longer persist between calls 46 | - `django://project/models` resource now defaults to `scope="project"` (first-party models only) instead of returning all models 47 | 48 | ### Removed 49 | 50 | - **BREAKING**: Removed `reset` tool, no longer needed with stateless execution 51 | - **BREAKING**: Removed `imports` parameter from `execute` tool. Parameter was redundant as LLMs would include imports in code body regardless, and is no longer needed with stateless execution 52 | 53 | ### Fixed 54 | 55 | - Shell execution now reflects code changes immediately without requiring MCP server restart, as each execution uses fresh globals rather than persistent state 56 | 57 | ## [0.11.0] 58 | 59 | ### Added 60 | 61 | - Added project introspection toolset containing `project_get_project_info`, `project_list_apps`, `project_list_models`, `project_list_routes`, and `project_get_setting` tools 62 | - Added parameterized resources for targeted introspection: `django://app/{app_label}`, `django://app/{app_label}/models`, `django://model/{app_label}/{model_name}`, `django://route/{pattern*}`, and `django://setting/{key}` 63 | - Added Django Packages integration via mounted toolset: `djangopackages_search`, `djangopackages_get_package`, and `djangopackages_get_grid` tools with corresponding `django://package/{slug}` and `django://grid/{slug}` resources 64 | - Added dependency: `httpx` 65 | 66 | ### Changed 67 | 68 | - Refactored project resources into dedicated toolset with namespaced URIs (`django://project/apps`, `django://project/models`, etc.) 69 | - Refactored shell tools into mounted toolset with `shell_` prefix 70 | 71 | ### Removed 72 | 73 | - Deprecated mcp-django-shell package deleted from repository 74 | 75 | ## [0.10.0] 76 | 77 | **🚨 BREAKING RELEASE 🚨** (Again.. sorry!) 78 | 79 | After splitting into separate packages in v2025.8.1 for security isolation, we're consolidating back into a single package, for a few reasons: 80 | 81 | - It seemed like a good idea, but it's early and the extra complexity adds unnecessary friction 82 | - While production deployment would be nice eventually, the current focus is developer tooling and building a damn good MCP server for Django 83 | 84 | The GitHub releases were previously using calendar versioning (e.g., v2025.8.1) while individual packages used semantic versioning. With the consolidation to a single package, GitHub releases will now use the package version directly. The consolidated package will be v0.10.0, continuing from the highest version among the previous packages (mcp-django-shell was at 0.9.0). 85 | 86 | ### Added 87 | 88 | - Added support for Python 3.14 89 | - Added support for Django 6.0 90 | 91 | ### Changed 92 | 93 | - **BREAKING**: Consolidated mcp-django-shell functionality into main mcp-django package 94 | - Shell tools are now included by default (no longer optional via extras) 95 | - Tool names changed: `shell_django_shell` → `django_shell`, `shell_django_reset` → `django_shell_reset` 96 | 97 | ### Deprecated 98 | 99 | - mcp-django-shell package is deprecated, functionality moved to mcp-django 100 | 101 | ### Removed 102 | 103 | - Optional installation extras `[shell]` and `[all]` - shell is now always included 104 | 105 | ## [2025.8.1] 106 | 107 | - [mcp-django: 0.1.0] 108 | - [mcp-django-shell: 0.9.0] 109 | 110 | **🚨 BREAKING RELEASE 🚨** 111 | 112 | This release restructures the project from a single package to a workspace with multiple packages for better separation of concerns. 113 | 114 | The dev only shell functionality is now an optional extra that must be explicitly installed, while the read-only resources are available in the base package. 115 | 116 | This should allow for safer production deployments where shell access can be completely excluded, as well as allow for future expansion with additional tool packages that can be selectively installed based on environment needs. 117 | 118 | > **Note**: All releases prior to this one are for mcp-django-shell only. 119 | 120 | ### Changed 121 | 122 | - Migrated to workspace structure with multiple packages 123 | - Repository renamed from mcp-django-shell to mcp-django 124 | - **Internal**: Git tags now use package-prefixed format: `mcp-django-shell-vX.Y.Z` 125 | - **🚨 BREAKING CHANGE 🚨**: Main entry point changed from `python -m mcp_django_shell` to `python -m mcp_django` 126 | - Shell functionality now installed via extras: `pip install "mcp-django[shell]"` 127 | - **🚨 BREAKING CHANGE 🚨**: Management command moved from mcp-django-shell to mcp-django package and renamed from `mcp_shell` to `mcp` 128 | 129 | ### mcp-django (new) 130 | 131 | - Initial release as root package providing core MCP server functionality 132 | - Includes the read-only resources for project exploration, previously included as a part of mcp-django-shell 133 | 134 | ### mcp-django-shell 135 | 136 | - Moved to workspace package under `packages/` directory 137 | - Now distributed as optional extra of mcp-django 138 | - Now only includes the two shell tools 139 | 140 | ## [0.8.0] 141 | 142 | ### Added 143 | 144 | - Optional `imports` parameter to `django_shell` tool for providing import statements separately from main code execution. 145 | 146 | ## [0.7.0] 147 | 148 | ### Added 149 | 150 | - MCP resources for exploring the project environment, Django apps, and models without shell execution. 151 | 152 | ### Changed 153 | 154 | - Updated server instructions to guide LLMs to use resources for project orientation before shell operations. 155 | 156 | ### Removed 157 | 158 | - Removed redundant input field from `django_shell` tool response to reduce output verbosity. 159 | 160 | ## [0.6.0] 161 | 162 | ### Added 163 | 164 | - Pydantic is now an explicit dependency of the library. Previously, it was a transitive dependency via FastMCP. 165 | 166 | ### Changed 167 | 168 | - The `code` argument to the `django_shell` MCP tool now has a helpful description. 169 | - `django_shell` now returns structured output with execution status, error details, and filtered tracebacks instead of plain strings. 170 | - MCP tools now provide annotations hints via `fastmcp.ToolAnnotations`. 171 | 172 | ## [0.5.0] 173 | 174 | ### Added 175 | 176 | - Support for HTTP and SSE transport protocols via CLI arguments (`--transport`, `--host`, `--port`, `--path`). 177 | 178 | ## [0.4.0] 179 | 180 | ### Added 181 | 182 | - Standalone CLI via `python -m mcp_django_shell`. 183 | 184 | ### Deprecated 185 | 186 | - 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. 187 | 188 | ## [0.3.1] 189 | 190 | ### Removed 191 | 192 | - Removed unused `timeout` parameter from django_shell tool, to prevent potential robot confusion. 193 | 194 | ## [0.3.0] 195 | 196 | ### Changed 197 | 198 | - Removed custom formatting for QuerySets and iterables in shell output. QuerySets now display as `` and lists show their standard `repr()` instead of truncated displays with "... and X more items". This makes output consistent with standard Django/Python shell behavior and should hopefully not confuse the robots. 199 | 200 | ### Fixed 201 | 202 | - Django shell no longer shows `None` after print statements. Expression values are now only displayed when code doesn't print output, matching Python script execution behavior. 203 | 204 | ## [0.2.0] 205 | 206 | ### Added 207 | 208 | - Comprehensive logging for debugging MCP/LLM interactions 209 | - `--debug` flag for the `mcp_shell` management command to enable detailed logging 210 | - Request and client ID tracking in server logs 211 | 212 | ### Changed 213 | 214 | - **Internal**: Refactored results to use separate dataclasses and a tagged union 215 | - Changed to using `contextlib` and its stdout/stderr output redirection context managers when executing code 216 | - Swapped out `.split("\n")` usage for `.splitlines()` for better cross-platform line ending handling (h/t to [@jefftriplett] for the tip 🎉) 217 | 218 | ## [0.1.0] 219 | 220 | ### Added 221 | 222 | - Django management command `mcp_shell` for MCP server integration 223 | - `django_shell` MCP tool for executing Python code in persistent Django shell 224 | - `django_reset` MCP tool for clearing session state 225 | 226 | ### New Contributors 227 | 228 | - Josh Thomas (maintainer) 229 | 230 | [unreleased]: https://github.com/joshuadavidthomas/mcp-django/compare/v0.13.0...HEAD 231 | [0.1.0]: https://github.com/joshuadavidthomas/mcp-django/releases/tag/mcp-django-shell-v0.1.0 232 | [0.2.0]: https://github.com/joshuadavidthomas/mcp-django/releases/tag/mcp-django-shell-v0.2.0 233 | [0.3.0]: https://github.com/joshuadavidthomas/mcp-django/releases/tag/mcp-django-shell-v0.3.0 234 | [0.3.1]: https://github.com/joshuadavidthomas/mcp-django/releases/tag/mcp-django-shell-v0.3.1 235 | [0.4.0]: https://github.com/joshuadavidthomas/mcp-django/releases/tag/mcp-django-shell-v0.4.0 236 | [0.5.0]: https://github.com/joshuadavidthomas/mcp-django/releases/tag/mcp-django-shell-v0.5.0 237 | [0.6.0]: https://github.com/joshuadavidthomas/mcp-django/releases/tag/mcp-django-shell-v0.6.0 238 | [0.7.0]: https://github.com/joshuadavidthomas/mcp-django/releases/tag/mcp-django-shell-v0.7.0 239 | [0.8.0]: https://github.com/joshuadavidthomas/mcp-django/releases/tag/mcp-django-shell-v0.8.0 240 | [mcp-django-shell: 0.9.0]: https://github.com/joshuadavidthomas/mcp-django/releases/tag/mcp-django-shell-v0.9.0 241 | [mcp-django: 0.1.0]: https://github.com/joshuadavidthomas/mcp-django/releases/tag/mcp-django-v0.1.0 242 | [2025.8.1]: https://github.com/joshuadavidthomas/mcp-django/releases/tag/v2025.8.1 243 | [0.10.0]: https://github.com/joshuadavidthomas/mcp-django/releases/tag/v0.10.0 244 | [0.11.0]: https://github.com/joshuadavidthomas/mcp-django/releases/tag/v0.11.0 245 | [0.12.0]: https://github.com/joshuadavidthomas/mcp-django/releases/tag/v0.12.0 246 | [0.13.0]: https://github.com/joshuadavidthomas/mcp-django/releases/tag/v0.13.0 247 | -------------------------------------------------------------------------------- /src/mcp_django/project/routing.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import inspect 4 | import re 5 | from collections.abc import Iterable 6 | from enum import Enum 7 | from pathlib import Path 8 | from typing import Any 9 | from typing import Literal 10 | 11 | from django.urls import get_resolver 12 | from django.urls.resolvers import URLPattern 13 | from django.urls.resolvers import URLResolver 14 | from pydantic import BaseModel 15 | 16 | 17 | class ViewType(Enum): 18 | CLASS = "class" 19 | FUNCTION = "function" 20 | 21 | 22 | class ViewMethod(Enum): 23 | DELETE = "DELETE" 24 | GET = "GET" 25 | HEAD = "HEAD" 26 | PATCH = "PATCH" 27 | POST = "POST" 28 | PUT = "PUT" 29 | OPTIONS = "OPTIONS" 30 | TRACE = "TRACE" 31 | 32 | 33 | class FunctionViewSchema(BaseModel): 34 | """Schema for function-based view. 35 | 36 | Fields: 37 | name: Fully qualified view name (module.function) 38 | type: Always ViewType.FUNCTION 39 | source_path: Path to source file, or Path("unknown") 40 | methods: List of allowed HTTP methods. Empty list indicates methods 41 | could not be determined. Django's built-in method decorators 42 | (@require_GET, @require_POST, @require_http_methods) are 43 | automatically detected via closure inspection. 44 | """ 45 | 46 | name: str 47 | type: Literal[ViewType.FUNCTION] 48 | source_path: Path 49 | methods: list[ViewMethod] 50 | 51 | @classmethod 52 | def from_callback(cls, callback: Any): 53 | view_func = get_view_func(callback) 54 | name = get_view_name(view_func) 55 | source_path = get_source_file_path(view_func) 56 | methods = _extract_methods_from_closure(callback) 57 | 58 | if methods is None: 59 | methods = [] 60 | 61 | return FunctionViewSchema( 62 | name=name, 63 | type=ViewType.FUNCTION, 64 | source_path=source_path, 65 | methods=methods, 66 | ) 67 | 68 | 69 | class ClassViewSchema(BaseModel): 70 | """Schema for class-based view. 71 | 72 | Fields: 73 | name: Fully qualified view name (module.ClassName) 74 | type: Always ViewType.CLASS 75 | source_path: Path to source file, or Path("unknown") 76 | methods: List of HTTP methods actually implemented by the view. 77 | Determined by checking which method handlers (get, post, etc.) 78 | are defined on the class. 79 | class_bases: List of base class names, excluding 'object'. 80 | Example: ['ListView', 'LoginRequiredMixin'] 81 | """ 82 | 83 | name: str 84 | type: Literal[ViewType.CLASS] 85 | source_path: Path 86 | methods: list[ViewMethod] 87 | class_bases: list[str] 88 | 89 | @classmethod 90 | def from_callback(cls, callback: Any): 91 | view_func = get_view_func(callback) 92 | name = get_view_name(view_func) 93 | source_path = get_source_file_path(view_func) 94 | 95 | bases = [ 96 | base.__name__ for base in view_func.__bases__ if base.__name__ != "object" 97 | ] 98 | class_bases = bases if bases else [] 99 | 100 | implemented_methods = [ 101 | method for method in ViewMethod if hasattr(view_func, method.value.lower()) 102 | ] 103 | 104 | methods = implemented_methods if implemented_methods else [] 105 | 106 | return cls( 107 | name=name, 108 | type=ViewType.CLASS, 109 | source_path=source_path, 110 | class_bases=class_bases, 111 | methods=methods, 112 | ) 113 | 114 | 115 | ViewSchema = FunctionViewSchema | ClassViewSchema 116 | 117 | 118 | class RouteSchema(BaseModel): 119 | """Schema for a complete Django URL route. 120 | 121 | Fields: 122 | pattern: Full URL pattern string (e.g., "blog//") 123 | name: Route name for reverse URL lookup, or None if unnamed 124 | namespace: URL namespace (e.g., "admin"), or None if not namespaced 125 | parameters: List of URL parameter names extracted from pattern 126 | view: View handler (FunctionViewSchema or ClassViewSchema) 127 | """ 128 | 129 | pattern: str 130 | name: str | None 131 | namespace: str | None 132 | parameters: list[str] 133 | view: ViewSchema 134 | 135 | 136 | def get_source_file_path(obj: Any) -> Path: 137 | """Get the source file path for a function or class. 138 | 139 | Returns Path("unknown") if the source cannot be determined. 140 | """ 141 | try: 142 | return Path(inspect.getfile(obj)) 143 | except (TypeError, OSError): 144 | return Path("unknown") 145 | 146 | 147 | def extract_url_parameters(pattern: str) -> list[str]: 148 | """Extract parameter names from a URL pattern. 149 | 150 | Supports Django's standard path converters (int, str, slug, uuid, path) 151 | and any custom converter names using word characters (a-z, A-Z, 0-9, _). 152 | 153 | Args: 154 | pattern: Django URL pattern string 155 | 156 | Returns: 157 | List of parameter names extracted from the pattern 158 | 159 | Examples: 160 | >>> extract_url_parameters("blog//") 161 | ['pk'] 162 | >>> extract_url_parameters("blog//") 163 | ['pk'] 164 | >>> extract_url_parameters("api//posts//") 165 | ['id', 'post_id'] 166 | """ 167 | # Matches or and captures the parameter name 168 | param_regex = r"<(?:\w+:)?(\w+)>" 169 | return re.findall(param_regex, pattern) 170 | 171 | 172 | def _extract_methods_from_closure(view_func: Any) -> list[ViewMethod] | None: 173 | """Extract allowed HTTP methods from Django decorator closure. 174 | 175 | Django's method decorators (@require_GET, @require_http_methods, etc.) 176 | store the allowed methods in the function's closure. This extracts them. 177 | 178 | Returns: 179 | List of allowed ViewMethod enums if decorator found, None otherwise. 180 | """ 181 | if not hasattr(view_func, "__closure__") or not view_func.__closure__: 182 | return None 183 | 184 | for cell in view_func.__closure__: 185 | try: 186 | content = cell.cell_contents 187 | if isinstance(content, list) and content: 188 | if all(isinstance(m, str) for m in content): 189 | methods = [] 190 | for method_str in content: 191 | if method_str in ViewMethod.__members__: 192 | methods.append(ViewMethod[method_str]) 193 | if methods: 194 | return methods 195 | except ValueError: # pragma: no cover 196 | continue 197 | 198 | return None 199 | 200 | 201 | def get_view_func(callback: Any): 202 | """Extract the actual view function or class from a callback. 203 | 204 | Unwraps decorators and extracts view_class from .as_view() callbacks. 205 | 206 | Returns: 207 | The underlying function or class object 208 | """ 209 | view_func = callback 210 | 211 | if hasattr(view_func, "view_class"): 212 | view_func = view_func.view_class 213 | 214 | while hasattr(view_func, "__wrapped__"): 215 | view_func = view_func.__wrapped__ 216 | 217 | return view_func 218 | 219 | 220 | def get_view_name(view_func: Any): 221 | """Get the fully qualified name of a view function or class. 222 | 223 | Returns: 224 | Fully qualified name (module.name) 225 | """ 226 | module = inspect.getmodule(view_func) 227 | assert module is not None, f"Could not determine module for {view_func}" 228 | return f"{module.__name__}.{view_func.__name__}" 229 | 230 | 231 | def extract_routes( 232 | patterns: Iterable[URLPattern | URLResolver], 233 | prefix: str = "", 234 | namespace: str | None = None, 235 | ) -> list[RouteSchema]: 236 | """Recursively extract routes from URL patterns.""" 237 | routes = [] 238 | 239 | for pattern in patterns: 240 | if isinstance(pattern, URLResolver): 241 | current_namespace = pattern.namespace 242 | full_namespace: str | None 243 | if namespace and current_namespace: 244 | full_namespace = f"{namespace}:{current_namespace}" 245 | elif current_namespace: 246 | full_namespace = current_namespace 247 | else: 248 | full_namespace = None 249 | 250 | extracted_routes = extract_routes( 251 | pattern.url_patterns, 252 | prefix + str(pattern.pattern), 253 | full_namespace, 254 | ) 255 | routes.extend(extracted_routes) 256 | 257 | elif isinstance(pattern, URLPattern): 258 | full_pattern = prefix + str(pattern.pattern) 259 | parameters = extract_url_parameters(full_pattern) 260 | 261 | view_func = get_view_func(pattern.callback) 262 | if inspect.isclass(view_func): 263 | view_schema = ClassViewSchema.from_callback(pattern.callback) 264 | else: 265 | view_schema = FunctionViewSchema.from_callback(pattern.callback) 266 | 267 | route = RouteSchema( 268 | pattern=full_pattern, 269 | name=pattern.name, 270 | namespace=namespace, 271 | parameters=parameters, 272 | view=view_schema, 273 | ) 274 | routes.append(route) 275 | 276 | return routes 277 | 278 | 279 | def get_all_routes() -> list[RouteSchema]: 280 | """Get all Django URL routes by recursively walking URLconf. 281 | 282 | Traverses the entire URL resolver tree starting from ROOT_URLCONF, 283 | extracting route patterns, view metadata, namespaces, and parameters. 284 | 285 | Returns: 286 | List of RouteSchema objects, one per URL pattern 287 | 288 | Note: 289 | For projects with many routes (1000+), this may take a few seconds 290 | on first call. Results are not cached. 291 | """ 292 | resolver = get_resolver() 293 | routes = extract_routes(resolver.url_patterns) 294 | return routes 295 | 296 | 297 | def filter_routes( 298 | routes: list[RouteSchema], 299 | method: ViewMethod | None = None, 300 | name: str | None = None, 301 | pattern: str | None = None, 302 | ) -> list[RouteSchema]: 303 | """Filter routes using contains matching on each parameter. 304 | 305 | All filters are AND'd together - routes must match all provided filters. 306 | 307 | Args: 308 | method: ViewMethod enum or None for filtering by HTTP method 309 | name: Route name substring for filtering (case-sensitive) 310 | pattern: URL pattern substring for filtering (case-sensitive) 311 | 312 | Returns: 313 | Filtered list of routes matching all provided criteria 314 | 315 | Raises: 316 | ValueError: If method is not a valid HTTP method name 317 | """ 318 | if method: 319 | routes = [r for r in routes if not r.view.methods or method in r.view.methods] 320 | 321 | if name is not None: 322 | routes = [r for r in routes if r.name and name in r.name] 323 | 324 | if pattern is not None: 325 | routes = [r for r in routes if pattern in r.pattern] 326 | 327 | return routes 328 | -------------------------------------------------------------------------------- /tests/test_routing.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import inspect 4 | from pathlib import Path 5 | 6 | import pytest 7 | 8 | from mcp_django.project.routing import ClassViewSchema 9 | from mcp_django.project.routing import FunctionViewSchema 10 | from mcp_django.project.routing import RouteSchema 11 | from mcp_django.project.routing import ViewMethod 12 | from mcp_django.project.routing import ViewType 13 | from mcp_django.project.routing import extract_url_parameters 14 | from mcp_django.project.routing import filter_routes 15 | from mcp_django.project.routing import get_all_routes 16 | from mcp_django.project.routing import get_source_file_path 17 | from mcp_django.project.routing import get_view_func 18 | from mcp_django.project.routing import get_view_name 19 | from tests.urls import ArticleCreate as DummyCreateView 20 | from tests.urls import ArticleDelete as DummyDeleteView 21 | from tests.urls import ArticleDetail as DummyDetailView 22 | from tests.urls import BasicView as DummyView 23 | from tests.urls import cached_get_view as stacked_decorators_view 24 | from tests.urls import dummy_view as plain_function_view 25 | from tests.urls import get_only_view as require_get_decorated_view 26 | from tests.urls import multi_method_view as require_http_methods_decorated_view 27 | 28 | 29 | @pytest.fixture 30 | def sample_routes(): 31 | return [ 32 | RouteSchema( 33 | pattern="home/", 34 | name="home_page", 35 | namespace=None, 36 | parameters=[], 37 | view=FunctionViewSchema( 38 | name="test.view1", 39 | type=ViewType.FUNCTION, 40 | source_path=Path("test.py"), 41 | methods=[], 42 | ), 43 | ), 44 | RouteSchema( 45 | pattern="about/", 46 | name="about_page", 47 | namespace=None, 48 | parameters=[], 49 | view=FunctionViewSchema( 50 | name="test.view2", 51 | type=ViewType.FUNCTION, 52 | source_path=Path("test.py"), 53 | methods=[], 54 | ), 55 | ), 56 | RouteSchema( 57 | pattern="home/detail/", 58 | name=None, 59 | namespace=None, 60 | parameters=[], 61 | view=FunctionViewSchema( 62 | name="test.view3", 63 | type=ViewType.FUNCTION, 64 | source_path=Path("test.py"), 65 | methods=[], 66 | ), 67 | ), 68 | RouteSchema( 69 | pattern="get-only/", 70 | name=None, 71 | namespace=None, 72 | parameters=[], 73 | view=FunctionViewSchema( 74 | name="test.view4", 75 | type=ViewType.FUNCTION, 76 | source_path=Path("test.py"), 77 | methods=[ViewMethod.GET], 78 | ), 79 | ), 80 | RouteSchema( 81 | pattern="post-only/", 82 | name=None, 83 | namespace=None, 84 | parameters=[], 85 | view=FunctionViewSchema( 86 | name="test.view5", 87 | type=ViewType.FUNCTION, 88 | source_path=Path("test.py"), 89 | methods=[ViewMethod.POST], 90 | ), 91 | ), 92 | RouteSchema( 93 | pattern="unspecified/", 94 | name=None, 95 | namespace=None, 96 | parameters=[], 97 | view=FunctionViewSchema( 98 | name="test.view6", 99 | type=ViewType.FUNCTION, 100 | source_path=Path("test.py"), 101 | methods=[], 102 | ), 103 | ), 104 | RouteSchema( 105 | pattern="api/users/", 106 | name="api_users", 107 | namespace=None, 108 | parameters=[], 109 | view=FunctionViewSchema( 110 | name="test.view7", 111 | type=ViewType.FUNCTION, 112 | source_path=Path("test.py"), 113 | methods=[ViewMethod.GET], 114 | ), 115 | ), 116 | RouteSchema( 117 | pattern="api/posts/", 118 | name="api_posts", 119 | namespace=None, 120 | parameters=[], 121 | view=FunctionViewSchema( 122 | name="test.view8", 123 | type=ViewType.FUNCTION, 124 | source_path=Path("test.py"), 125 | methods=[ViewMethod.GET], 126 | ), 127 | ), 128 | RouteSchema( 129 | pattern="web/users/", 130 | name="web_users", 131 | namespace=None, 132 | parameters=[], 133 | view=FunctionViewSchema( 134 | name="test.view9", 135 | type=ViewType.FUNCTION, 136 | source_path=Path("test.py"), 137 | methods=[ViewMethod.GET], 138 | ), 139 | ), 140 | ] 141 | 142 | 143 | def test_function_view_schema_plain_function(): 144 | schema = FunctionViewSchema.from_callback(plain_function_view) 145 | 146 | assert isinstance(schema, FunctionViewSchema) 147 | assert schema.type == ViewType.FUNCTION 148 | assert schema.name.endswith("dummy_view") 149 | assert schema.methods == [] 150 | assert isinstance(schema.source_path, Path) 151 | 152 | 153 | @pytest.mark.parametrize( 154 | "view,expected_methods", 155 | [ 156 | (require_get_decorated_view, [ViewMethod.GET]), 157 | ( 158 | require_http_methods_decorated_view, 159 | [ViewMethod.GET, ViewMethod.POST, ViewMethod.PUT], 160 | ), 161 | (stacked_decorators_view, []), 162 | ], 163 | ) 164 | def test_function_view_schema_decorator_detection(view, expected_methods): 165 | schema = FunctionViewSchema.from_callback(view) 166 | assert isinstance(schema, FunctionViewSchema) 167 | assert schema.type == ViewType.FUNCTION 168 | assert set(schema.methods) == set(expected_methods) 169 | 170 | 171 | def test_class_view_schema_basic_view(): 172 | schema = ClassViewSchema.from_callback(DummyView) 173 | 174 | assert isinstance(schema, ClassViewSchema) 175 | assert schema.type == ViewType.CLASS 176 | assert schema.name.endswith("BasicView") 177 | assert schema.class_bases == ["View"] 178 | assert ViewMethod.GET in schema.methods 179 | assert ViewMethod.POST in schema.methods 180 | assert isinstance(schema.source_path, Path) 181 | 182 | 183 | @pytest.mark.parametrize( 184 | "view_class,base_name,expected_in,expected_not_in", 185 | [ 186 | ( 187 | DummyDetailView, 188 | "DetailView", 189 | [ViewMethod.GET], 190 | [ViewMethod.POST, ViewMethod.PUT, ViewMethod.DELETE], 191 | ), 192 | (DummyCreateView, "CreateView", [ViewMethod.GET, ViewMethod.POST], []), 193 | ( 194 | DummyDeleteView, 195 | "DeleteView", 196 | [ViewMethod.GET, ViewMethod.POST, ViewMethod.DELETE], 197 | [], 198 | ), 199 | ], 200 | ) 201 | def test_class_view_schema_generic_views( 202 | view_class, base_name, expected_in, expected_not_in 203 | ): 204 | schema = ClassViewSchema.from_callback(view_class) 205 | assert isinstance(schema, ClassViewSchema) 206 | assert schema.type == ViewType.CLASS 207 | assert base_name in schema.class_bases 208 | for method in expected_in: 209 | assert method in schema.methods 210 | for method in expected_not_in: 211 | assert method not in schema.methods 212 | 213 | 214 | def test_class_view_schema_as_view_callback(): 215 | callback = DummyView.as_view() 216 | schema = ClassViewSchema.from_callback(callback) 217 | 218 | assert isinstance(schema, ClassViewSchema) 219 | assert "BasicView" in schema.name 220 | assert schema.class_bases == ["View"] 221 | 222 | 223 | def test_get_view_func_unwraps_decorators(): 224 | view_func = get_view_func(stacked_decorators_view) 225 | 226 | assert view_func.__name__ == "cached_get_view" 227 | assert not hasattr(view_func, "__wrapped__") 228 | 229 | 230 | def test_get_view_func_extracts_view_class_from_as_view(): 231 | callback = DummyView.as_view() 232 | view_func = get_view_func(callback) 233 | 234 | assert inspect.isclass(view_func) 235 | assert view_func == DummyView 236 | 237 | 238 | @pytest.mark.parametrize( 239 | "obj,expected_suffix", 240 | [ 241 | (plain_function_view, "dummy_view"), 242 | (DummyView, "BasicView"), 243 | ], 244 | ids=["function", "class"], 245 | ) 246 | def test_get_view_name(obj, expected_suffix): 247 | name = get_view_name(obj) 248 | assert "." in name 249 | assert name.endswith(expected_suffix) 250 | 251 | 252 | @pytest.mark.parametrize( 253 | "obj,expected_marker", 254 | [ 255 | (plain_function_view, "urls.py"), 256 | (DummyView, "urls.py"), 257 | (int, "unknown"), 258 | ], 259 | ids=["function", "class", "builtin"], 260 | ) 261 | def test_get_source_file_path(obj, expected_marker): 262 | result = get_source_file_path(obj) 263 | assert isinstance(result, Path) 264 | 265 | if expected_marker == "unknown": 266 | assert result == Path("unknown") 267 | else: 268 | assert expected_marker in str(result) 269 | 270 | 271 | @pytest.mark.parametrize( 272 | "pattern,expected", 273 | [ 274 | ("home/", []), 275 | ("about/contact/", []), 276 | ("", []), 277 | ("no-params/", []), 278 | ("blog//", ["pk"]), 279 | ("user//", ["username"]), 280 | ("blog////", ["year", "month", "slug"]), 281 | ( 282 | "api/v1/users//posts//", 283 | ["user_id", "post_id"], 284 | ), 285 | ("items//", ["pk"]), 286 | ("users//profile/", ["username"]), 287 | ("broken//", []), 288 | ("weird/<:name>/", []), 289 | ("//", ["a", "b"]), 290 | ], 291 | ) 292 | def test_extract_url_parameters(pattern, expected): 293 | assert extract_url_parameters(pattern) == expected 294 | 295 | 296 | def test_filter_routes_by_pattern(sample_routes): 297 | filtered = filter_routes(sample_routes, pattern="home") 298 | 299 | assert len(filtered) == 2 300 | assert all("home" in route.pattern for route in filtered) 301 | 302 | 303 | def test_filter_routes_by_name(sample_routes): 304 | filtered = filter_routes(sample_routes, name="home") 305 | 306 | assert len(filtered) == 1 307 | assert filtered[0].name == "home_page" 308 | assert all(route.name is not None for route in filtered) 309 | 310 | 311 | def test_filter_routes_by_method(sample_routes): 312 | filtered = filter_routes(sample_routes, method=ViewMethod.GET) 313 | 314 | assert len(filtered) >= 2 315 | for route in filtered: 316 | if route.view.methods: 317 | assert ViewMethod.GET in route.view.methods 318 | 319 | 320 | def test_filter_routes_combines_criteria_with_and(sample_routes): 321 | filtered = filter_routes( 322 | sample_routes, pattern="api", name="users", method=ViewMethod.GET 323 | ) 324 | 325 | assert len(filtered) == 1 326 | assert filtered[0].pattern == "api/users/" 327 | assert filtered[0].name == "api_users" 328 | 329 | 330 | def test_filter_routes_empty_list(): 331 | filtered = filter_routes([]) 332 | assert filtered == [] 333 | 334 | 335 | def test_filter_routes_no_matches(sample_routes): 336 | filtered = filter_routes(sample_routes, pattern="NONEXISTENT") 337 | assert filtered == [] 338 | 339 | 340 | def test_route_count_matches_urlconf_integration(): 341 | routes = get_all_routes() 342 | 343 | expected_routes = [ 344 | "home", 345 | "get_only", 346 | "multi_method", 347 | "cached_get", 348 | "basic_view", 349 | "article_detail", 350 | "article_create", 351 | "article_delete", 352 | "item_by_slug", 353 | "archive_detail", 354 | "blog:post-list", 355 | "v1:users:user-detail", 356 | "v1:users:user-posts", 357 | ] 358 | 359 | route_names = [] 360 | for route in routes: 361 | if route.namespace and route.name: 362 | route_names.append(f"{route.namespace}:{route.name}") 363 | elif route.name: 364 | route_names.append(route.name) 365 | 366 | for expected in expected_routes: 367 | assert expected in route_names, f"Expected route '{expected}' not found" 368 | --------------------------------------------------------------------------------