Files

605 lines
21 KiB
Python

#!/usr/bin/env python3
"""
Hanzo Unified MCP Backend
========================
Core backend service that powers all MCP interfaces (VS Code, browser, CLI, MCP server).
Implements the 6 universal tools: edit, fmt, test, build, lint, guard.
Features:
- Unified session logging to ~/.hanzo/sessions/<id>
- Codebase intelligence with SQLite vector storage
- LSP integration for semantic operations
- Workspace-aware operations with go.work support
- Cross-language tool execution
"""
import asyncio
import json
import logging
import os
import sqlite3
import subprocess
import uuid
from dataclasses import asdict, dataclass
from datetime import datetime
from pathlib import Path
from typing import Any, Dict, List, Optional, Union
from pydantic import BaseModel, Field
# Core Types
@dataclass
class ToolResult:
"""Standard result format for all tools"""
ok: bool
root: str
language_used: Union[str, List[str]]
backend_used: Union[str, List[str]]
scope_resolved: Union[str, List[str]]
touched_files: List[str]
stdout: str
stderr: str
exit_code: int
errors: List[str]
execution_time: float
session_id: str
class TargetSpec(BaseModel):
"""Target specification for tool operations"""
target: str = Field(
..., description="file:<path>, dir:<path>, pkg:<spec>, ws, or changed"
)
language: str = Field(default="auto", description="Language override")
backend: str = Field(default="auto", description="Backend override")
root: Optional[str] = Field(default=None, description="Workspace root override")
env: Dict[str, str] = Field(
default_factory=dict, description="Environment variables"
)
dry_run: bool = Field(default=False, description="Preview mode")
class WorkspaceDetector:
"""Intelligent workspace detection with go.work support"""
@staticmethod
def detect(target_path: str) -> Dict[str, Any]:
"""Detect workspace root and configuration"""
path = Path(target_path).absolute()
# Walk up to find workspace markers
for current in [path] + list(path.parents):
# Check for various workspace indicators
if (current / "go.work").exists():
return {
"root": str(current),
"type": "go_workspace",
"config": current / "go.work",
"language": "go",
}
elif (current / "package.json").exists():
return {
"root": str(current),
"type": "node_workspace",
"config": current / "package.json",
"language": "ts",
}
elif (current / "pyproject.toml").exists():
return {
"root": str(current),
"type": "python_workspace",
"config": current / "pyproject.toml",
"language": "py",
}
elif (current / "Cargo.toml").exists():
return {
"root": str(current),
"type": "rust_workspace",
"config": current / "Cargo.toml",
"language": "rs",
}
elif (current / ".git").exists():
return {
"root": str(current),
"type": "git_repository",
"config": current / ".git",
"language": "auto",
}
# Default to current directory
return {
"root": str(path.parent if path.is_file() else path),
"type": "directory",
"config": None,
"language": "auto",
}
class SessionManager:
"""Manages logging and session tracking"""
def __init__(self):
self.hanzo_dir = Path.home() / ".hanzo"
self.sessions_dir = self.hanzo_dir / "sessions"
self.sessions_dir.mkdir(parents=True, exist_ok=True)
self.current_session = str(uuid.uuid4())
self.session_file = self.sessions_dir / f"{self.current_session}.jsonl"
def log_tool_execution(self, tool_name: str, args: Dict, result: ToolResult):
"""Log tool execution to JSONL"""
log_entry = {
"timestamp": datetime.utcnow().isoformat(),
"session_id": self.current_session,
"tool": tool_name,
"args": args,
"result": asdict(result),
"user": os.getenv("USER", "unknown"),
"cwd": os.getcwd(),
}
with open(self.session_file, "a") as f:
f.write(json.dumps(log_entry) + "\n")
def get_recent_sessions(self, limit: int = 10) -> List[Dict]:
"""Get recent session activity"""
sessions = []
for session_file in sorted(self.sessions_dir.glob("*.jsonl"), reverse=True)[
:limit
]:
with open(session_file) as f:
session_data = [json.loads(line) for line in f]
if session_data:
sessions.append(
{
"session_id": session_file.stem,
"start_time": session_data[0]["timestamp"],
"tool_count": len(session_data),
"tools_used": list(
set(entry["tool"] for entry in session_data)
),
}
)
return sessions
class CodebaseIndexer:
"""SQLite-based codebase intelligence"""
def __init__(self, hanzo_dir: Path):
self.db_path = hanzo_dir / "codebase.db"
self.init_database()
def init_database(self):
"""Initialize SQLite database with vector storage"""
with sqlite3.connect(self.db_path) as conn:
conn.executescript("""
CREATE TABLE IF NOT EXISTS files (
id INTEGER PRIMARY KEY,
path TEXT UNIQUE NOT NULL,
content_hash TEXT NOT NULL,
language TEXT,
size INTEGER,
modified_time REAL,
indexed_time REAL DEFAULT (julianday('now'))
);
CREATE TABLE IF NOT EXISTS symbols (
id INTEGER PRIMARY KEY,
file_id INTEGER REFERENCES files(id),
name TEXT NOT NULL,
kind TEXT NOT NULL, -- function, class, variable, etc
line_start INTEGER,
line_end INTEGER,
definition TEXT,
UNIQUE(file_id, name, line_start)
);
CREATE TABLE IF NOT EXISTS imports (
id INTEGER PRIMARY KEY,
file_id INTEGER REFERENCES files(id),
import_path TEXT NOT NULL,
alias TEXT,
line_number INTEGER
);
CREATE TABLE IF NOT EXISTS dependencies (
id INTEGER PRIMARY KEY,
from_file_id INTEGER REFERENCES files(id),
to_file_id INTEGER REFERENCES files(id),
relationship TEXT NOT NULL, -- imports, calls, extends, etc
UNIQUE(from_file_id, to_file_id, relationship)
);
CREATE INDEX IF NOT EXISTS idx_files_path ON files(path);
CREATE INDEX IF NOT EXISTS idx_symbols_name ON symbols(name);
CREATE INDEX IF NOT EXISTS idx_imports_path ON imports(import_path);
""")
def index_file(self, file_path: str, content: str, language: str):
"""Index a single file for intelligent search"""
import hashlib
content_hash = hashlib.sha256(content.encode()).hexdigest()
file_size = len(content.encode())
modified_time = os.path.getmtime(file_path)
with sqlite3.connect(self.db_path) as conn:
# Insert or update file record
conn.execute(
"""
INSERT OR REPLACE INTO files (path, content_hash, language, size, modified_time)
VALUES (?, ?, ?, ?, ?)
""",
(file_path, content_hash, language, file_size, modified_time),
)
# Symbol extraction handled by LSP integration in the tools layer
# See hanzo_mcp/tools/common/lsp.py for language-specific parsing
def search_symbols(self, query: str, language: Optional[str] = None) -> List[Dict]:
"""Search for symbols across codebase"""
with sqlite3.connect(self.db_path) as conn:
sql = """
SELECT f.path, s.name, s.kind, s.line_start, s.definition
FROM symbols s
JOIN files f ON s.file_id = f.id
WHERE s.name LIKE ?
"""
params = [f"%{query}%"]
if language:
sql += " AND f.language = ?"
params.append(language)
cursor = conn.execute(sql, params)
return [
{
"path": row[0],
"name": row[1],
"kind": row[2],
"line": row[3],
"definition": row[4],
}
for row in cursor.fetchall()
]
class LSPBridge:
"""Unified LSP client for cross-language operations"""
LSP_SERVERS = {
"go": "gopls",
"ts": "typescript-language-server",
"py": "pyright",
"rs": "rust-analyzer",
"cc": "clangd",
"sol": "solidity-language-server",
}
def __init__(self):
self.active_servers = {}
async def start_server(self, language: str, workspace_root: str):
"""Start LSP server for language"""
# Implementation would start LSP server process
# and handle communication via JSON-RPC
pass
async def rename_symbol(
self, file_path: str, line: int, character: int, new_name: str
):
"""Perform LSP rename operation"""
# Implementation would send LSP rename request
pass
async def code_actions(self, file_path: str, line_start: int, line_end: int):
"""Get available code actions"""
# Implementation would send LSP codeAction request
pass
class UnifiedBackend:
"""Main backend service implementing the 6 universal tools"""
def __init__(self):
self.session_manager = SessionManager()
self.indexer = CodebaseIndexer(self.session_manager.hanzo_dir)
self.lsp_bridge = LSPBridge()
self.logger = logging.getLogger(__name__)
def resolve_target(self, target_spec: TargetSpec) -> Dict[str, Any]:
"""Resolve target specification to concrete file/directory list"""
target = target_spec.target
if target.startswith("file:"):
path = target[5:]
workspace = WorkspaceDetector.detect(path)
return {"type": "file", "paths": [path], "workspace": workspace}
elif target.startswith("dir:"):
path = target[4:]
workspace = WorkspaceDetector.detect(path)
# Recursively find relevant files
files = []
for ext in [".py", ".go", ".ts", ".js", ".rs", ".c", ".cpp", ".sol"]:
files.extend(Path(path).rglob(f"*{ext}"))
return {
"type": "directory",
"paths": [str(f) for f in files],
"workspace": workspace,
}
elif target.startswith("pkg:"):
pkg_spec = target[4:]
workspace = WorkspaceDetector.detect(".")
# Language-specific package resolution
if workspace["language"] == "go":
return self._resolve_go_package(pkg_spec, workspace)
elif workspace["language"] == "ts":
return self._resolve_ts_package(pkg_spec, workspace)
# etc.
elif target == "ws":
workspace = WorkspaceDetector.detect(".")
# Return all files in workspace
root = Path(workspace["root"])
files = []
for ext in [".py", ".go", ".ts", ".js", ".rs", ".c", ".cpp", ".sol"]:
files.extend(root.rglob(f"*{ext}"))
return {
"type": "workspace",
"paths": [str(f) for f in files],
"workspace": workspace,
}
elif target == "changed":
# Git diff against HEAD
try:
result = subprocess.run(
["git", "diff", "--name-only", "HEAD"],
capture_output=True,
text=True,
check=True,
)
changed_files = (
result.stdout.strip().split("\n") if result.stdout.strip() else []
)
workspace = WorkspaceDetector.detect(".")
return {
"type": "changed",
"paths": changed_files,
"workspace": workspace,
}
except subprocess.CalledProcessError:
return {
"type": "changed",
"paths": [],
"workspace": WorkspaceDetector.detect("."),
"error": "Not a git repository or git not available",
}
def _resolve_go_package(self, pkg_spec: str, workspace: Dict) -> Dict:
"""Resolve Go package specification"""
# Implementation for Go package resolution
# Handle ./..., ./cli/..., specific packages
pass
def _resolve_ts_package(self, pkg_spec: str, workspace: Dict) -> Dict:
"""Resolve TypeScript/Node package specification"""
# Implementation for TS package resolution
pass
async def edit(self, target: TargetSpec, op: str, **kwargs) -> ToolResult:
"""Edit tool: semantic refactors via LSP"""
start_time = datetime.utcnow().timestamp()
try:
resolved = self.resolve_target(target)
workspace = resolved["workspace"]
result = ToolResult(
ok=True,
root=workspace["root"],
language_used=workspace["language"],
backend_used="lsp",
scope_resolved=resolved["paths"],
touched_files=[],
stdout="",
stderr="",
exit_code=0,
errors=[],
execution_time=0,
session_id=self.session_manager.current_session,
)
if op == "rename":
# LSP rename operation
file_path = kwargs.get("file")
pos = kwargs.get("pos", {})
new_name = kwargs.get("new_name")
if target.dry_run:
result.stdout = (
f"Would rename symbol at {file_path}:{pos} to {new_name}"
)
else:
# Actual LSP rename
await self.lsp_bridge.rename_symbol(
file_path, pos.get("line", 0), pos.get("character", 0), new_name
)
result.touched_files = [file_path]
elif op == "code_action":
# LSP code actions
file_path = kwargs.get("file")
range_spec = kwargs.get("range", {})
kwargs.get("only", [])
actions = await self.lsp_bridge.code_actions(
file_path,
range_spec.get("start", {}).get("line", 0),
range_spec.get("end", {}).get("line", 0),
)
result.stdout = f"Available actions: {actions}"
elif op == "organize_imports":
# Organize imports for all files in scope
for file_path in resolved["paths"]:
if not target.dry_run:
# Implement import organization
pass
result.touched_files.append(file_path)
result.execution_time = datetime.utcnow().timestamp() - start_time
return result
except Exception as e:
result = ToolResult(
ok=False,
root=workspace.get("root", "."),
language_used="unknown",
backend_used="lsp",
scope_resolved=[],
touched_files=[],
stdout="",
stderr=str(e),
exit_code=1,
errors=[str(e)],
execution_time=datetime.utcnow().timestamp() - start_time,
session_id=self.session_manager.current_session,
)
return result
async def fmt(self, target: TargetSpec, **kwargs) -> ToolResult:
"""Format tool: formatting + import normalization"""
start_time = datetime.utcnow().timestamp()
try:
resolved = self.resolve_target(target)
workspace = resolved["workspace"]
language = workspace["language"]
# Select formatter based on language
formatters = {
"go": "goimports",
"py": "ruff format",
"ts": "prettier",
"rs": "cargo fmt",
"cc": "clang-format",
"sol": "prettier",
}
formatter = formatters.get(language, "cat")
backend_used = formatter.split()[0]
result = ToolResult(
ok=True,
root=workspace["root"],
language_used=language,
backend_used=backend_used,
scope_resolved=resolved["paths"],
touched_files=[],
stdout="",
stderr="",
exit_code=0,
errors=[],
execution_time=0,
session_id=self.session_manager.current_session,
)
if not target.dry_run:
for file_path in resolved["paths"]:
# Run formatter on each file
if language == "go":
cmd = ["goimports", "-w"]
local_prefix = kwargs.get("opts", {}).get("local_prefix")
if local_prefix:
cmd.extend(["-local", local_prefix])
cmd.append(file_path)
proc_result = subprocess.run(
cmd, capture_output=True, text=True
)
if proc_result.returncode == 0:
result.touched_files.append(file_path)
else:
result.errors.append(
f"Failed to format {file_path}: {proc_result.stderr}"
)
# Add other language formatting logic
result.execution_time = datetime.utcnow().timestamp() - start_time
result.ok = len(result.errors) == 0
return result
except Exception as e:
return ToolResult(
ok=False,
root=".",
language_used="unknown",
backend_used="unknown",
scope_resolved=[],
touched_files=[],
stdout="",
stderr=str(e),
exit_code=1,
errors=[str(e)],
execution_time=datetime.utcnow().timestamp() - start_time,
session_id=self.session_manager.current_session,
)
async def test(self, target: TargetSpec, **kwargs) -> ToolResult:
"""Test tool: run tests narrowly by default"""
# Implementation for test execution
pass
async def build(self, target: TargetSpec, **kwargs) -> ToolResult:
"""Build tool: compile/build artifacts"""
# Implementation for build execution
pass
async def lint(self, target: TargetSpec, **kwargs) -> ToolResult:
"""Lint tool: lint/typecheck in one place"""
# Implementation for linting
pass
async def guard(
self, target: TargetSpec, rules: List[Dict], **kwargs
) -> ToolResult:
"""Guard tool: repo invariants"""
# Implementation for guard rules
pass
# Global backend instance
backend = UnifiedBackend()
if __name__ == "__main__":
# CLI interface for testing
import asyncio
import sys
async def main():
if len(sys.argv) < 2:
print("Usage: python unified_backend.py <tool> <target> [args...]")
sys.exit(1)
tool_name = sys.argv[1]
target_spec = TargetSpec(target=sys.argv[2])
if tool_name == "fmt":
result = await backend.fmt(target_spec)
elif tool_name == "edit":
result = await backend.edit(target_spec, op="organize_imports")
# Add other tools
print(json.dumps(asdict(result), indent=2))
asyncio.run(main())