django-orm-lens 1.2.6__tar.gz → 1.3.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {django_orm_lens-1.2.6 → django_orm_lens-1.3.0}/PKG-INFO +22 -6
- {django_orm_lens-1.2.6 → django_orm_lens-1.3.0}/README.md +21 -5
- {django_orm_lens-1.2.6 → django_orm_lens-1.3.0}/django_orm_lens/mcp_server.py +211 -95
- {django_orm_lens-1.2.6 → django_orm_lens-1.3.0}/django_orm_lens/parser.py +5 -0
- django_orm_lens-1.3.0/django_orm_lens/workspace.py +348 -0
- {django_orm_lens-1.2.6 → django_orm_lens-1.3.0}/django_orm_lens.egg-info/PKG-INFO +22 -6
- {django_orm_lens-1.2.6 → django_orm_lens-1.3.0}/django_orm_lens.egg-info/SOURCES.txt +4 -0
- {django_orm_lens-1.2.6 → django_orm_lens-1.3.0}/pyproject.toml +1 -1
- django_orm_lens-1.3.0/tests/test_bom_prefix.py +40 -0
- django_orm_lens-1.3.0/tests/test_mcp_server.py +282 -0
- django_orm_lens-1.3.0/tests/test_mcp_workspace.py +364 -0
- {django_orm_lens-1.2.6 → django_orm_lens-1.3.0}/django_orm_lens/__init__.py +0 -0
- {django_orm_lens-1.2.6 → django_orm_lens-1.3.0}/django_orm_lens/cli.py +0 -0
- {django_orm_lens-1.2.6 → django_orm_lens-1.3.0}/django_orm_lens/diff.py +0 -0
- {django_orm_lens-1.2.6 → django_orm_lens-1.3.0}/django_orm_lens/formatters.py +0 -0
- {django_orm_lens-1.2.6 → django_orm_lens-1.3.0}/django_orm_lens/migrations_parser.py +0 -0
- {django_orm_lens-1.2.6 → django_orm_lens-1.3.0}/django_orm_lens/models.py +0 -0
- {django_orm_lens-1.2.6 → django_orm_lens-1.3.0}/django_orm_lens/py.typed +0 -0
- {django_orm_lens-1.2.6 → django_orm_lens-1.3.0}/django_orm_lens/query_analyzer.py +0 -0
- {django_orm_lens-1.2.6 → django_orm_lens-1.3.0}/django_orm_lens/signals_parser.py +0 -0
- {django_orm_lens-1.2.6 → django_orm_lens-1.3.0}/django_orm_lens.egg-info/dependency_links.txt +0 -0
- {django_orm_lens-1.2.6 → django_orm_lens-1.3.0}/django_orm_lens.egg-info/entry_points.txt +0 -0
- {django_orm_lens-1.2.6 → django_orm_lens-1.3.0}/django_orm_lens.egg-info/requires.txt +0 -0
- {django_orm_lens-1.2.6 → django_orm_lens-1.3.0}/django_orm_lens.egg-info/top_level.txt +0 -0
- {django_orm_lens-1.2.6 → django_orm_lens-1.3.0}/setup.cfg +0 -0
- {django_orm_lens-1.2.6 → django_orm_lens-1.3.0}/tests/test_abstract_and_auth_user.py +0 -0
- {django_orm_lens-1.2.6 → django_orm_lens-1.3.0}/tests/test_aliased_module_and_third_party_fields.py +0 -0
- {django_orm_lens-1.2.6 → django_orm_lens-1.3.0}/tests/test_cli_hint.py +0 -0
- {django_orm_lens-1.2.6 → django_orm_lens-1.3.0}/tests/test_cli_subcommands_smoke.py +0 -0
- {django_orm_lens-1.2.6 → django_orm_lens-1.3.0}/tests/test_cli_verbose.py +0 -0
- {django_orm_lens-1.2.6 → django_orm_lens-1.3.0}/tests/test_cross_module_bases.py +0 -0
- {django_orm_lens-1.2.6 → django_orm_lens-1.3.0}/tests/test_diff_command.py +0 -0
- {django_orm_lens-1.2.6 → django_orm_lens-1.3.0}/tests/test_golden_fixtures.py +0 -0
- {django_orm_lens-1.2.6 → django_orm_lens-1.3.0}/tests/test_kwarg_order_and_auth_user.py +0 -0
- {django_orm_lens-1.2.6 → django_orm_lens-1.3.0}/tests/test_list_command.py +0 -0
- {django_orm_lens-1.2.6 → django_orm_lens-1.3.0}/tests/test_migration_risk.py +0 -0
- {django_orm_lens-1.2.6 → django_orm_lens-1.3.0}/tests/test_migrations_parser.py +0 -0
- {django_orm_lens-1.2.6 → django_orm_lens-1.3.0}/tests/test_nplusone_detector.py +0 -0
- {django_orm_lens-1.2.6 → django_orm_lens-1.3.0}/tests/test_parity.py +0 -0
- {django_orm_lens-1.2.6 → django_orm_lens-1.3.0}/tests/test_pep526_type_hints.py +0 -0
- {django_orm_lens-1.2.6 → django_orm_lens-1.3.0}/tests/test_pep695_generic_classes.py +0 -0
- {django_orm_lens-1.2.6 → django_orm_lens-1.3.0}/tests/test_query_analyzer.py +0 -0
- {django_orm_lens-1.2.6 → django_orm_lens-1.3.0}/tests/test_scan_root.py +0 -0
- {django_orm_lens-1.2.6 → django_orm_lens-1.3.0}/tests/test_signals_parser.py +0 -0
- {django_orm_lens-1.2.6 → django_orm_lens-1.3.0}/tests/test_tab_indented_models.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: django-orm-lens
|
|
3
|
-
Version: 1.
|
|
3
|
+
Version: 1.3.0
|
|
4
4
|
Summary: Static analysis + MCP server for Django models. Sidebar tree, ER diagrams, and JSON output for terminals and AI coding agents.
|
|
5
5
|
Author: frowningdev
|
|
6
6
|
License: MIT
|
|
@@ -128,8 +128,16 @@ django-orm-lens-mcp # dedicated entry point
|
|
|
128
128
|
django-orm-lens mcp # subcommand
|
|
129
129
|
```
|
|
130
130
|
|
|
131
|
-
|
|
132
|
-
`DJANGO_ORM_LENS_ROOT
|
|
131
|
+
**Workspace resolution (py-1.3.0+).** Priority: explicit `workspace_root`
|
|
132
|
+
argument on the tool call → `DJANGO_ORM_LENS_ROOT` env var → current working
|
|
133
|
+
directory. If none resolves to a Django project (`manage.py`, `django` in
|
|
134
|
+
`pyproject.toml`, or any `models.py`) you get a structured error envelope
|
|
135
|
+
back — `{"error": "WORKSPACE_NOT_DJANGO", "hint": "…"}` — instead of an
|
|
136
|
+
empty list, so the agent knows what to do next.
|
|
137
|
+
|
|
138
|
+
Optional sandbox: set `DJANGO_ORM_LENS_ALLOWED_ROOTS` (`;`-separated on
|
|
139
|
+
Windows, `:`-separated elsewhere) to a whitelist of prefixes; any path
|
|
140
|
+
outside them is rejected with `WORKSPACE_NOT_ALLOWED`.
|
|
133
141
|
|
|
134
142
|
### Register it with an agent
|
|
135
143
|
|
|
@@ -146,9 +154,17 @@ By default the server scans the current working directory. Override with
|
|
|
146
154
|
}
|
|
147
155
|
```
|
|
148
156
|
|
|
149
|
-
**
|
|
150
|
-
|
|
151
|
-
|
|
157
|
+
**Any MCP client** — same shape, generic tool. Point `command` at the
|
|
158
|
+
installed `django-orm-lens-mcp` binary. Two ways to tell it which Django
|
|
159
|
+
project to scan:
|
|
160
|
+
|
|
161
|
+
1. **Set `DJANGO_ORM_LENS_ROOT` in `env`** — the whole session uses one
|
|
162
|
+
project. Simplest for single-repo workflows.
|
|
163
|
+
2. **Pass `workspace_root` on each tool call** — the agent switches
|
|
164
|
+
projects per call. Useful for mono-repos and multi-workspace setups.
|
|
165
|
+
|
|
166
|
+
The tool signatures include `workspace_root: str = ""` as an optional
|
|
167
|
+
parameter, so any agent inspecting `tools/list` sees it and can supply it.
|
|
152
168
|
|
|
153
169
|
---
|
|
154
170
|
|
|
@@ -79,8 +79,16 @@ django-orm-lens-mcp # dedicated entry point
|
|
|
79
79
|
django-orm-lens mcp # subcommand
|
|
80
80
|
```
|
|
81
81
|
|
|
82
|
-
|
|
83
|
-
`DJANGO_ORM_LENS_ROOT
|
|
82
|
+
**Workspace resolution (py-1.3.0+).** Priority: explicit `workspace_root`
|
|
83
|
+
argument on the tool call → `DJANGO_ORM_LENS_ROOT` env var → current working
|
|
84
|
+
directory. If none resolves to a Django project (`manage.py`, `django` in
|
|
85
|
+
`pyproject.toml`, or any `models.py`) you get a structured error envelope
|
|
86
|
+
back — `{"error": "WORKSPACE_NOT_DJANGO", "hint": "…"}` — instead of an
|
|
87
|
+
empty list, so the agent knows what to do next.
|
|
88
|
+
|
|
89
|
+
Optional sandbox: set `DJANGO_ORM_LENS_ALLOWED_ROOTS` (`;`-separated on
|
|
90
|
+
Windows, `:`-separated elsewhere) to a whitelist of prefixes; any path
|
|
91
|
+
outside them is rejected with `WORKSPACE_NOT_ALLOWED`.
|
|
84
92
|
|
|
85
93
|
### Register it with an agent
|
|
86
94
|
|
|
@@ -97,9 +105,17 @@ By default the server scans the current working directory. Override with
|
|
|
97
105
|
}
|
|
98
106
|
```
|
|
99
107
|
|
|
100
|
-
**
|
|
101
|
-
|
|
102
|
-
|
|
108
|
+
**Any MCP client** — same shape, generic tool. Point `command` at the
|
|
109
|
+
installed `django-orm-lens-mcp` binary. Two ways to tell it which Django
|
|
110
|
+
project to scan:
|
|
111
|
+
|
|
112
|
+
1. **Set `DJANGO_ORM_LENS_ROOT` in `env`** — the whole session uses one
|
|
113
|
+
project. Simplest for single-repo workflows.
|
|
114
|
+
2. **Pass `workspace_root` on each tool call** — the agent switches
|
|
115
|
+
projects per call. Useful for mono-repos and multi-workspace setups.
|
|
116
|
+
|
|
117
|
+
The tool signatures include `workspace_root: str = ""` as an optional
|
|
118
|
+
parameter, so any agent inspecting `tools/list` sees it and can supply it.
|
|
103
119
|
|
|
104
120
|
---
|
|
105
121
|
|
|
@@ -15,87 +15,66 @@ Exposes nine read-only tools that any MCP-compatible AI agent can call:
|
|
|
15
15
|
The ``mcp`` runtime package is loaded lazily so ``pip install django-orm-lens``
|
|
16
16
|
stays zero-dep. Install with the extras: ``pip install 'django-orm-lens[mcp]'``.
|
|
17
17
|
|
|
18
|
-
|
|
18
|
+
Workspace discovery (py-1.3.0+)
|
|
19
|
+
-------------------------------
|
|
20
|
+
Every tool accepts an optional ``workspace_root`` argument. Resolution
|
|
21
|
+
priority — explicit arg, then ``$DJANGO_ORM_LENS_ROOT`` env var, then ``cwd``
|
|
22
|
+
— is implemented once in :mod:`django_orm_lens.workspace`. Before py-1.3.0
|
|
23
|
+
the argument was silently dropped by FastMCP because the tool signatures did
|
|
24
|
+
not declare it; the fix is to declare the argument on every tool and delegate
|
|
25
|
+
resolution to a single hardened helper. On failure the tool returns a JSON
|
|
26
|
+
envelope ``{"error": "...", "hint": "..."}`` so the agent gets an actionable
|
|
27
|
+
message instead of an empty list.
|
|
19
28
|
"""
|
|
20
29
|
|
|
21
30
|
from __future__ import annotations
|
|
22
31
|
|
|
23
32
|
import json
|
|
24
|
-
import os
|
|
25
33
|
import sys
|
|
26
|
-
import
|
|
27
|
-
from typing import Any, Dict, List,
|
|
34
|
+
from pathlib import Path
|
|
35
|
+
from typing import Any, Dict, List, Union
|
|
28
36
|
|
|
29
37
|
from .cli import _build_mermaid
|
|
30
38
|
from .migrations_parser import describe_migration_dependency
|
|
31
39
|
from .models import WorkspaceIndex, find_model, find_user_model, resolve_related_tail
|
|
32
|
-
from .parser import DEFAULT_EXCLUDES, scan_workspace
|
|
33
40
|
from .query_analyzer import suggest_indexes as _suggest_indexes
|
|
34
41
|
from .signals_parser import signal_graph as _signal_graph
|
|
42
|
+
from .workspace import WorkspaceError, get_index, resolve_workspace
|
|
35
43
|
|
|
36
44
|
|
|
37
|
-
|
|
38
|
-
|
|
45
|
+
# ---------------------------------------------------------------------------
|
|
46
|
+
# Resolution helper — every tool starts here
|
|
47
|
+
# ---------------------------------------------------------------------------
|
|
39
48
|
|
|
40
49
|
|
|
41
|
-
|
|
42
|
-
|
|
50
|
+
def _resolve(args: Dict[str, Any]) -> Union[Path, WorkspaceError]:
|
|
51
|
+
"""Extract ``workspace_root`` from tool args and delegate to workspace.py.
|
|
43
52
|
|
|
53
|
+
Centralised so every tool has identical resolution semantics. Empty string,
|
|
54
|
+
missing key, and ``None`` all fall through to env var / cwd — same as
|
|
55
|
+
calling the tool with no argument at all.
|
|
56
|
+
"""
|
|
57
|
+
raw = (args or {}).get("workspace_root") or ""
|
|
58
|
+
return resolve_workspace(raw)
|
|
44
59
|
|
|
45
|
-
def _get_index() -> WorkspaceIndex:
|
|
46
|
-
"""Cache-backed workspace index — 30s TTL, keyed by workspace root.
|
|
47
60
|
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
edits between calls still pick up on the next scan.
|
|
52
|
-
"""
|
|
53
|
-
root = _workspace_root()
|
|
54
|
-
now_ms = int(time.time() * 1000)
|
|
55
|
-
entry = _INDEX_CACHE.get(root)
|
|
56
|
-
if entry is not None and entry[1] > now_ms:
|
|
57
|
-
return entry[0]
|
|
58
|
-
idx = scan_workspace(root, DEFAULT_EXCLUDES)
|
|
59
|
-
_INDEX_CACHE[root] = (idx, now_ms + _CACHE_TTL_MS)
|
|
60
|
-
return idx
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
def _tool_list_apps(_args: Dict[str, Any]) -> str:
|
|
64
|
-
idx = _get_index()
|
|
65
|
-
return json.dumps(
|
|
66
|
-
[
|
|
67
|
-
{"name": a.name, "path": a.path, "models": len(a.models)}
|
|
68
|
-
for a in idx.apps
|
|
69
|
-
],
|
|
70
|
-
indent=2,
|
|
71
|
-
)
|
|
61
|
+
def _error_json(err: WorkspaceError) -> str:
|
|
62
|
+
"""Serialize a :class:`WorkspaceError` as an indent-2 JSON envelope."""
|
|
63
|
+
return json.dumps(err.to_dict(), indent=2)
|
|
72
64
|
|
|
73
65
|
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
if only_app and not any(a.name == only_app for a in idx.apps):
|
|
78
|
-
available = ", ".join(sorted(a.name for a in idx.apps)) or "(none)"
|
|
79
|
-
return (
|
|
80
|
-
f"(app '{only_app}' not found in workspace; "
|
|
81
|
-
f"available apps: {available})"
|
|
82
|
-
)
|
|
83
|
-
rows: List[str] = []
|
|
84
|
-
for app in idx.apps:
|
|
85
|
-
if only_app and app.name != only_app:
|
|
86
|
-
continue
|
|
87
|
-
for m in app.models:
|
|
88
|
-
rows.append(f"{app.name}.{m.name}")
|
|
89
|
-
return "\n".join(rows) if rows else "(no models)"
|
|
66
|
+
# ---------------------------------------------------------------------------
|
|
67
|
+
# Model lookup helpers — shared by describe_model / find_relations / cascade
|
|
68
|
+
# ---------------------------------------------------------------------------
|
|
90
69
|
|
|
91
70
|
|
|
92
|
-
def _find(idx, ref: str):
|
|
93
|
-
|
|
94
|
-
|
|
71
|
+
def _find(idx: WorkspaceIndex, ref: str):
|
|
72
|
+
"""Delegate to shared ``find_model`` so MCP and CLI lookup semantics stay
|
|
73
|
+
in sync (they previously drifted — hence the shared helper)."""
|
|
95
74
|
return find_model(idx, ref)
|
|
96
75
|
|
|
97
76
|
|
|
98
|
-
def _workspace_user_model(idx):
|
|
77
|
+
def _workspace_user_model(idx: WorkspaceIndex):
|
|
99
78
|
"""Return ``(app_name, model)`` for the workspace's Django User model.
|
|
100
79
|
|
|
101
80
|
Thin wrapper around :func:`models.find_user_model` that preserves the
|
|
@@ -111,7 +90,7 @@ def _workspace_user_model(idx):
|
|
|
111
90
|
return None, user_model
|
|
112
91
|
|
|
113
92
|
|
|
114
|
-
def _rel_matches_target(related_model, target_name, user_model):
|
|
93
|
+
def _rel_matches_target(related_model, target_name, user_model) -> bool:
|
|
115
94
|
"""True iff ``related_model`` string points at ``target_name``. Handles
|
|
116
95
|
``settings.AUTH_USER_MODEL`` via :func:`models.resolve_related_tail`."""
|
|
117
96
|
if not related_model:
|
|
@@ -120,8 +99,51 @@ def _rel_matches_target(related_model, target_name, user_model):
|
|
|
120
99
|
return resolve_related_tail(related_model, user_name) == target_name
|
|
121
100
|
|
|
122
101
|
|
|
102
|
+
# ---------------------------------------------------------------------------
|
|
103
|
+
# Nine tool handlers
|
|
104
|
+
# ---------------------------------------------------------------------------
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def _tool_list_apps(args: Dict[str, Any]) -> str:
|
|
108
|
+
ws = _resolve(args)
|
|
109
|
+
if isinstance(ws, WorkspaceError):
|
|
110
|
+
return _error_json(ws)
|
|
111
|
+
idx = get_index(ws)
|
|
112
|
+
return json.dumps(
|
|
113
|
+
[
|
|
114
|
+
{"name": a.name, "path": a.path, "models": len(a.models)}
|
|
115
|
+
for a in idx.apps
|
|
116
|
+
],
|
|
117
|
+
indent=2,
|
|
118
|
+
)
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def _tool_list_models(args: Dict[str, Any]) -> str:
|
|
122
|
+
ws = _resolve(args)
|
|
123
|
+
if isinstance(ws, WorkspaceError):
|
|
124
|
+
return _error_json(ws)
|
|
125
|
+
idx = get_index(ws)
|
|
126
|
+
only_app = (args or {}).get("app")
|
|
127
|
+
if only_app and not any(a.name == only_app for a in idx.apps):
|
|
128
|
+
available = ", ".join(sorted(a.name for a in idx.apps)) or "(none)"
|
|
129
|
+
return (
|
|
130
|
+
f"(app '{only_app}' not found in workspace; "
|
|
131
|
+
f"available apps: {available})"
|
|
132
|
+
)
|
|
133
|
+
rows: List[str] = []
|
|
134
|
+
for app in idx.apps:
|
|
135
|
+
if only_app and app.name != only_app:
|
|
136
|
+
continue
|
|
137
|
+
for m in app.models:
|
|
138
|
+
rows.append(f"{app.name}.{m.name}")
|
|
139
|
+
return "\n".join(rows) if rows else "(no models)"
|
|
140
|
+
|
|
141
|
+
|
|
123
142
|
def _tool_describe_model(args: Dict[str, Any]) -> str:
|
|
124
|
-
|
|
143
|
+
ws = _resolve(args)
|
|
144
|
+
if isinstance(ws, WorkspaceError):
|
|
145
|
+
return _error_json(ws)
|
|
146
|
+
idx = get_index(ws)
|
|
125
147
|
ref = (args or {}).get("model", "")
|
|
126
148
|
m = _find(idx, ref)
|
|
127
149
|
if not m:
|
|
@@ -130,7 +152,10 @@ def _tool_describe_model(args: Dict[str, Any]) -> str:
|
|
|
130
152
|
|
|
131
153
|
|
|
132
154
|
def _tool_find_relations(args: Dict[str, Any]) -> str:
|
|
133
|
-
|
|
155
|
+
ws = _resolve(args)
|
|
156
|
+
if isinstance(ws, WorkspaceError):
|
|
157
|
+
return _error_json(ws)
|
|
158
|
+
idx = get_index(ws)
|
|
134
159
|
ref = (args or {}).get("model", "")
|
|
135
160
|
m = _find(idx, ref)
|
|
136
161
|
if not m:
|
|
@@ -167,7 +192,10 @@ def _tool_find_relations(args: Dict[str, Any]) -> str:
|
|
|
167
192
|
|
|
168
193
|
|
|
169
194
|
def _tool_cascade_preview(args: Dict[str, Any]) -> str:
|
|
170
|
-
|
|
195
|
+
ws = _resolve(args)
|
|
196
|
+
if isinstance(ws, WorkspaceError):
|
|
197
|
+
return _error_json(ws)
|
|
198
|
+
idx = get_index(ws)
|
|
171
199
|
app_label = (args or {}).get("app_label", "")
|
|
172
200
|
model_name = (args or {}).get("model_name", "")
|
|
173
201
|
ref = f"{app_label}.{model_name}" if app_label else model_name
|
|
@@ -210,8 +238,11 @@ def _tool_cascade_preview(args: Dict[str, Any]) -> str:
|
|
|
210
238
|
return json.dumps(out, indent=2)
|
|
211
239
|
|
|
212
240
|
|
|
213
|
-
def _tool_er_diagram(
|
|
214
|
-
|
|
241
|
+
def _tool_er_diagram(args: Dict[str, Any]) -> str:
|
|
242
|
+
ws = _resolve(args)
|
|
243
|
+
if isinstance(ws, WorkspaceError):
|
|
244
|
+
return _error_json(ws)
|
|
245
|
+
idx = get_index(ws)
|
|
215
246
|
return _build_mermaid(idx)
|
|
216
247
|
|
|
217
248
|
|
|
@@ -222,10 +253,13 @@ def _tool_describe_migration_dependency(args: Dict[str, Any]) -> str:
|
|
|
222
253
|
Unique to django-orm-lens across the MCP + Django-graph tool ecosystem
|
|
223
254
|
(competitors require a running Django process).
|
|
224
255
|
"""
|
|
256
|
+
ws = _resolve(args)
|
|
257
|
+
if isinstance(ws, WorkspaceError):
|
|
258
|
+
return _error_json(ws)
|
|
225
259
|
app_label = (args or {}).get("app_label", "")
|
|
226
260
|
if not app_label:
|
|
227
261
|
raise ValueError("app_label is required")
|
|
228
|
-
result = describe_migration_dependency(app_label,
|
|
262
|
+
result = describe_migration_dependency(app_label, str(ws))
|
|
229
263
|
return json.dumps(result, indent=2)
|
|
230
264
|
|
|
231
265
|
|
|
@@ -238,16 +272,19 @@ def _tool_suggest_indexes(args: Dict[str, Any]) -> str:
|
|
|
238
272
|
Cross-references with the model's existing ``Meta.indexes`` so
|
|
239
273
|
already-covered combos are not re-proposed.
|
|
240
274
|
"""
|
|
275
|
+
ws = _resolve(args)
|
|
276
|
+
if isinstance(ws, WorkspaceError):
|
|
277
|
+
return _error_json(ws)
|
|
241
278
|
app_label = (args or {}).get("app_label", "")
|
|
242
279
|
model_name = (args or {}).get("model_name", "")
|
|
243
280
|
if not app_label or not model_name:
|
|
244
281
|
raise ValueError("app_label and model_name are required")
|
|
245
|
-
index =
|
|
246
|
-
result = _suggest_indexes(app_label, model_name,
|
|
282
|
+
index = get_index(ws)
|
|
283
|
+
result = _suggest_indexes(app_label, model_name, str(ws), index=index)
|
|
247
284
|
return json.dumps(result, indent=2)
|
|
248
285
|
|
|
249
286
|
|
|
250
|
-
def _tool_signal_graph(
|
|
287
|
+
def _tool_signal_graph(args: Dict[str, Any]) -> str:
|
|
251
288
|
"""Return the sender→signal→handler DAG for the workspace.
|
|
252
289
|
|
|
253
290
|
Static AST parse of every ``@receiver(...)`` decorator plus every
|
|
@@ -255,51 +292,99 @@ def _tool_signal_graph(_args: Dict[str, Any]) -> str:
|
|
|
255
292
|
``.send(...)`` call-sites. Surfaces cross-model side effects and
|
|
256
293
|
orphan handlers whose sender model isn't in the workspace.
|
|
257
294
|
"""
|
|
258
|
-
|
|
259
|
-
|
|
295
|
+
ws = _resolve(args)
|
|
296
|
+
if isinstance(ws, WorkspaceError):
|
|
297
|
+
return _error_json(ws)
|
|
298
|
+
index = get_index(ws)
|
|
299
|
+
result = _signal_graph(str(ws), index=index)
|
|
260
300
|
return json.dumps(result, indent=2)
|
|
261
301
|
|
|
262
302
|
|
|
303
|
+
# ---------------------------------------------------------------------------
|
|
304
|
+
# Tool registry — descriptions ship in tools/list so agents see them
|
|
305
|
+
# ---------------------------------------------------------------------------
|
|
306
|
+
|
|
307
|
+
_WORKSPACE_HINT = (
|
|
308
|
+
" Optional 'workspace_root' argument overrides the DJANGO_ORM_LENS_ROOT "
|
|
309
|
+
"env var and current directory — pass the absolute path to your Django "
|
|
310
|
+
"project root (the directory containing manage.py)."
|
|
311
|
+
)
|
|
312
|
+
|
|
263
313
|
TOOLS = {
|
|
264
314
|
"list_apps": {
|
|
265
315
|
"handler": _tool_list_apps,
|
|
266
|
-
"description":
|
|
316
|
+
"description": (
|
|
317
|
+
"List every Django app in the workspace with model counts." + _WORKSPACE_HINT
|
|
318
|
+
),
|
|
267
319
|
},
|
|
268
320
|
"list_models": {
|
|
269
321
|
"handler": _tool_list_models,
|
|
270
|
-
"description":
|
|
322
|
+
"description": (
|
|
323
|
+
"Flat list of app.Model. Optional 'app' filter." + _WORKSPACE_HINT
|
|
324
|
+
),
|
|
271
325
|
},
|
|
272
326
|
"describe_model": {
|
|
273
327
|
"handler": _tool_describe_model,
|
|
274
|
-
"description":
|
|
328
|
+
"description": (
|
|
329
|
+
"Full JSON detail for one model: fields, relations, Meta, base "
|
|
330
|
+
"classes, file path." + _WORKSPACE_HINT
|
|
331
|
+
),
|
|
275
332
|
},
|
|
276
333
|
"find_relations": {
|
|
277
334
|
"handler": _tool_find_relations,
|
|
278
|
-
"description":
|
|
335
|
+
"description": (
|
|
336
|
+
"Outbound (this model -> others) and inbound (others -> this) "
|
|
337
|
+
"relations. Inbound entries include on_delete behavior." + _WORKSPACE_HINT
|
|
338
|
+
),
|
|
279
339
|
},
|
|
280
340
|
"cascade_preview": {
|
|
281
341
|
"handler": _tool_cascade_preview,
|
|
282
|
-
"description":
|
|
342
|
+
"description": (
|
|
343
|
+
"Blast radius of deleting a row: inbound relations grouped by "
|
|
344
|
+
"on_delete behavior (cascade_kills, set_null, protected)." + _WORKSPACE_HINT
|
|
345
|
+
),
|
|
283
346
|
},
|
|
284
347
|
"er_diagram": {
|
|
285
348
|
"handler": _tool_er_diagram,
|
|
286
|
-
"description":
|
|
349
|
+
"description": (
|
|
350
|
+
"Emit a Mermaid erDiagram for the whole workspace." + _WORKSPACE_HINT
|
|
351
|
+
),
|
|
287
352
|
},
|
|
288
353
|
"describe_migration_dependency": {
|
|
289
354
|
"handler": _tool_describe_migration_dependency,
|
|
290
|
-
"description":
|
|
355
|
+
"description": (
|
|
356
|
+
"Return per-app migration DAG (dependencies, roots, leaves, "
|
|
357
|
+
"cross-app deps) from static AST parse of migrations/*.py. No "
|
|
358
|
+
"Django boot, no DB. Lets agents debug migration conflicts "
|
|
359
|
+
"safely." + _WORKSPACE_HINT
|
|
360
|
+
),
|
|
291
361
|
},
|
|
292
362
|
"suggest_indexes": {
|
|
293
363
|
"handler": _tool_suggest_indexes,
|
|
294
|
-
"description":
|
|
364
|
+
"description": (
|
|
365
|
+
"Static analysis of every filter/exclude/get/order_by/aggregate "
|
|
366
|
+
"usage of a model across the workspace. Returns field-usage "
|
|
367
|
+
"frequency and proposes Meta.indexes covering entries. "
|
|
368
|
+
"Zero-runtime, no DB, no Django boot." + _WORKSPACE_HINT
|
|
369
|
+
),
|
|
295
370
|
},
|
|
296
371
|
"signal_graph": {
|
|
297
372
|
"handler": _tool_signal_graph,
|
|
298
|
-
"description":
|
|
373
|
+
"description": (
|
|
374
|
+
"Parse every @receiver() decorator and Signal() definition in "
|
|
375
|
+
"the workspace. Returns sender->signal->handler DAG plus custom-"
|
|
376
|
+
"signal send-sites and orphan handlers. Zero-runtime, no DB, no "
|
|
377
|
+
"Django boot." + _WORKSPACE_HINT
|
|
378
|
+
),
|
|
299
379
|
},
|
|
300
380
|
}
|
|
301
381
|
|
|
302
382
|
|
|
383
|
+
# ---------------------------------------------------------------------------
|
|
384
|
+
# Server bootstrap — lazy-imports mcp so the base install stays zero-dep
|
|
385
|
+
# ---------------------------------------------------------------------------
|
|
386
|
+
|
|
387
|
+
|
|
303
388
|
def main() -> int:
|
|
304
389
|
"""Start the MCP stdio server. Lazy-imports the ``mcp`` package."""
|
|
305
390
|
try:
|
|
@@ -319,49 +404,80 @@ def main() -> int:
|
|
|
319
404
|
# surface server stderr (Cursor, Aider, mcp-inspector) show this once
|
|
320
405
|
# per session; silent clients ignore it. Zero effect on tool protocol.
|
|
321
406
|
print(
|
|
322
|
-
"django-orm-lens MCP ready
|
|
407
|
+
"django-orm-lens MCP ready - "
|
|
323
408
|
"if it saves your agent time, a star helps: "
|
|
324
409
|
"https://github.com/FROWNINGdev/django-orm-lens",
|
|
325
410
|
file=sys.stderr,
|
|
326
411
|
)
|
|
327
412
|
|
|
328
413
|
def _register(name: str, description: str, handler):
|
|
414
|
+
"""Register one tool.
|
|
415
|
+
|
|
416
|
+
Every tool declares ``workspace_root: str = ""`` in its Python
|
|
417
|
+
signature so FastMCP includes it in the ``inputSchema`` served to
|
|
418
|
+
``tools/list``. Agents that pass the argument reach the handler with
|
|
419
|
+
the value; agents that omit it fall through to env var / cwd via the
|
|
420
|
+
resolution chain in :mod:`django_orm_lens.workspace`.
|
|
421
|
+
"""
|
|
329
422
|
if name == "list_apps":
|
|
330
423
|
@server.tool(name=name, description=description)
|
|
331
|
-
def list_apps() -> str:
|
|
332
|
-
return handler({})
|
|
424
|
+
def list_apps(workspace_root: str = "") -> str:
|
|
425
|
+
return handler({"workspace_root": workspace_root})
|
|
333
426
|
elif name == "list_models":
|
|
334
427
|
@server.tool(name=name, description=description)
|
|
335
|
-
def list_models(app: str = "") -> str:
|
|
336
|
-
|
|
428
|
+
def list_models(app: str = "", workspace_root: str = "") -> str:
|
|
429
|
+
args: Dict[str, Any] = {"workspace_root": workspace_root}
|
|
430
|
+
if app:
|
|
431
|
+
args["app"] = app
|
|
432
|
+
return handler(args)
|
|
337
433
|
elif name == "describe_model":
|
|
338
434
|
@server.tool(name=name, description=description)
|
|
339
|
-
def describe_model(model: str) -> str:
|
|
340
|
-
return handler({"model": model})
|
|
435
|
+
def describe_model(model: str, workspace_root: str = "") -> str:
|
|
436
|
+
return handler({"model": model, "workspace_root": workspace_root})
|
|
341
437
|
elif name == "find_relations":
|
|
342
438
|
@server.tool(name=name, description=description)
|
|
343
|
-
def find_relations(model: str) -> str:
|
|
344
|
-
return handler({"model": model})
|
|
439
|
+
def find_relations(model: str, workspace_root: str = "") -> str:
|
|
440
|
+
return handler({"model": model, "workspace_root": workspace_root})
|
|
345
441
|
elif name == "cascade_preview":
|
|
346
442
|
@server.tool(name=name, description=description)
|
|
347
|
-
def cascade_preview(
|
|
348
|
-
|
|
443
|
+
def cascade_preview(
|
|
444
|
+
app_label: str, model_name: str, workspace_root: str = ""
|
|
445
|
+
) -> str:
|
|
446
|
+
return handler(
|
|
447
|
+
{
|
|
448
|
+
"app_label": app_label,
|
|
449
|
+
"model_name": model_name,
|
|
450
|
+
"workspace_root": workspace_root,
|
|
451
|
+
}
|
|
452
|
+
)
|
|
349
453
|
elif name == "er_diagram":
|
|
350
454
|
@server.tool(name=name, description=description)
|
|
351
|
-
def er_diagram() -> str:
|
|
352
|
-
return handler({})
|
|
455
|
+
def er_diagram(workspace_root: str = "") -> str:
|
|
456
|
+
return handler({"workspace_root": workspace_root})
|
|
353
457
|
elif name == "describe_migration_dependency":
|
|
354
458
|
@server.tool(name=name, description=description)
|
|
355
|
-
def describe_migration_dependency(
|
|
356
|
-
|
|
459
|
+
def describe_migration_dependency(
|
|
460
|
+
app_label: str, workspace_root: str = ""
|
|
461
|
+
) -> str:
|
|
462
|
+
return handler(
|
|
463
|
+
{"app_label": app_label, "workspace_root": workspace_root}
|
|
464
|
+
)
|
|
357
465
|
elif name == "suggest_indexes":
|
|
358
466
|
@server.tool(name=name, description=description)
|
|
359
|
-
def suggest_indexes(
|
|
360
|
-
|
|
467
|
+
def suggest_indexes(
|
|
468
|
+
app_label: str, model_name: str, workspace_root: str = ""
|
|
469
|
+
) -> str:
|
|
470
|
+
return handler(
|
|
471
|
+
{
|
|
472
|
+
"app_label": app_label,
|
|
473
|
+
"model_name": model_name,
|
|
474
|
+
"workspace_root": workspace_root,
|
|
475
|
+
}
|
|
476
|
+
)
|
|
361
477
|
elif name == "signal_graph":
|
|
362
478
|
@server.tool(name=name, description=description)
|
|
363
|
-
def signal_graph() -> str:
|
|
364
|
-
return handler({})
|
|
479
|
+
def signal_graph(workspace_root: str = "") -> str:
|
|
480
|
+
return handler({"workspace_root": workspace_root})
|
|
365
481
|
|
|
366
482
|
for name, spec in TOOLS.items():
|
|
367
483
|
_register(name, spec["description"], spec["handler"])
|
|
@@ -398,6 +398,11 @@ def _collect_defs(file_path: str, content: str) -> List[ParsedModel]:
|
|
|
398
398
|
result with definitions from other files before calling
|
|
399
399
|
``_resolve_and_filter``.
|
|
400
400
|
"""
|
|
401
|
+
# Strip leading BOM (U+FEFF) — Windows editors (Notepad, VS Code with
|
|
402
|
+
# certain settings) save UTF-8 files with a byte-order mark. Without this
|
|
403
|
+
# the first line becomes "class Foo..." and CLASS_RE fails to match.
|
|
404
|
+
if content.startswith(""):
|
|
405
|
+
content = content[1:]
|
|
401
406
|
# Expand tab characters to 4 spaces so the ``\s{indent}`` prefix used in
|
|
402
407
|
# FIELD_RE / BARE_FIELD_RE / META_ITEM_RE matches tab-indented code too.
|
|
403
408
|
# A single ``\t`` character otherwise contributes 1 to ``\s`` even though
|