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.
Files changed (45) hide show
  1. {django_orm_lens-1.2.6 → django_orm_lens-1.3.0}/PKG-INFO +22 -6
  2. {django_orm_lens-1.2.6 → django_orm_lens-1.3.0}/README.md +21 -5
  3. {django_orm_lens-1.2.6 → django_orm_lens-1.3.0}/django_orm_lens/mcp_server.py +211 -95
  4. {django_orm_lens-1.2.6 → django_orm_lens-1.3.0}/django_orm_lens/parser.py +5 -0
  5. django_orm_lens-1.3.0/django_orm_lens/workspace.py +348 -0
  6. {django_orm_lens-1.2.6 → django_orm_lens-1.3.0}/django_orm_lens.egg-info/PKG-INFO +22 -6
  7. {django_orm_lens-1.2.6 → django_orm_lens-1.3.0}/django_orm_lens.egg-info/SOURCES.txt +4 -0
  8. {django_orm_lens-1.2.6 → django_orm_lens-1.3.0}/pyproject.toml +1 -1
  9. django_orm_lens-1.3.0/tests/test_bom_prefix.py +40 -0
  10. django_orm_lens-1.3.0/tests/test_mcp_server.py +282 -0
  11. django_orm_lens-1.3.0/tests/test_mcp_workspace.py +364 -0
  12. {django_orm_lens-1.2.6 → django_orm_lens-1.3.0}/django_orm_lens/__init__.py +0 -0
  13. {django_orm_lens-1.2.6 → django_orm_lens-1.3.0}/django_orm_lens/cli.py +0 -0
  14. {django_orm_lens-1.2.6 → django_orm_lens-1.3.0}/django_orm_lens/diff.py +0 -0
  15. {django_orm_lens-1.2.6 → django_orm_lens-1.3.0}/django_orm_lens/formatters.py +0 -0
  16. {django_orm_lens-1.2.6 → django_orm_lens-1.3.0}/django_orm_lens/migrations_parser.py +0 -0
  17. {django_orm_lens-1.2.6 → django_orm_lens-1.3.0}/django_orm_lens/models.py +0 -0
  18. {django_orm_lens-1.2.6 → django_orm_lens-1.3.0}/django_orm_lens/py.typed +0 -0
  19. {django_orm_lens-1.2.6 → django_orm_lens-1.3.0}/django_orm_lens/query_analyzer.py +0 -0
  20. {django_orm_lens-1.2.6 → django_orm_lens-1.3.0}/django_orm_lens/signals_parser.py +0 -0
  21. {django_orm_lens-1.2.6 → django_orm_lens-1.3.0}/django_orm_lens.egg-info/dependency_links.txt +0 -0
  22. {django_orm_lens-1.2.6 → django_orm_lens-1.3.0}/django_orm_lens.egg-info/entry_points.txt +0 -0
  23. {django_orm_lens-1.2.6 → django_orm_lens-1.3.0}/django_orm_lens.egg-info/requires.txt +0 -0
  24. {django_orm_lens-1.2.6 → django_orm_lens-1.3.0}/django_orm_lens.egg-info/top_level.txt +0 -0
  25. {django_orm_lens-1.2.6 → django_orm_lens-1.3.0}/setup.cfg +0 -0
  26. {django_orm_lens-1.2.6 → django_orm_lens-1.3.0}/tests/test_abstract_and_auth_user.py +0 -0
  27. {django_orm_lens-1.2.6 → django_orm_lens-1.3.0}/tests/test_aliased_module_and_third_party_fields.py +0 -0
  28. {django_orm_lens-1.2.6 → django_orm_lens-1.3.0}/tests/test_cli_hint.py +0 -0
  29. {django_orm_lens-1.2.6 → django_orm_lens-1.3.0}/tests/test_cli_subcommands_smoke.py +0 -0
  30. {django_orm_lens-1.2.6 → django_orm_lens-1.3.0}/tests/test_cli_verbose.py +0 -0
  31. {django_orm_lens-1.2.6 → django_orm_lens-1.3.0}/tests/test_cross_module_bases.py +0 -0
  32. {django_orm_lens-1.2.6 → django_orm_lens-1.3.0}/tests/test_diff_command.py +0 -0
  33. {django_orm_lens-1.2.6 → django_orm_lens-1.3.0}/tests/test_golden_fixtures.py +0 -0
  34. {django_orm_lens-1.2.6 → django_orm_lens-1.3.0}/tests/test_kwarg_order_and_auth_user.py +0 -0
  35. {django_orm_lens-1.2.6 → django_orm_lens-1.3.0}/tests/test_list_command.py +0 -0
  36. {django_orm_lens-1.2.6 → django_orm_lens-1.3.0}/tests/test_migration_risk.py +0 -0
  37. {django_orm_lens-1.2.6 → django_orm_lens-1.3.0}/tests/test_migrations_parser.py +0 -0
  38. {django_orm_lens-1.2.6 → django_orm_lens-1.3.0}/tests/test_nplusone_detector.py +0 -0
  39. {django_orm_lens-1.2.6 → django_orm_lens-1.3.0}/tests/test_parity.py +0 -0
  40. {django_orm_lens-1.2.6 → django_orm_lens-1.3.0}/tests/test_pep526_type_hints.py +0 -0
  41. {django_orm_lens-1.2.6 → django_orm_lens-1.3.0}/tests/test_pep695_generic_classes.py +0 -0
  42. {django_orm_lens-1.2.6 → django_orm_lens-1.3.0}/tests/test_query_analyzer.py +0 -0
  43. {django_orm_lens-1.2.6 → django_orm_lens-1.3.0}/tests/test_scan_root.py +0 -0
  44. {django_orm_lens-1.2.6 → django_orm_lens-1.3.0}/tests/test_signals_parser.py +0 -0
  45. {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.2.6
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
- 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,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
- 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
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
- def _workspace_root() -> str:
38
- return os.environ.get("DJANGO_ORM_LENS_ROOT") or os.getcwd()
45
+ # ---------------------------------------------------------------------------
46
+ # Resolution helper — every tool starts here
47
+ # ---------------------------------------------------------------------------
39
48
 
40
49
 
41
- _INDEX_CACHE: Dict[str, Tuple[WorkspaceIndex, int]] = {}
42
- _CACHE_TTL_MS = 30_000
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
- 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
- )
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
- 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)"
66
+ # ---------------------------------------------------------------------------
67
+ # Model lookup helpers — shared by describe_model / find_relations / cascade
68
+ # ---------------------------------------------------------------------------
90
69
 
91
70
 
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).
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
- idx = _get_index()
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
- idx = _get_index()
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
- idx = _get_index()
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(_args: Dict[str, Any]) -> str:
214
- idx = _get_index()
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, _workspace_root())
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 = _get_index()
246
- result = _suggest_indexes(app_label, model_name, _workspace_root(), index=index)
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(_args: Dict[str, Any]) -> str:
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
- index = _get_index()
259
- result = _signal_graph(_workspace_root(), index=index)
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": "List every Django app in the workspace with model counts.",
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": "Flat list of app.Model. Optional 'app' filter.",
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": "Full JSON detail for one model: fields, relations, Meta, base classes, file path.",
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": "Outbound (this model → others) and inbound (others → this) relations. Inbound entries include on_delete behavior.",
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": "Blast radius of deleting a row: inbound relations grouped by on_delete behavior (cascade_kills, set_null, protected).",
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": "Emit a Mermaid erDiagram for the whole workspace.",
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": "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.",
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": "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.",
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": "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.",
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
- return handler({"app": app} if app else {})
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(app_label: str, model_name: str) -> str:
348
- return handler({"app_label": app_label, "model_name": model_name})
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(app_label: str) -> str:
356
- return handler({"app_label": app_label})
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(app_label: str, model_name: str) -> str:
360
- return handler({"app_label": app_label, "model_name": model_name})
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