Files
2025-03-23 22:18:10 -05:00

880 lines
30 KiB
Python

"""Project analysis tools for MCP Claude Code.
This module provides tools for analyzing project structure and dependencies.
"""
import json
from pathlib import Path
from typing import Any, Callable, final
from mcp.server.fastmcp import Context as MCPContext
from mcp.server.fastmcp import FastMCP
from dev_mcp.tools.common.context import DocumentContext, create_tool_context
from dev_mcp.tools.common.permissions import PermissionManager
from dev_mcp.tools.common.validation import validate_path_parameter
from dev_mcp.tools.shell.command_executor import CommandExecutor
@final
class ProjectAnalyzer:
"""Analyzes project structure and dependencies."""
def __init__(self, command_executor: CommandExecutor) -> None:
"""Initialize the project analyzer.
Args:
command_executor: The command executor for running analysis scripts
"""
self.command_executor: CommandExecutor = command_executor
async def analyze_python_dependencies(self, project_dir: str) -> dict[str, Any]:
"""Analyze Python project dependencies.
Args:
project_dir: The project directory
Returns:
Dictionary of dependency information
"""
script: str = """
import os
import sys
import json
import pkg_resources
from pathlib import Path
# Scan for requirements files
requirements_files = []
for root, _, files in os.walk('.'):
for file in files:
if file in ('requirements.txt', 'pyproject.toml', 'setup.py'):
requirements_files.append(os.path.join(root, file))
# Get installed packages
installed_packages = {pkg.key: pkg.version for pkg in pkg_resources.working_set}
# Scan for import statements
imports = set()
for root, _, files in os.walk('.'):
for file in files:
if file.endswith('.py'):
try:
with open(os.path.join(root, file), 'r', encoding='utf-8') as f:
for line in f:
line = line.strip()
if line.startswith('import ') or line.startswith('from '):
parts = line.split()
if parts[0] == 'import':
imports.add(parts[1].split('.')[0])
elif parts[0] == 'from' and parts[1] != '.':
imports.add(parts[1].split('.')[0])
except:
pass # Skip files that can't be read
# Create result
result = {
'requirements_files': requirements_files,
'installed_packages': installed_packages,
'imports': list(imports)
}
print(json.dumps(result))
"""
# Execute script
result = await self.command_executor.execute_script_from_file(
script=script, language="python", cwd=project_dir, timeout=30.0
)
code, stdout, stderr = result.return_code, result.stdout, result.stderr
if code != 0:
return {"error": f"Failed to analyze Python dependencies: {stderr}"}
try:
return json.loads(stdout)
except json.JSONDecodeError:
return {"error": "Failed to parse analysis result"}
async def analyze_javascript_dependencies(self, project_dir: str) -> dict[str, Any]:
"""Analyze JavaScript/Node.js project dependencies.
Args:
project_dir: The project directory
Returns:
Dictionary of dependency information
"""
script: str = """
const fs = require('fs');
const path = require('path');
// Scan for package.json files
const packageFiles = [];
function findPackageFiles(dir) {
const files = fs.readdirSync(dir, { withFileTypes: true });
for (const file of files) {
const filePath = path.join(dir, file.name);
if (file.isDirectory() && file.name !== 'node_modules') {
findPackageFiles(filePath);
} else if (file.name === 'package.json') {
packageFiles.push(filePath);
}
}
}
// Find imports
const imports = new Set();
function scanImports(dir) {
const files = fs.readdirSync(dir, { withFileTypes: true });
for (const file of files) {
const filePath = path.join(dir, file.name);
if (file.isDirectory() && file.name !== 'node_modules') {
scanImports(filePath);
} else if (file.name.endsWith('.js') || file.name.endsWith('.jsx') ||
file.name.endsWith('.ts') || file.name.endsWith('.tsx')) {
try {
const content = fs.readFileSync(filePath, 'utf-8');
// Match import statements
const importRegex = /import.*?from\\s+['"](.*?)['"];/g;
let match;
while (match = importRegex.exec(content)) {
const importPath = match[1];
if (!importPath.startsWith('.')) {
imports.add(importPath.split('/')[0]);
}
}
// Match require statements
const requireRegex = /require\\(['"](.*?)['"]\\)/g;
while (match = requireRegex.exec(content)) {
const importPath = match[1];
if (!importPath.startsWith('.')) {
imports.add(importPath.split('/')[0]);
}
}
} catch (err) {
// Skip files that can't be read
}
}
}
}
try {
findPackageFiles('.');
scanImports('.');
// Parse package.json files
const packageDetails = [];
for (const pkgFile of packageFiles) {
try {
const pkgJson = JSON.parse(fs.readFileSync(pkgFile, 'utf-8'));
packageDetails.push({
path: pkgFile,
name: pkgJson.name,
version: pkgJson.version,
dependencies: pkgJson.dependencies || {},
devDependencies: pkgJson.devDependencies || {}
});
} catch (err) {
packageDetails.push({
path: pkgFile,
error: 'Failed to parse package.json'
});
}
}
const result = {
packageFiles: packageFiles,
packageDetails: packageDetails,
imports: Array.from(imports)
};
console.log(JSON.stringify(result));
} catch (err) {
console.error(err.message);
process.exit(1);
}
"""
# Execute script
result = await self.command_executor.execute_script_from_file(
script=script, language="javascript", cwd=project_dir, timeout=30.0
)
code, stdout, stderr = result.return_code, result.stdout, result.stderr
if code != 0:
return {"error": f"Failed to analyze JavaScript dependencies: {stderr}"}
try:
return json.loads(stdout)
except json.JSONDecodeError:
return {"error": "Failed to parse analysis result"}
async def analyze_project_structure(self, project_dir: str) -> dict[str, Any]:
"""Analyze project structure.
Args:
project_dir: The project directory
Returns:
Dictionary of project structure information
"""
script: str = """
import os
import json
from pathlib import Path
def count_lines(file_path):
try:
with open(file_path, 'r', encoding='utf-8') as f:
return len(f.readlines())
except:
return 0
# Get file extensions
extensions = {}
file_count = 0
dir_count = 0
total_size = 0
total_lines = 0
# Scan files
for root, dirs, files in os.walk('.'):
dir_count += len(dirs)
file_count += len(files)
for file in files:
file_path = Path(root) / file
ext = file_path.suffix.lower()
size = file_path.stat().st_size
total_size += size
if ext in ('.py', '.js', '.jsx', '.ts', '.tsx', '.java', '.c', '.cpp', '.h', '.go', '.rb', '.php'):
lines = count_lines(file_path)
total_lines += lines
if ext in extensions:
extensions[ext]['count'] += 1
extensions[ext]['size'] += size
else:
extensions[ext] = {'count': 1, 'size': size}
# Sort extensions by count
sorted_extensions = {k: v for k, v in sorted(
extensions.items(),
key=lambda item: item[1]['count'],
reverse=True
)}
# Create result
result = {
'file_count': file_count,
'directory_count': dir_count,
'total_size': total_size,
'total_lines': total_lines,
'extensions': sorted_extensions
}
print(json.dumps(result))
"""
# Execute script
result = await self.command_executor.execute_script_from_file(
script=script, language="python", cwd=project_dir, timeout=30.0
)
code, stdout, stderr = result.return_code, result.stdout, result.stderr
if code != 0:
return {"error": f"Failed to analyze project structure: {stderr}"}
try:
return json.loads(stdout)
except json.JSONDecodeError:
return {"error": "Failed to parse analysis result"}
@final
class ProjectManager:
"""Manages project context and understanding."""
def __init__(
self,
document_context: DocumentContext,
permission_manager: PermissionManager,
project_analyzer: ProjectAnalyzer,
) -> None:
"""Initialize the project manager.
Args:
document_context: The document context for storing files
permission_manager: The permission manager for checking permissions
project_analyzer: The project analyzer for analyzing project structure
"""
self.document_context: DocumentContext = document_context
self.permission_manager: PermissionManager = permission_manager
self.project_analyzer: ProjectAnalyzer = project_analyzer
# Project metadata
self.project_root: str | None = None
self.project_metadata: dict[str, Any] = {}
self.project_analysis: dict[str, Any] = {}
self.project_files: dict[str, dict[str, Any]] = {}
# Source code stats
self.stats: dict[str, int] = {
"files": 0,
"directories": 0,
"lines_of_code": 0,
"functions": 0,
"classes": 0,
}
# Programming languages detected
self.languages: dict[str, int] = {}
# Detected framework/library usage
self.frameworks: dict[str, dict[str, Any]] = {}
def set_project_root(self, root_path: str) -> bool:
"""Set the project root directory.
Args:
root_path: The root directory of the project
Returns:
True if successful, False otherwise
"""
if not self.permission_manager.is_path_allowed(root_path):
return False
path: Path = Path(root_path)
if not path.exists() or not path.is_dir():
return False
self.project_root = str(path.resolve())
return True
def detect_programming_languages(self) -> dict[str, int]:
"""Detect programming languages used in the project.
Returns:
Dictionary mapping language names to file counts
"""
if not self.project_root:
return {}
extension_to_language: dict[str, str] = {
".py": "Python",
".js": "JavaScript",
".jsx": "JavaScript (React)",
".ts": "TypeScript",
".tsx": "TypeScript (React)",
".html": "HTML",
".css": "CSS",
".scss": "SCSS",
".less": "LESS",
".java": "Java",
".kt": "Kotlin",
".rb": "Ruby",
".php": "PHP",
".go": "Go",
".rs": "Rust",
".swift": "Swift",
".c": "C",
".cpp": "C++",
".h": "C/C++ Header",
".cs": "C#",
".sh": "Shell",
".bat": "Batch",
".ps1": "PowerShell",
".md": "Markdown",
".json": "JSON",
".yaml": "YAML",
".yml": "YAML",
".toml": "TOML",
".xml": "XML",
".sql": "SQL",
".r": "R",
".scala": "Scala",
}
languages: dict[str, int] = {}
root_path: Path = Path(self.project_root)
for ext, lang in extension_to_language.items():
files: list[Path] = list(root_path.glob(f"**/*{ext}"))
# Filter out files in excluded directories
filtered_files: list[Path] = []
for file in files:
if self.permission_manager.is_path_allowed(str(file)):
filtered_files.append(file)
if filtered_files:
languages[lang] = len(filtered_files)
self.languages = languages
return languages
def detect_project_type(self) -> dict[str, Any]:
"""Detect the type of project.
Returns:
Dictionary describing the project type and frameworks
"""
if not self.project_root:
return {"type": "unknown"}
root_path: Path = Path(self.project_root)
result: dict[str, Any] = {"type": "unknown", "frameworks": []}
# Define type checker functions with proper type annotations
def check_package_dependency(p: Path, dependency: str) -> bool:
return dependency in self._read_json(p).get("dependencies", {})
def check_requirement(p: Path, prefix: str) -> bool:
return any(x.startswith(prefix) for x in self._read_lines(p))
def always_true(p: Path) -> bool:
return True
def is_directory(p: Path) -> bool:
return p.is_dir()
# Check for common project markers using list of tuples with properly typed functions
markers: dict[str, list[tuple[str, Callable[[Path], bool]]]] = {
"web-frontend": [
("package.json", lambda p: check_package_dependency(p, "react")),
("package.json", lambda p: check_package_dependency(p, "vue")),
("package.json", lambda p: check_package_dependency(p, "angular")),
("angular.json", always_true),
("next.config.js", always_true),
("nuxt.config.js", always_true),
],
"web-backend": [
("requirements.txt", lambda p: check_requirement(p, "django")),
("requirements.txt", lambda p: check_requirement(p, "flask")),
("requirements.txt", lambda p: check_requirement(p, "fastapi")),
("package.json", lambda p: check_package_dependency(p, "express")),
("package.json", lambda p: check_package_dependency(p, "koa")),
("package.json", lambda p: check_package_dependency(p, "nest")),
("pom.xml", always_true),
("build.gradle", always_true),
],
"mobile": [
("pubspec.yaml", always_true), # Flutter
("AndroidManifest.xml", always_true),
("Info.plist", always_true),
("package.json", lambda p: check_package_dependency(p, "react-native")),
],
"desktop": [
("package.json", lambda p: check_package_dependency(p, "electron")),
("CMakeLists.txt", always_true),
("Makefile", always_true),
],
"data-science": [
("requirements.txt", lambda p: check_requirement(p, "pandas")),
("requirements.txt", lambda p: check_requirement(p, "numpy")),
("requirements.txt", lambda p: check_requirement(p, "jupyter")),
("environment.yml", always_true),
],
"devops": [
(".github/workflows", is_directory),
(".gitlab-ci.yml", always_true),
("Dockerfile", always_true),
("docker-compose.yml", always_true),
("Jenkinsfile", always_true),
("terraform.tf", always_true),
],
"game": [
("UnityProject.sln", always_true),
("Assembly-CSharp.csproj", always_true),
("ProjectSettings/ProjectSettings.asset", always_true),
("Godot", always_true),
("project.godot", always_true),
],
}
# Check markers
for project_type, type_markers in markers.items():
for marker, condition in type_markers:
marker_path: Path = root_path / marker
if marker_path.exists() and condition(marker_path):
result["type"] = project_type
break
# Detect frameworks
self._detect_frameworks(result)
return result
def _detect_frameworks(self, result: dict[str, Any]) -> None:
"""Detect frameworks used in the project.
Args:
result: Dictionary to update with framework information
"""
if not self.project_root:
return
root_path: Path = Path(self.project_root)
frameworks: list[str] = []
# Package.json based detection
package_json: Path = root_path / "package.json"
if package_json.exists() and package_json.is_file():
try:
data: dict[str, Any] = self._read_json(package_json)
dependencies: dict[str, Any] = {
**data.get("dependencies", {}),
**data.get("devDependencies", {}),
}
framework_markers: dict[str, list[str]] = {
"React": ["react", "react-dom"],
"Vue.js": ["vue"],
"Angular": ["@angular/core"],
"Next.js": ["next"],
"Nuxt.js": ["nuxt"],
"Express": ["express"],
"NestJS": ["@nestjs/core"],
"React Native": ["react-native"],
"Electron": ["electron"],
"jQuery": ["jquery"],
"Bootstrap": ["bootstrap"],
"Tailwind CSS": ["tailwindcss"],
"Material UI": ["@mui/material", "@material-ui/core"],
"Redux": ["redux"],
"Gatsby": ["gatsby"],
"Svelte": ["svelte"],
"Jest": ["jest"],
"Mocha": ["mocha"],
"Cypress": ["cypress"],
}
for framework, markers in framework_markers.items():
if any(marker in dependencies for marker in markers):
frameworks.append(framework)
except Exception:
pass
# Python requirements.txt based detection
requirements_txt: Path = root_path / "requirements.txt"
if requirements_txt.exists() and requirements_txt.is_file():
try:
requirements: list[str] = self._read_lines(requirements_txt)
framework_markers: dict[str, list[str]] = {
"Django": ["django"],
"Flask": ["flask"],
"FastAPI": ["fastapi"],
"Pandas": ["pandas"],
"NumPy": ["numpy"],
"TensorFlow": ["tensorflow"],
"PyTorch": ["torch"],
"Scikit-learn": ["scikit-learn", "sklearn"],
"Jupyter": ["jupyter", "ipython"],
"Pytest": ["pytest"],
"SQLAlchemy": ["sqlalchemy"],
}
for framework, markers in framework_markers.items():
if any(
any(req.lower().startswith(marker) for marker in markers)
for req in requirements
):
frameworks.append(framework)
except Exception:
pass
result["frameworks"] = frameworks
self.frameworks = {f: {"detected": True} for f in frameworks}
def _read_json(self, path: Path) -> dict[str, Any]:
"""Read a JSON file.
Args:
path: Path to the JSON file
Returns:
Dictionary containing the JSON data, or empty dict on error
"""
try:
with open(path, "r", encoding="utf-8") as f:
return json.load(f)
except Exception:
return {}
def _read_lines(self, path: Path) -> list[str]:
"""Read lines from a text file.
Args:
path: Path to the text file
Returns:
List of lines, or empty list on error
"""
try:
with open(path, "r", encoding="utf-8") as f:
return f.readlines()
except Exception:
return []
async def analyze_project(self) -> dict[str, Any]:
"""Analyze the project structure and dependencies.
Returns:
Dictionary containing analysis results
"""
if not self.project_root:
return {"error": "Project root not set"}
result: dict[str, Any] = {}
# Detect languages
result["languages"] = self.detect_programming_languages()
# Detect project type
result["project_type"] = self.detect_project_type()
# Analyze structure
structure: dict[
str, Any
] = await self.project_analyzer.analyze_project_structure(self.project_root)
result["structure"] = structure
# Analyze dependencies based on project type
if "Python" in result["languages"]:
python_deps: dict[
str, Any
] = await self.project_analyzer.analyze_python_dependencies(
self.project_root
)
result["python_dependencies"] = python_deps
if "JavaScript" in result["languages"] or "TypeScript" in result["languages"]:
js_deps: dict[
str, Any
] = await self.project_analyzer.analyze_javascript_dependencies(
self.project_root
)
result["javascript_dependencies"] = js_deps
self.project_analysis = result
return result
def generate_project_summary(self) -> str:
"""Generate a human-readable summary of the project.
Returns:
Formatted string with project summary
"""
if not self.project_root or not self.project_analysis:
return "No project analysis available. Please set project root and run analysis first."
# Build summary
summary: list[str] = [f"# Project Summary: {Path(self.project_root).name}\n"]
# Project type
project_type: dict[str, Any] = self.project_analysis.get("project_type", {})
if project_type:
summary.append(
f"## Project Type: {project_type.get('type', 'Unknown').title()}"
)
frameworks: list[str] = project_type.get("frameworks", [])
if frameworks:
summary.append("### Frameworks/Libraries")
summary.append(", ".join(frameworks))
summary.append("")
# Languages
languages: dict[str, int] = self.project_analysis.get("languages", {})
if languages:
summary.append("## Programming Languages")
for lang, count in languages.items():
summary.append(f"- {lang}: {count} files")
summary.append("")
# Structure
structure: dict[str, Any] = self.project_analysis.get("structure", {})
if structure and not isinstance(structure, str):
summary.append("## Project Structure")
summary.append(f"- Files: {structure.get('file_count', 0)}")
summary.append(f"- Directories: {structure.get('directory_count', 0)}")
summary.append(
f"- Total size: {self._format_size(structure.get('total_size', 0))}"
)
if structure.get("total_lines"):
summary.append(
f"- Total lines of code: {structure.get('total_lines', 0)}"
)
# File extensions
extensions: dict[str, dict[str, int]] = structure.get("extensions", {})
if extensions:
summary.append("\n### File Types")
for ext, info in list(extensions.items())[:10]: # Show top 10
if ext:
summary.append(
f"- {ext}: {info.get('count', 0)} files ({self._format_size(info.get('size', 0))})"
)
summary.append("")
# Dependencies
py_deps: dict[str, Any] = self.project_analysis.get("python_dependencies", {})
if py_deps and not isinstance(py_deps, str) and not py_deps.get("error"):
summary.append("## Python Dependencies")
# Requirements files
req_files: list[str] = py_deps.get("requirements_files", [])
if req_files:
summary.append("### Dependency Files")
for req in req_files:
summary.append(f"- {req}")
# Imports
imports: list[str] = py_deps.get("imports", [])
if imports:
summary.append("\n### Top Imports")
for imp in sorted(imports)[:15]: # Show top 15
summary.append(f"- {imp}")
summary.append("")
js_deps: dict[str, Any] = self.project_analysis.get(
"javascript_dependencies", {}
)
if js_deps and not isinstance(js_deps, str) and not js_deps.get("error"):
summary.append("## JavaScript/TypeScript Dependencies")
# Package files
pkg_files: list[str] = js_deps.get("packageFiles", [])
if pkg_files:
summary.append("### Package Files")
for pkg in pkg_files:
summary.append(f"- {pkg}")
# Imports
imports: list[str] = js_deps.get("imports", [])
if imports:
summary.append("\n### Top Imports")
for imp in sorted(imports)[:15]: # Show top 15
summary.append(f"- {imp}")
summary.append("")
return "\n".join(summary)
def _format_size(self, size_bytes: float) -> str:
"""Format file size in human-readable form.
Args:
size_bytes: Size in bytes
Returns:
Formatted size string
"""
for unit in ["B", "KB", "MB", "GB"]:
if size_bytes < 1024.0:
return f"{size_bytes:.1f} {unit}"
size_bytes = size_bytes / 1024.0
return f"{size_bytes:.1f} TB"
@final
class ProjectAnalysis:
"""Project analysis tools for MCP Claude Code."""
def __init__(
self,
project_manager: ProjectManager,
project_analyzer: ProjectAnalyzer,
permission_manager: PermissionManager,
) -> None:
"""Initialize project analysis.
Args:
project_manager: Project manager for tracking projects
project_analyzer: Project analyzer for analyzing project structure and dependencies
permission_manager: Permission manager for access control
"""
self.project_manager: ProjectManager = project_manager
self.project_analyzer: ProjectAnalyzer = project_analyzer
self.permission_manager: PermissionManager = permission_manager
def register_tools(self, mcp_server: FastMCP) -> None:
"""Register project analysis tools with the MCP server.
Args:
mcp_server: The FastMCP server instance
"""
# Project analysis tool
@mcp_server.tool()
async def project_analyze_tool(project_dir: str, ctx: MCPContext) -> str:
"""Analyze a project directory structure and dependencies.
Args:
project_dir: Path to the project directory
Returns:
Analysis of the project
"""
tool_ctx = create_tool_context(ctx)
tool_ctx.set_tool_info("project_analyze")
# Validate project_dir parameter
path_validation = validate_path_parameter(project_dir, "project_dir")
if path_validation.is_error:
await tool_ctx.error(path_validation.error_message)
return f"Error: {path_validation.error_message}"
await tool_ctx.info(f"Analyzing project: {project_dir}")
# Check if directory is allowed
if not self.permission_manager.is_path_allowed(project_dir):
await tool_ctx.error(f"Directory not allowed: {project_dir}")
return f"Error: Directory not allowed: {project_dir}"
# Set project root
if not self.project_manager.set_project_root(project_dir):
await tool_ctx.error(f"Failed to set project root: {project_dir}")
return f"Error: Failed to set project root: {project_dir}"
await tool_ctx.info("Analyzing project structure...")
# Report intermediate progress
await tool_ctx.report_progress(10, 100)
# Analyze project
analysis: dict[str, Any] = await self.project_manager.analyze_project()
if "error" in analysis:
await tool_ctx.error(f"Error analyzing project: {analysis['error']}")
return f"Error analyzing project: {analysis['error']}"
# Report more progress
await tool_ctx.report_progress(50, 100)
await tool_ctx.info("Generating project summary...")
# Generate summary
summary = self.project_manager.generate_project_summary()
# Complete progress
await tool_ctx.report_progress(100, 100)
await tool_ctx.info("Project analysis complete")
return summary