mirror of
https://github.com/hanzoai/python-sdk.git
synced 2026-10-27 04:20:09 +00:00
605 lines
21 KiB
Python
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())
|