django-orm-lens 1.2.7__tar.gz → 1.4.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.
Files changed (45) hide show
  1. {django_orm_lens-1.2.7 → django_orm_lens-1.4.0}/PKG-INFO +22 -6
  2. {django_orm_lens-1.2.7 → django_orm_lens-1.4.0}/README.md +21 -5
  3. {django_orm_lens-1.2.7 → django_orm_lens-1.4.0}/django_orm_lens/mcp_server.py +245 -95
  4. django_orm_lens-1.4.0/django_orm_lens/workspace.py +348 -0
  5. {django_orm_lens-1.2.7 → django_orm_lens-1.4.0}/django_orm_lens.egg-info/PKG-INFO +22 -6
  6. {django_orm_lens-1.2.7 → django_orm_lens-1.4.0}/django_orm_lens.egg-info/SOURCES.txt +3 -0
  7. {django_orm_lens-1.2.7 → django_orm_lens-1.4.0}/pyproject.toml +1 -1
  8. django_orm_lens-1.4.0/tests/test_mcp_server.py +315 -0
  9. django_orm_lens-1.4.0/tests/test_mcp_workspace.py +364 -0
  10. {django_orm_lens-1.2.7 → django_orm_lens-1.4.0}/django_orm_lens/__init__.py +0 -0
  11. {django_orm_lens-1.2.7 → django_orm_lens-1.4.0}/django_orm_lens/cli.py +0 -0
  12. {django_orm_lens-1.2.7 → django_orm_lens-1.4.0}/django_orm_lens/diff.py +0 -0
  13. {django_orm_lens-1.2.7 → django_orm_lens-1.4.0}/django_orm_lens/formatters.py +0 -0
  14. {django_orm_lens-1.2.7 → django_orm_lens-1.4.0}/django_orm_lens/migrations_parser.py +0 -0
  15. {django_orm_lens-1.2.7 → django_orm_lens-1.4.0}/django_orm_lens/models.py +0 -0
  16. {django_orm_lens-1.2.7 → django_orm_lens-1.4.0}/django_orm_lens/parser.py +0 -0
  17. {django_orm_lens-1.2.7 → django_orm_lens-1.4.0}/django_orm_lens/py.typed +0 -0
  18. {django_orm_lens-1.2.7 → django_orm_lens-1.4.0}/django_orm_lens/query_analyzer.py +0 -0
  19. {django_orm_lens-1.2.7 → django_orm_lens-1.4.0}/django_orm_lens/signals_parser.py +0 -0
  20. {django_orm_lens-1.2.7 → django_orm_lens-1.4.0}/django_orm_lens.egg-info/dependency_links.txt +0 -0
  21. {django_orm_lens-1.2.7 → django_orm_lens-1.4.0}/django_orm_lens.egg-info/entry_points.txt +0 -0
  22. {django_orm_lens-1.2.7 → django_orm_lens-1.4.0}/django_orm_lens.egg-info/requires.txt +0 -0
  23. {django_orm_lens-1.2.7 → django_orm_lens-1.4.0}/django_orm_lens.egg-info/top_level.txt +0 -0
  24. {django_orm_lens-1.2.7 → django_orm_lens-1.4.0}/setup.cfg +0 -0
  25. {django_orm_lens-1.2.7 → django_orm_lens-1.4.0}/tests/test_abstract_and_auth_user.py +0 -0
  26. {django_orm_lens-1.2.7 → django_orm_lens-1.4.0}/tests/test_aliased_module_and_third_party_fields.py +0 -0
  27. {django_orm_lens-1.2.7 → django_orm_lens-1.4.0}/tests/test_bom_prefix.py +0 -0
  28. {django_orm_lens-1.2.7 → django_orm_lens-1.4.0}/tests/test_cli_hint.py +0 -0
  29. {django_orm_lens-1.2.7 → django_orm_lens-1.4.0}/tests/test_cli_subcommands_smoke.py +0 -0
  30. {django_orm_lens-1.2.7 → django_orm_lens-1.4.0}/tests/test_cli_verbose.py +0 -0
  31. {django_orm_lens-1.2.7 → django_orm_lens-1.4.0}/tests/test_cross_module_bases.py +0 -0
  32. {django_orm_lens-1.2.7 → django_orm_lens-1.4.0}/tests/test_diff_command.py +0 -0
  33. {django_orm_lens-1.2.7 → django_orm_lens-1.4.0}/tests/test_golden_fixtures.py +0 -0
  34. {django_orm_lens-1.2.7 → django_orm_lens-1.4.0}/tests/test_kwarg_order_and_auth_user.py +0 -0
  35. {django_orm_lens-1.2.7 → django_orm_lens-1.4.0}/tests/test_list_command.py +0 -0
  36. {django_orm_lens-1.2.7 → django_orm_lens-1.4.0}/tests/test_migration_risk.py +0 -0
  37. {django_orm_lens-1.2.7 → django_orm_lens-1.4.0}/tests/test_migrations_parser.py +0 -0
  38. {django_orm_lens-1.2.7 → django_orm_lens-1.4.0}/tests/test_nplusone_detector.py +0 -0
  39. {django_orm_lens-1.2.7 → django_orm_lens-1.4.0}/tests/test_parity.py +0 -0
  40. {django_orm_lens-1.2.7 → django_orm_lens-1.4.0}/tests/test_pep526_type_hints.py +0 -0
  41. {django_orm_lens-1.2.7 → django_orm_lens-1.4.0}/tests/test_pep695_generic_classes.py +0 -0
  42. {django_orm_lens-1.2.7 → django_orm_lens-1.4.0}/tests/test_query_analyzer.py +0 -0
  43. {django_orm_lens-1.2.7 → django_orm_lens-1.4.0}/tests/test_scan_root.py +0 -0
  44. {django_orm_lens-1.2.7 → django_orm_lens-1.4.0}/tests/test_signals_parser.py +0 -0
  45. {django_orm_lens-1.2.7 → django_orm_lens-1.4.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.2.7
3
+ Version: 1.4.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
- By default the server scans the current working directory. Override with
132
- `DJANGO_ORM_LENS_ROOT=/abs/path/to/project`.
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
- **Aider / Continue.dev / Zed / any MCP client** — same shape, the tool is
150
- generic. Point `command` at the installed `django-orm-lens-mcp` binary and set
151
- the workspace root via env.
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
- By default the server scans the current working directory. Override with
83
- `DJANGO_ORM_LENS_ROOT=/abs/path/to/project`.
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
- **Aider / Continue.dev / Zed / any MCP client** — same shape, the tool is
101
- generic. Point `command` at the installed `django-orm-lens-mcp` binary and set
102
- the workspace root via env.
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,67 @@ 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
- Config: workspace root taken from ``$DJANGO_ORM_LENS_ROOT`` if set, else ``cwd``.
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 time
27
- from typing import Any, Dict, List, Tuple
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
40
+ from .query_analyzer import scan_for_nplusone as _scan_for_nplusone
33
41
  from .query_analyzer import suggest_indexes as _suggest_indexes
34
42
  from .signals_parser import signal_graph as _signal_graph
43
+ from .workspace import WorkspaceError, get_index, resolve_workspace
35
44
 
36
45
 
37
- def _workspace_root() -> str:
38
- return os.environ.get("DJANGO_ORM_LENS_ROOT") or os.getcwd()
46
+ # ---------------------------------------------------------------------------
47
+ # Resolution helper — every tool starts here
48
+ # ---------------------------------------------------------------------------
39
49
 
40
50
 
41
- _INDEX_CACHE: Dict[str, Tuple[WorkspaceIndex, int]] = {}
42
- _CACHE_TTL_MS = 30_000
51
+ def _resolve(args: Dict[str, Any]) -> Union[Path, WorkspaceError]:
52
+ """Extract ``workspace_root`` from tool args and delegate to workspace.py.
43
53
 
54
+ Centralised so every tool has identical resolution semantics. Empty string,
55
+ missing key, and ``None`` all fall through to env var / cwd — same as
56
+ calling the tool with no argument at all.
57
+ """
58
+ raw = (args or {}).get("workspace_root") or ""
59
+ return resolve_workspace(raw)
44
60
 
45
- def _get_index() -> WorkspaceIndex:
46
- """Cache-backed workspace index — 30s TTL, keyed by workspace root.
47
61
 
48
- Agents typically fire multiple MCP tool calls back-to-back (list_apps →
49
- describe_model → find_relations). Without a cache each call re-walked the
50
- filesystem and re-parsed every models.py. TTL is short enough that manual
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
- )
62
+ def _error_json(err: WorkspaceError) -> str:
63
+ """Serialize a :class:`WorkspaceError` as an indent-2 JSON envelope."""
64
+ return json.dumps(err.to_dict(), indent=2)
72
65
 
73
66
 
74
- def _tool_list_models(args: Dict[str, Any]) -> str:
75
- idx = _get_index()
76
- only_app = (args or {}).get("app")
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)"
67
+ # ---------------------------------------------------------------------------
68
+ # Model lookup helpers — shared by describe_model / find_relations / cascade
69
+ # ---------------------------------------------------------------------------
90
70
 
91
71
 
92
- def _find(idx, ref: str):
93
- # Delegates to shared find_model so MCP and CLI lookup semantics stay
94
- # in sync (they previously drifted — hence the shared helper).
72
+ def _find(idx: WorkspaceIndex, ref: str):
73
+ """Delegate to shared ``find_model`` so MCP and CLI lookup semantics stay
74
+ in sync (they previously drifted — hence the shared helper)."""
95
75
  return find_model(idx, ref)
96
76
 
97
77
 
98
- def _workspace_user_model(idx):
78
+ def _workspace_user_model(idx: WorkspaceIndex):
99
79
  """Return ``(app_name, model)`` for the workspace's Django User model.
100
80
 
101
81
  Thin wrapper around :func:`models.find_user_model` that preserves the
@@ -111,7 +91,7 @@ def _workspace_user_model(idx):
111
91
  return None, user_model
112
92
 
113
93
 
114
- def _rel_matches_target(related_model, target_name, user_model):
94
+ def _rel_matches_target(related_model, target_name, user_model) -> bool:
115
95
  """True iff ``related_model`` string points at ``target_name``. Handles
116
96
  ``settings.AUTH_USER_MODEL`` via :func:`models.resolve_related_tail`."""
117
97
  if not related_model:
@@ -120,8 +100,51 @@ def _rel_matches_target(related_model, target_name, user_model):
120
100
  return resolve_related_tail(related_model, user_name) == target_name
121
101
 
122
102
 
103
+ # ---------------------------------------------------------------------------
104
+ # Nine tool handlers
105
+ # ---------------------------------------------------------------------------
106
+
107
+
108
+ def _tool_list_apps(args: Dict[str, Any]) -> str:
109
+ ws = _resolve(args)
110
+ if isinstance(ws, WorkspaceError):
111
+ return _error_json(ws)
112
+ idx = get_index(ws)
113
+ return json.dumps(
114
+ [
115
+ {"name": a.name, "path": a.path, "models": len(a.models)}
116
+ for a in idx.apps
117
+ ],
118
+ indent=2,
119
+ )
120
+
121
+
122
+ def _tool_list_models(args: Dict[str, Any]) -> str:
123
+ ws = _resolve(args)
124
+ if isinstance(ws, WorkspaceError):
125
+ return _error_json(ws)
126
+ idx = get_index(ws)
127
+ only_app = (args or {}).get("app")
128
+ if only_app and not any(a.name == only_app for a in idx.apps):
129
+ available = ", ".join(sorted(a.name for a in idx.apps)) or "(none)"
130
+ return (
131
+ f"(app '{only_app}' not found in workspace; "
132
+ f"available apps: {available})"
133
+ )
134
+ rows: List[str] = []
135
+ for app in idx.apps:
136
+ if only_app and app.name != only_app:
137
+ continue
138
+ for m in app.models:
139
+ rows.append(f"{app.name}.{m.name}")
140
+ return "\n".join(rows) if rows else "(no models)"
141
+
142
+
123
143
  def _tool_describe_model(args: Dict[str, Any]) -> str:
124
- idx = _get_index()
144
+ ws = _resolve(args)
145
+ if isinstance(ws, WorkspaceError):
146
+ return _error_json(ws)
147
+ idx = get_index(ws)
125
148
  ref = (args or {}).get("model", "")
126
149
  m = _find(idx, ref)
127
150
  if not m:
@@ -130,7 +153,10 @@ def _tool_describe_model(args: Dict[str, Any]) -> str:
130
153
 
131
154
 
132
155
  def _tool_find_relations(args: Dict[str, Any]) -> str:
133
- idx = _get_index()
156
+ ws = _resolve(args)
157
+ if isinstance(ws, WorkspaceError):
158
+ return _error_json(ws)
159
+ idx = get_index(ws)
134
160
  ref = (args or {}).get("model", "")
135
161
  m = _find(idx, ref)
136
162
  if not m:
@@ -167,7 +193,10 @@ def _tool_find_relations(args: Dict[str, Any]) -> str:
167
193
 
168
194
 
169
195
  def _tool_cascade_preview(args: Dict[str, Any]) -> str:
170
- idx = _get_index()
196
+ ws = _resolve(args)
197
+ if isinstance(ws, WorkspaceError):
198
+ return _error_json(ws)
199
+ idx = get_index(ws)
171
200
  app_label = (args or {}).get("app_label", "")
172
201
  model_name = (args or {}).get("model_name", "")
173
202
  ref = f"{app_label}.{model_name}" if app_label else model_name
@@ -210,8 +239,11 @@ def _tool_cascade_preview(args: Dict[str, Any]) -> str:
210
239
  return json.dumps(out, indent=2)
211
240
 
212
241
 
213
- def _tool_er_diagram(_args: Dict[str, Any]) -> str:
214
- idx = _get_index()
242
+ def _tool_er_diagram(args: Dict[str, Any]) -> str:
243
+ ws = _resolve(args)
244
+ if isinstance(ws, WorkspaceError):
245
+ return _error_json(ws)
246
+ idx = get_index(ws)
215
247
  return _build_mermaid(idx)
216
248
 
217
249
 
@@ -222,10 +254,13 @@ def _tool_describe_migration_dependency(args: Dict[str, Any]) -> str:
222
254
  Unique to django-orm-lens across the MCP + Django-graph tool ecosystem
223
255
  (competitors require a running Django process).
224
256
  """
257
+ ws = _resolve(args)
258
+ if isinstance(ws, WorkspaceError):
259
+ return _error_json(ws)
225
260
  app_label = (args or {}).get("app_label", "")
226
261
  if not app_label:
227
262
  raise ValueError("app_label is required")
228
- result = describe_migration_dependency(app_label, _workspace_root())
263
+ result = describe_migration_dependency(app_label, str(ws))
229
264
  return json.dumps(result, indent=2)
230
265
 
231
266
 
@@ -238,16 +273,19 @@ def _tool_suggest_indexes(args: Dict[str, Any]) -> str:
238
273
  Cross-references with the model's existing ``Meta.indexes`` so
239
274
  already-covered combos are not re-proposed.
240
275
  """
276
+ ws = _resolve(args)
277
+ if isinstance(ws, WorkspaceError):
278
+ return _error_json(ws)
241
279
  app_label = (args or {}).get("app_label", "")
242
280
  model_name = (args or {}).get("model_name", "")
243
281
  if not app_label or not model_name:
244
282
  raise ValueError("app_label and model_name are required")
245
- index = _get_index()
246
- result = _suggest_indexes(app_label, model_name, _workspace_root(), index=index)
283
+ index = get_index(ws)
284
+ result = _suggest_indexes(app_label, model_name, str(ws), index=index)
247
285
  return json.dumps(result, indent=2)
248
286
 
249
287
 
250
- def _tool_signal_graph(_args: Dict[str, Any]) -> str:
288
+ def _tool_signal_graph(args: Dict[str, Any]) -> str:
251
289
  """Return the sender→signal→handler DAG for the workspace.
252
290
 
253
291
  Static AST parse of every ``@receiver(...)`` decorator plus every
@@ -255,51 +293,128 @@ def _tool_signal_graph(_args: Dict[str, Any]) -> str:
255
293
  ``.send(...)`` call-sites. Surfaces cross-model side effects and
256
294
  orphan handlers whose sender model isn't in the workspace.
257
295
  """
258
- index = _get_index()
259
- result = _signal_graph(_workspace_root(), index=index)
296
+ ws = _resolve(args)
297
+ if isinstance(ws, WorkspaceError):
298
+ return _error_json(ws)
299
+ index = get_index(ws)
300
+ result = _signal_graph(str(ws), index=index)
260
301
  return json.dumps(result, indent=2)
261
302
 
262
303
 
304
+ def _tool_nplusone_scan(args: Dict[str, Any]) -> str:
305
+ """Static scan for Django ORM N+1 anti-patterns across the workspace.
306
+
307
+ Walks every ``.py`` file, finds ``for ... in <queryset>:`` loops, and
308
+ flags attribute-chain accesses against the loop target that touch a
309
+ related object (FK / O2O / M2M / reverse FK) without a matching
310
+ ``select_related`` / ``prefetch_related`` clause on the source queryset.
311
+ Uses the parsed WorkspaceIndex to classify relations; falls back to
312
+ a schema-less heuristic when a model is unknown. Zero-runtime, no DB.
313
+ """
314
+ ws = _resolve(args)
315
+ if isinstance(ws, WorkspaceError):
316
+ return _error_json(ws)
317
+ index = get_index(ws)
318
+ findings = _scan_for_nplusone(str(ws), index)
319
+ return json.dumps([f.to_dict() for f in findings], indent=2)
320
+
321
+
322
+ # ---------------------------------------------------------------------------
323
+ # Tool registry — descriptions ship in tools/list so agents see them
324
+ # ---------------------------------------------------------------------------
325
+
326
+ _WORKSPACE_HINT = (
327
+ " Optional 'workspace_root' argument overrides the DJANGO_ORM_LENS_ROOT "
328
+ "env var and current directory — pass the absolute path to your Django "
329
+ "project root (the directory containing manage.py)."
330
+ )
331
+
263
332
  TOOLS = {
264
333
  "list_apps": {
265
334
  "handler": _tool_list_apps,
266
- "description": "List every Django app in the workspace with model counts.",
335
+ "description": (
336
+ "List every Django app in the workspace with model counts." + _WORKSPACE_HINT
337
+ ),
267
338
  },
268
339
  "list_models": {
269
340
  "handler": _tool_list_models,
270
- "description": "Flat list of app.Model. Optional 'app' filter.",
341
+ "description": (
342
+ "Flat list of app.Model. Optional 'app' filter." + _WORKSPACE_HINT
343
+ ),
271
344
  },
272
345
  "describe_model": {
273
346
  "handler": _tool_describe_model,
274
- "description": "Full JSON detail for one model: fields, relations, Meta, base classes, file path.",
347
+ "description": (
348
+ "Full JSON detail for one model: fields, relations, Meta, base "
349
+ "classes, file path." + _WORKSPACE_HINT
350
+ ),
275
351
  },
276
352
  "find_relations": {
277
353
  "handler": _tool_find_relations,
278
- "description": "Outbound (this model → others) and inbound (others → this) relations. Inbound entries include on_delete behavior.",
354
+ "description": (
355
+ "Outbound (this model -> others) and inbound (others -> this) "
356
+ "relations. Inbound entries include on_delete behavior." + _WORKSPACE_HINT
357
+ ),
279
358
  },
280
359
  "cascade_preview": {
281
360
  "handler": _tool_cascade_preview,
282
- "description": "Blast radius of deleting a row: inbound relations grouped by on_delete behavior (cascade_kills, set_null, protected).",
361
+ "description": (
362
+ "Blast radius of deleting a row: inbound relations grouped by "
363
+ "on_delete behavior (cascade_kills, set_null, protected)." + _WORKSPACE_HINT
364
+ ),
283
365
  },
284
366
  "er_diagram": {
285
367
  "handler": _tool_er_diagram,
286
- "description": "Emit a Mermaid erDiagram for the whole workspace.",
368
+ "description": (
369
+ "Emit a Mermaid erDiagram for the whole workspace." + _WORKSPACE_HINT
370
+ ),
287
371
  },
288
372
  "describe_migration_dependency": {
289
373
  "handler": _tool_describe_migration_dependency,
290
- "description": "Return per-app migration DAG (dependencies, roots, leaves, cross-app deps) from static AST parse of migrations/*.py. No Django boot, no DB. Lets agents debug migration conflicts safely.",
374
+ "description": (
375
+ "Return per-app migration DAG (dependencies, roots, leaves, "
376
+ "cross-app deps) from static AST parse of migrations/*.py. No "
377
+ "Django boot, no DB. Lets agents debug migration conflicts "
378
+ "safely." + _WORKSPACE_HINT
379
+ ),
291
380
  },
292
381
  "suggest_indexes": {
293
382
  "handler": _tool_suggest_indexes,
294
- "description": "Static analysis of every filter/exclude/get/order_by/aggregate usage of a model across the workspace. Returns field-usage frequency and proposes Meta.indexes covering entries. Zero-runtime, no DB, no Django boot.",
383
+ "description": (
384
+ "Static analysis of every filter/exclude/get/order_by/aggregate "
385
+ "usage of a model across the workspace. Returns field-usage "
386
+ "frequency and proposes Meta.indexes covering entries. "
387
+ "Zero-runtime, no DB, no Django boot." + _WORKSPACE_HINT
388
+ ),
295
389
  },
296
390
  "signal_graph": {
297
391
  "handler": _tool_signal_graph,
298
- "description": "Parse every @receiver() decorator and Signal() definition in the workspace. Returns sender→signal→handler DAG plus custom-signal send-sites and orphan handlers. Zero-runtime, no DB, no Django boot.",
392
+ "description": (
393
+ "Parse every @receiver() decorator and Signal() definition in "
394
+ "the workspace. Returns sender->signal->handler DAG plus custom-"
395
+ "signal send-sites and orphan handlers. Zero-runtime, no DB, no "
396
+ "Django boot." + _WORKSPACE_HINT
397
+ ),
398
+ },
399
+ "nplusone_scan": {
400
+ "handler": _tool_nplusone_scan,
401
+ "description": (
402
+ "Static scan for Django ORM N+1 anti-patterns. Walks every .py "
403
+ "file, finds 'for x in <queryset>:' loops that touch related "
404
+ "objects (FK/O2O/M2M/reverse) without matching select_related "
405
+ "or prefetch_related. Returns findings with file, line, "
406
+ "queryset_var, accessed relations, suggested_fix, and confidence "
407
+ "(high/medium). Zero-runtime, no DB, no Django boot." + _WORKSPACE_HINT
408
+ ),
299
409
  },
300
410
  }
301
411
 
302
412
 
413
+ # ---------------------------------------------------------------------------
414
+ # Server bootstrap — lazy-imports mcp so the base install stays zero-dep
415
+ # ---------------------------------------------------------------------------
416
+
417
+
303
418
  def main() -> int:
304
419
  """Start the MCP stdio server. Lazy-imports the ``mcp`` package."""
305
420
  try:
@@ -319,49 +434,84 @@ def main() -> int:
319
434
  # surface server stderr (Cursor, Aider, mcp-inspector) show this once
320
435
  # per session; silent clients ignore it. Zero effect on tool protocol.
321
436
  print(
322
- "django-orm-lens MCP ready — "
437
+ "django-orm-lens MCP ready - "
323
438
  "if it saves your agent time, a star helps: "
324
439
  "https://github.com/FROWNINGdev/django-orm-lens",
325
440
  file=sys.stderr,
326
441
  )
327
442
 
328
443
  def _register(name: str, description: str, handler):
444
+ """Register one tool.
445
+
446
+ Every tool declares ``workspace_root: str = ""`` in its Python
447
+ signature so FastMCP includes it in the ``inputSchema`` served to
448
+ ``tools/list``. Agents that pass the argument reach the handler with
449
+ the value; agents that omit it fall through to env var / cwd via the
450
+ resolution chain in :mod:`django_orm_lens.workspace`.
451
+ """
329
452
  if name == "list_apps":
330
453
  @server.tool(name=name, description=description)
331
- def list_apps() -> str:
332
- return handler({})
454
+ def list_apps(workspace_root: str = "") -> str:
455
+ return handler({"workspace_root": workspace_root})
333
456
  elif name == "list_models":
334
457
  @server.tool(name=name, description=description)
335
- def list_models(app: str = "") -> str:
336
- return handler({"app": app} if app else {})
458
+ def list_models(app: str = "", workspace_root: str = "") -> str:
459
+ args: Dict[str, Any] = {"workspace_root": workspace_root}
460
+ if app:
461
+ args["app"] = app
462
+ return handler(args)
337
463
  elif name == "describe_model":
338
464
  @server.tool(name=name, description=description)
339
- def describe_model(model: str) -> str:
340
- return handler({"model": model})
465
+ def describe_model(model: str, workspace_root: str = "") -> str:
466
+ return handler({"model": model, "workspace_root": workspace_root})
341
467
  elif name == "find_relations":
342
468
  @server.tool(name=name, description=description)
343
- def find_relations(model: str) -> str:
344
- return handler({"model": model})
469
+ def find_relations(model: str, workspace_root: str = "") -> str:
470
+ return handler({"model": model, "workspace_root": workspace_root})
345
471
  elif name == "cascade_preview":
346
472
  @server.tool(name=name, description=description)
347
- def cascade_preview(app_label: str, model_name: str) -> str:
348
- return handler({"app_label": app_label, "model_name": model_name})
473
+ def cascade_preview(
474
+ app_label: str, model_name: str, workspace_root: str = ""
475
+ ) -> str:
476
+ return handler(
477
+ {
478
+ "app_label": app_label,
479
+ "model_name": model_name,
480
+ "workspace_root": workspace_root,
481
+ }
482
+ )
349
483
  elif name == "er_diagram":
350
484
  @server.tool(name=name, description=description)
351
- def er_diagram() -> str:
352
- return handler({})
485
+ def er_diagram(workspace_root: str = "") -> str:
486
+ return handler({"workspace_root": workspace_root})
353
487
  elif name == "describe_migration_dependency":
354
488
  @server.tool(name=name, description=description)
355
- def describe_migration_dependency(app_label: str) -> str:
356
- return handler({"app_label": app_label})
489
+ def describe_migration_dependency(
490
+ app_label: str, workspace_root: str = ""
491
+ ) -> str:
492
+ return handler(
493
+ {"app_label": app_label, "workspace_root": workspace_root}
494
+ )
357
495
  elif name == "suggest_indexes":
358
496
  @server.tool(name=name, description=description)
359
- def suggest_indexes(app_label: str, model_name: str) -> str:
360
- return handler({"app_label": app_label, "model_name": model_name})
497
+ def suggest_indexes(
498
+ app_label: str, model_name: str, workspace_root: str = ""
499
+ ) -> str:
500
+ return handler(
501
+ {
502
+ "app_label": app_label,
503
+ "model_name": model_name,
504
+ "workspace_root": workspace_root,
505
+ }
506
+ )
361
507
  elif name == "signal_graph":
362
508
  @server.tool(name=name, description=description)
363
- def signal_graph() -> str:
364
- return handler({})
509
+ def signal_graph(workspace_root: str = "") -> str:
510
+ return handler({"workspace_root": workspace_root})
511
+ elif name == "nplusone_scan":
512
+ @server.tool(name=name, description=description)
513
+ def nplusone_scan(workspace_root: str = "") -> str:
514
+ return handler({"workspace_root": workspace_root})
365
515
 
366
516
  for name, spec in TOOLS.items():
367
517
  _register(name, spec["description"], spec["handler"])