braynix-dev-mcp 2.2.0__py3-none-any.whl

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.
app.py ADDED
@@ -0,0 +1,60 @@
1
+ """
2
+ Shared application state for dev-mcp.
3
+
4
+ This module creates the FastMCP instance and holds global state that is
5
+ shared across all tool, prompt, and resource modules.
6
+
7
+ All tool/prompt/resource modules import `mcp` from here to register their
8
+ decorators in-place. `server.py` then imports those modules to trigger
9
+ registration, and finally calls ``mcp.run()``.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ from pathlib import Path
15
+ from typing import Optional
16
+
17
+ from fastmcp import FastMCP
18
+
19
+ from context import ProjectContext
20
+
21
+ # ---------------------------------------------------------------------------
22
+ # FastMCP instance
23
+ # ---------------------------------------------------------------------------
24
+
25
+ mcp = FastMCP(
26
+ "dev-mcp",
27
+ instructions=(
28
+ "Project management MCP server for building, editing, and customizing "
29
+ "projects with AI. Maintains a persistent project context document that "
30
+ "captures tech stack, architecture, conventions, key files, active "
31
+ "tasks, goals, and architectural decisions. Provides prebuilt prompt "
32
+ "templates for common operations."
33
+ ),
34
+ )
35
+
36
+ # ---------------------------------------------------------------------------
37
+ # Shared global state
38
+ # ---------------------------------------------------------------------------
39
+
40
+ # Lazy-initialized project context
41
+ _project_context: ProjectContext | None = None
42
+
43
+
44
+ def load_context(project_root: str | Path | None = None) -> ProjectContext:
45
+ """Get (or create) the global project context instance."""
46
+ global _project_context
47
+ from security import validate_path
48
+
49
+ target_root = validate_path(project_root) if project_root else Path.cwd().resolve()
50
+
51
+ if _project_context is None or _project_context._root != target_root:
52
+ _project_context = ProjectContext(target_root)
53
+
54
+ return _project_context
55
+
56
+
57
+ def set_context(ctx: ProjectContext) -> None:
58
+ """Set the global project context (used by init_context tool)."""
59
+ global _project_context
60
+ _project_context = ctx
brain/__init__.py ADDED
@@ -0,0 +1,7 @@
1
+ """
2
+ Project Brain modules for dev-mcp.
3
+
4
+ Transforms the MCP from a generic developer tool into a
5
+ **Project Operating System** that enforces workflow, maintains
6
+ context across AI sessions, and prevents scope creep.
7
+ """
brain/dependency.py ADDED
@@ -0,0 +1,292 @@
1
+ """
2
+ Dependency Graph and Impact Analysis engine for Project Brain.
3
+
4
+ Scans imports across codebase to map connections and analyze edit impact.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import os
10
+ import re
11
+ from pathlib import Path
12
+ import json
13
+ from app import mcp
14
+ from security import validate_path
15
+
16
+ IGNORE_DIRS = {
17
+ ".git",
18
+ "node_modules",
19
+ ".venv",
20
+ "venv",
21
+ "__pycache__",
22
+ "dist",
23
+ "build",
24
+ ".project_brain",
25
+ ".agents",
26
+ }
27
+
28
+
29
+ def _resolve_js_ts_import(source_file: Path, import_str: str, root: Path) -> Path | None:
30
+ """Resolve JS/TS relative imports to actual file paths."""
31
+ if not import_str.startswith("."):
32
+ return None # External package
33
+
34
+ dirpath = source_file.parent
35
+ base_target = (dirpath / import_str).resolve()
36
+
37
+ # Try direct file resolutions
38
+ for ext in (".ts", ".tsx", ".js", ".jsx", ".json"):
39
+ candidate = base_target.with_suffix(ext)
40
+ if candidate.is_file():
41
+ return candidate.relative_to(root)
42
+
43
+ # Try directory resolutions (index files)
44
+ if base_target.is_dir():
45
+ for index_name in ("index.ts", "index.tsx", "index.js", "index.jsx"):
46
+ candidate = base_target / index_name
47
+ if candidate.is_file():
48
+ return candidate.relative_to(root)
49
+
50
+ return None
51
+
52
+
53
+ def _resolve_python_import(source_file: Path, import_str: str, root: Path) -> Path | None:
54
+ """Resolve Python absolute or relative imports to file paths."""
55
+ # Handle relative imports e.g., 'from .app import mcp' or 'from ..utils import X'
56
+ if import_str.startswith("."):
57
+ dots_count = len(import_str) - len(import_str.lstrip("."))
58
+ target_parts = import_str.lstrip(".").split(".")
59
+
60
+ dirpath = source_file.parent
61
+ for _ in range(dots_count - 1):
62
+ dirpath = dirpath.parent
63
+
64
+ base_target = dirpath
65
+ if target_parts and target_parts[0]:
66
+ base_target = base_target.joinpath(*target_parts)
67
+
68
+ candidate = base_target.with_suffix(".py")
69
+ if candidate.is_file():
70
+ return candidate.relative_to(root)
71
+
72
+ # Check folder __init__.py
73
+ candidate_init = base_target / "__init__.py"
74
+ if candidate_init.is_file():
75
+ return candidate_init.relative_to(root)
76
+
77
+ # Handle absolute imports relative to project root e.g., 'brain.todo_engine'
78
+ parts = import_str.split(".")
79
+ candidate = root.joinpath(*parts).with_suffix(".py")
80
+ if candidate.is_file():
81
+ return candidate.relative_to(root)
82
+
83
+ candidate_init = root.joinpath(*parts) / "__init__.py"
84
+ if candidate_init.is_file():
85
+ return candidate_init.relative_to(root)
86
+
87
+ return None
88
+
89
+
90
+ # In-memory mtime cache to avoid re-reading and re-parsing unchanged files
91
+ # Key: canonical file path, Value: (mtime, list_of_raw_imports)
92
+ _FILE_PARSE_CACHE: dict[str, tuple[float, list[str]]] = {}
93
+
94
+ # Graph-level cache: key is root string, value is (cache_timestamp, dependencies, dependents)
95
+ _ROOT_GRAPH_CACHE: dict[str, tuple[float, dict[str, list[str]], dict[str, list[str]]]] = {}
96
+
97
+
98
+ def _get_graph(root: Path, force_refresh: bool = False) -> tuple[dict[str, list[str]], dict[str, list[str]]]:
99
+ """Scan root files and build adjacency lists: dependencies (outgoing) and dependents (incoming).
100
+
101
+ Uses mtime-based in-memory caching to eliminate disk reads and regex overhead on unchanged files.
102
+ """
103
+ dependencies: dict[str, list[str]] = {}
104
+ dependents: dict[str, list[str]] = {}
105
+
106
+ js_import_re = re.compile(r'\b(?:import\s+.*\s+from\s+[\'"]([^\'"]+)[\'"]|require\([\'"]([^\'"]+)[\'"]\))')
107
+ py_import_re = re.compile(r'\b(?:import\s+([\w\.]+)|from\s+([\w\.]+)\s+import)')
108
+
109
+ all_files: list[tuple[Path, str, str, float]] = []
110
+
111
+ for dirpath, dirnames, filenames in os.walk(root):
112
+ dirnames[:] = [d for d in dirnames if d not in IGNORE_DIRS and not d.startswith(".")]
113
+
114
+ for name in filenames:
115
+ file_path = Path(dirpath) / name
116
+ suffix = file_path.suffix
117
+ if suffix not in (".py", ".ts", ".tsx", ".js", ".jsx"):
118
+ continue
119
+ rel = str(file_path.relative_to(root))
120
+ try:
121
+ mtime = file_path.stat().st_mtime
122
+ except OSError:
123
+ continue
124
+ all_files.append((file_path, rel, suffix, mtime))
125
+ dependencies[rel] = []
126
+ dependents[rel] = []
127
+
128
+ # Parse each file using mtime cache
129
+ for file_path, rel, suffix, mtime in all_files:
130
+ canonical_key = str(file_path.resolve())
131
+ cached_entry = _FILE_PARSE_CACHE.get(canonical_key)
132
+
133
+ if cached_entry is not None and cached_entry[0] == mtime and not force_refresh:
134
+ raw_imports = cached_entry[1]
135
+ else:
136
+ try:
137
+ content = file_path.read_text(encoding="utf-8", errors="ignore")
138
+ except Exception:
139
+ continue
140
+
141
+ raw_imports = []
142
+ if suffix in (".js", ".jsx", ".ts", ".tsx"):
143
+ for match in js_import_re.finditer(content):
144
+ imp = match.group(1) or match.group(2)
145
+ if imp:
146
+ raw_imports.append(imp)
147
+ elif suffix == ".py":
148
+ for match in py_import_re.finditer(content):
149
+ imp = match.group(1) or match.group(2)
150
+ if imp:
151
+ raw_imports.append(imp)
152
+
153
+ _FILE_PARSE_CACHE[canonical_key] = (mtime, raw_imports)
154
+
155
+ # Resolve imports to relative paths
156
+ targets: list[str] = []
157
+ if suffix in (".js", ".jsx", ".ts", ".tsx"):
158
+ for imp in raw_imports:
159
+ resolved = _resolve_js_ts_import(file_path, imp, root)
160
+ if resolved:
161
+ targets.append(str(resolved))
162
+ elif suffix == ".py":
163
+ for imp in raw_imports:
164
+ resolved = _resolve_python_import(file_path, imp, root)
165
+ if resolved:
166
+ targets.append(str(resolved))
167
+
168
+ # Save unique edges
169
+ for t in set(targets):
170
+ if t != rel and t in dependencies: # ensure it exists in project
171
+ dependencies[rel].append(t)
172
+ if rel not in dependents[t]:
173
+ dependents[t].append(rel)
174
+
175
+ return dependencies, dependents
176
+
177
+
178
+ @mcp.tool(
179
+ name="build_dependency_graph",
180
+ description="Scan files and construct a complete mapping of dependencies and references in the project.",
181
+ )
182
+ def build_dependency_graph(path: str = ".") -> str:
183
+ """Scan codebase imports and return dependency graph JSON."""
184
+ root = validate_path(path)
185
+ deps, deps_in = _get_graph(root)
186
+
187
+ total_edges = sum(len(v) for v in deps.values())
188
+ summary = {
189
+ "total_files": len(deps),
190
+ "total_connections": total_edges,
191
+ "graph": deps
192
+ }
193
+ return json.dumps(summary, indent=2)
194
+
195
+
196
+ @mcp.tool(
197
+ name="impact_analysis",
198
+ description="Calculate direct and transitive downstream files affected if a given file is modified.",
199
+ )
200
+ def impact_analysis(file_path: str, path: str = ".") -> str:
201
+ """Find all files that depend on the target file, directly or transitively (BFS traversal)."""
202
+ root = validate_path(path)
203
+ target_path = Path(file_path)
204
+
205
+ # Try to resolve relative to root or as is
206
+ resolved_target = target_path
207
+ if not resolved_target.is_file():
208
+ resolved_target = root / target_path
209
+
210
+ if not resolved_target.is_file():
211
+ return f"File '{file_path}' not found."
212
+
213
+ rel_target = str(resolved_target.relative_to(root))
214
+
215
+ dependencies, dependents = _get_graph(root)
216
+
217
+ if rel_target not in dependents:
218
+ return f"File '{rel_target}' is not tracked in the dependency graph (or has no suffix dependencies)."
219
+
220
+ # Traverse using BFS to find direct and transitive dependents
221
+ queue = [rel_target]
222
+ visited = set()
223
+ direct = dependents[rel_target]
224
+ transitive = []
225
+
226
+ while queue:
227
+ current = queue.pop(0)
228
+ for parent in dependents.get(current, []):
229
+ if parent not in visited and parent != rel_target:
230
+ visited.add(parent)
231
+ queue.append(parent)
232
+ if parent not in direct:
233
+ transitive.append(parent)
234
+
235
+ total_affected = len(direct) + len(transitive)
236
+
237
+ # Assess Blast Radius Risk Level
238
+ if total_affected == 0:
239
+ risk_badge = "๐ŸŸข LOW RISK (Isolated)"
240
+ elif total_affected <= 3:
241
+ risk_badge = "๐ŸŸก MEDIUM RISK (Manageable)"
242
+ else:
243
+ risk_badge = "๐Ÿ”ด HIGH RISK (Critical Surface Area)"
244
+
245
+ # Identify affected test files vs source files
246
+ all_affected = sorted(list(set(direct + transitive)))
247
+ test_files = [f for f in all_affected if "test" in f.lower() or "spec" in f.lower()]
248
+ source_files = [f for f in all_affected if f not in test_files]
249
+
250
+ report = [
251
+ f"# ๐Ÿšจ Downstream Impact Analysis: `{rel_target}`",
252
+ f"**Blast Radius Assessment:** {risk_badge}",
253
+ f"- **Direct Dependents (1st level):** {len(direct)} files",
254
+ f"- **Transitive Dependents (recursive):** {len(transitive)} files",
255
+ f"- **Total Affected Files:** {total_affected} files",
256
+ ""
257
+ ]
258
+
259
+ if direct:
260
+ report.append("### ๐Ÿ”— Direct Impact (Must update immediately when modifying exports)")
261
+ for d in sorted(direct):
262
+ icon = "๐Ÿงช" if d in test_files else "๐Ÿ“„"
263
+ report.append(f"- {icon} `{d}`")
264
+ report.append("")
265
+
266
+ if transitive:
267
+ report.append("### ๐ŸŒช๏ธ Transitive Impact (Verify against regressions)")
268
+ for t in sorted(transitive):
269
+ icon = "๐Ÿงช" if t in test_files else "๐Ÿ“„"
270
+ report.append(f"- {icon} `{t}`")
271
+ report.append("")
272
+
273
+ if test_files:
274
+ report.append("### ๐Ÿงช Affected Test Suites")
275
+ report.append("Run these test files to verify refactoring integrity:")
276
+ for tf in sorted(test_files):
277
+ report.append(f"- `{tf}`")
278
+ report.append("")
279
+
280
+ # Blast-Radius-First Refactoring Playbook
281
+ if total_affected > 0:
282
+ report.append("### ๐Ÿ“‹ Blast-Radius-First Refactoring Playbook")
283
+ report.append("1. **Contract Lock:** Review existing exports/signatures in `" + rel_target + "`.")
284
+ report.append("2. **Core Refactor:** Implement changes in `" + rel_target + "`.")
285
+ report.append(f"3. **Adapt Direct Callers:** Update the {len(direct)} direct dependent files.")
286
+ if test_files:
287
+ report.append(f"4. **Regression Run:** Execute tests on: " + ", ".join([f"`{t}`" for t in test_files[:4]]) + ("..." if len(test_files) > 4 else ""))
288
+ report.append("5. **Verify Transitive:** Validate that no downstream runtime contracts were broken.")
289
+ else:
290
+ report.append("โœ… **Isolation verified:** This file has no downstream dependents. It is safe to refactor without breaking other project modules.")
291
+
292
+ return "\n".join(report)
brain/guardian.py ADDED
@@ -0,0 +1,124 @@
1
+ """
2
+ Architecture Guardian for Project Brain.
3
+
4
+ Validates project files against allowed and forbidden technology stacks.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import os
10
+ import re
11
+ from pathlib import Path
12
+ from app import mcp, load_context as get_context
13
+ from security import validate_path
14
+
15
+ # Standard directories to skip when scanning
16
+ IGNORE_DIRS = {
17
+ ".git",
18
+ "node_modules",
19
+ ".venv",
20
+ "venv",
21
+ "__pycache__",
22
+ "dist",
23
+ "build",
24
+ ".project_brain",
25
+ ".agents",
26
+ "brain",
27
+ "tools",
28
+ }
29
+
30
+ # Standard file extensions to scan
31
+ SCAN_EXTENSIONS = {
32
+ ".py",
33
+ ".ts",
34
+ ".tsx",
35
+ ".js",
36
+ ".jsx",
37
+ ".go",
38
+ ".rs",
39
+ ".java",
40
+ ".cs",
41
+ ".cpp",
42
+ ".h",
43
+ }
44
+
45
+
46
+ @mcp.tool(
47
+ name="validate_architecture",
48
+ description="Scan the codebase and validate imports/technologies against the project's architectural guidelines and forbidden tools.",
49
+ )
50
+ def validate_architecture(path: str = ".") -> str:
51
+ """Validate imports in codebase files against forbidden and required lists in project context."""
52
+ root = validate_path(path)
53
+ ctx = get_context(root)
54
+
55
+ rules = ctx.data.get("architecture", {}).get("rules", {})
56
+ forbidden = rules.get("forbidden_technologies", [])
57
+ required = rules.get("required_technologies", [])
58
+
59
+ if not forbidden and not required:
60
+ # Provide sensible defaults if none are defined
61
+ forbidden = ["redux", "firebase"]
62
+ required = []
63
+
64
+ violations = []
65
+ scanned_files_count = 0
66
+
67
+ # Compile regexes for each forbidden tech
68
+ forbidden_patterns = {}
69
+ for tech in forbidden:
70
+ # Match imports: import * from 'tech', require('tech'), import tech, from tech import
71
+ js_import = re.compile(r'\b(?:import\s+.*\s+from\s+[\'"]' + re.escape(tech) + r'[\'"]|require\([\'"]' + re.escape(tech) + r'[\'"]\))', re.IGNORECASE)
72
+ py_import = re.compile(r'\b(?:import\s+' + re.escape(tech) + r'\b|from\s+' + re.escape(tech) + r'\s+import)', re.IGNORECASE)
73
+ forbidden_patterns[tech] = (js_import, py_import)
74
+
75
+ # Traverse directories
76
+ for dirpath, dirnames, filenames in os.walk(root):
77
+ # Exclude directories in-place
78
+ dirnames[:] = [d for d in dirnames if d not in IGNORE_DIRS and not d.startswith(".")]
79
+
80
+ for name in filenames:
81
+ file_path = Path(dirpath) / name
82
+ if file_path.suffix not in SCAN_EXTENSIONS:
83
+ continue
84
+
85
+ scanned_files_count += 1
86
+ rel_path = file_path.relative_to(root)
87
+
88
+ try:
89
+ content_lines = file_path.read_text(encoding="utf-8", errors="ignore").splitlines()
90
+ for line_idx, line in enumerate(content_lines, 1):
91
+ # Check for forbidden imports
92
+ for tech, (js_pat, py_pat) in forbidden_patterns.items():
93
+ if js_pat.search(line) or py_pat.search(line):
94
+ violations.append({
95
+ "file": str(rel_path),
96
+ "line": line_idx,
97
+ "tech": tech,
98
+ "content": line.strip()
99
+ })
100
+ except Exception:
101
+ continue
102
+
103
+ # Compile report
104
+ report = [
105
+ "# ๐Ÿ›ก๏ธ Architecture Guardian Report",
106
+ f"**Scanned Files:** {scanned_files_count}",
107
+ f"**Forbidden Technologies Checked:** {', '.join(forbidden) if forbidden else 'None'}",
108
+ ""
109
+ ]
110
+
111
+ if violations:
112
+ report.append("## ๐Ÿšจ Violations Detected!")
113
+ report.append("The following files imported or referenced forbidden libraries:")
114
+ report.append("")
115
+ for v in violations:
116
+ report.append(f"- **{v['file']}** (Line {v['line']}): Found `{v['tech']}`")
117
+ report.append(f" `{v['content']}`")
118
+ report.append("")
119
+ report.append("โš ๏ธ **Recommendation:** Replace forbidden imports with the approved stack (e.g. use Supabase instead of Firebase, or Zustand/Recoil instead of Redux).")
120
+ else:
121
+ report.append("## โœ… Verification Passed")
122
+ report.append("No architectural guidelines violations were found. All imports align with rules.")
123
+
124
+ return "\n".join(report)