Apply ruff format to hanzo_mcp and tests

This commit is contained in:
2026-01-17 21:51:58 -08:00
parent d4bfe735f0
commit c3a49bebd5
123 changed files with 3024 additions and 857 deletions
+3 -1
View File
@@ -16,7 +16,9 @@ import os
import warnings
# Suppress litellm deprecation warnings about event loop
warnings.filterwarnings("ignore", message="There is no current event loop", category=DeprecationWarning)
warnings.filterwarnings(
"ignore", message="There is no current event loop", category=DeprecationWarning
)
if os.environ.get("HANZO_MCP_TRANSPORT") == "stdio":
try:
@@ -138,7 +138,9 @@ class Analytics:
self.capture("tool_used", properties)
def track_error(self, error: Exception, context: Optional[Dict[str, Any]] = None) -> None:
def track_error(
self, error: Exception, context: Optional[Dict[str, Any]] = None
) -> None:
"""Track an error event."""
if not self.config.capture_errors:
return
@@ -158,7 +160,9 @@ class Analytics:
return default
try:
return self._client.feature_enabled(flag_key, self.config.distinct_id, default=default)
return self._client.feature_enabled(
flag_key, self.config.distinct_id, default=default
)
except Exception:
return default
@@ -168,7 +172,9 @@ class Analytics:
return default
try:
return self._client.get_feature_flag(flag_key, self.config.distinct_id, default=default)
return self._client.get_feature_flag(
flag_key, self.config.distinct_id, default=default
)
except Exception:
return default
@@ -49,7 +49,11 @@ class SQLiteBackendPlugin:
self._initialized = False
async def store_memory(
self, content: str, metadata: Dict[str, Any], user_id: str = "default", project_id: str = "default"
self,
content: str,
metadata: Dict[str, Any],
user_id: str = "default",
project_id: str = "default",
) -> str:
"""Store a memory and return its ID."""
if not self._client:
@@ -84,7 +88,11 @@ class SQLiteBackendPlugin:
return result["memory_id"]
async def retrieve_memory(
self, query: str, user_id: str = "default", project_id: str = "default", limit: int = 10
self,
query: str,
user_id: str = "default",
project_id: str = "default",
limit: int = 10,
) -> List[Dict[str, Any]]:
"""Retrieve memories based on query."""
if not self._client:
@@ -100,7 +108,10 @@ class SQLiteBackendPlugin:
query_embedding = embedding_service.embed_single(query)
results = self._client.search_memories(
user_id=user_id, query_embedding=query_embedding, project_id=project_id, limit=limit
user_id=user_id,
query_embedding=query_embedding,
project_id=project_id,
limit=limit,
)
return [
@@ -132,12 +143,18 @@ class SQLiteBackendPlugin:
]
async def search_memory(
self, query: str, user_id: str = "default", project_id: str = "default", limit: int = 10
self,
query: str,
user_id: str = "default",
project_id: str = "default",
limit: int = 10,
) -> List[Dict[str, Any]]:
"""Search memories based on query with scoring."""
return await self.retrieve_memory(query, user_id, project_id, limit)
async def delete_memory(self, memory_id: str, user_id: str = "default", project_id: str = "default") -> bool:
async def delete_memory(
self, memory_id: str, user_id: str = "default", project_id: str = "default"
) -> bool:
"""Delete a memory by ID."""
if not self._client:
raise RuntimeError("Plugin not initialized")
+24 -8
View File
@@ -129,7 +129,9 @@ class ClaudeBridge(FastMCP):
"""
logger.info(f"Bridge {self.config.instance_id}: Task delegation")
delegation_prompt = self._build_delegation_prompt(task, requirements, constraints)
delegation_prompt = self._build_delegation_prompt(
task, requirements, constraints
)
result = await self._forward_to_claude(delegation_prompt)
return {
@@ -171,7 +173,9 @@ class ClaudeBridge(FastMCP):
}
@self.tool()
async def share_context_with_claude(key: str, value: Any, description: Optional[str] = None) -> bool:
async def share_context_with_claude(
key: str, value: Any, description: Optional[str] = None
) -> bool:
"""Share context with another Claude instance.
Args:
@@ -254,7 +258,9 @@ class ClaudeBridge(FastMCP):
"shared_context_keys": list(self.shared_context.keys()),
}
def _build_review_prompt(self, code: str, description: str, focus_areas: Optional[List[str]]) -> str:
def _build_review_prompt(
self, code: str, description: str, focus_areas: Optional[List[str]]
) -> str:
"""Build a code review prompt."""
prompt = f"""
Please review the following code:
@@ -282,7 +288,9 @@ class ClaudeBridge(FastMCP):
return prompt
def _build_delegation_prompt(self, task: str, requirements: List[str], constraints: Optional[List[str]]) -> str:
def _build_delegation_prompt(
self, task: str, requirements: List[str], constraints: Optional[List[str]]
) -> str:
"""Build a task delegation prompt."""
prompt = f"""
Please complete the following task:
@@ -307,7 +315,9 @@ class ClaudeBridge(FastMCP):
return prompt
def _build_opinion_prompt(self, question: str, options: Optional[List[str]], criteria: Optional[List[str]]) -> str:
def _build_opinion_prompt(
self, question: str, options: Optional[List[str]], criteria: Optional[List[str]]
) -> str:
"""Build an opinion request prompt."""
prompt = f"""
I need your opinion on the following:
@@ -336,7 +346,9 @@ class ClaudeBridge(FastMCP):
return prompt
async def _forward_to_claude(self, prompt: str, context: Optional[str] = None) -> str:
async def _forward_to_claude(
self, prompt: str, context: Optional[str] = None
) -> str:
"""Forward a request to the target Claude instance.
In production, this would make an actual API call to the Claude instance.
@@ -348,7 +360,9 @@ class ClaudeBridge(FastMCP):
full_prompt = f"Context: {context}\n\n{prompt}"
# Log the forwarding
logger.info(f"Forwarding from instance {self.config.source_instance} to {self.config.target_instance}")
logger.info(
f"Forwarding from instance {self.config.source_instance} to {self.config.target_instance}"
)
logger.debug(f"Prompt: {full_prompt[:200]}...")
# In production, this would:
@@ -421,7 +435,9 @@ async def run_bridge_server(config: BridgeConfig):
def main():
"""Main entry point for the bridge."""
parser = argparse.ArgumentParser(description="MCP Bridge for Claude-to-Claude communication")
parser = argparse.ArgumentParser(
description="MCP Bridge for Claude-to-Claude communication"
)
parser.add_argument(
"--target-port",
type=int,
+15 -5
View File
@@ -113,7 +113,9 @@ def main() -> None:
_version_info = f"hanzo-mcp {_version} (async: {_async_backend})"
parser = argparse.ArgumentParser(description="MCP server implementing Hanzo AI capabilities")
parser = argparse.ArgumentParser(
description="MCP server implementing Hanzo AI capabilities"
)
parser.add_argument("--version", action="version", version=_version_info)
@@ -405,8 +407,12 @@ def main() -> None:
port: int = cast(int, args.port)
log_level: str = cast(str, args.log_level)
project_dir: str | None = cast(str | None, args.project_dir)
allowed_paths: list[str] = cast(list[str], args.allowed_paths) if args.allowed_paths else []
project_paths: list[str] = cast(list[str], args.project_paths) if args.project_paths else []
allowed_paths: list[str] = (
cast(list[str], args.allowed_paths) if args.allowed_paths else []
)
project_paths: list[str] = (
cast(list[str], args.project_paths) if args.project_paths else []
)
# Handle project_dir parameter (add to both allowed_paths and project_paths)
if project_dir:
@@ -416,7 +422,9 @@ def main() -> None:
project_paths.append(project_dir)
if install:
install_claude_desktop_config(name, allowed_paths, disable_write_tools, disable_search_tools, host, port)
install_claude_desktop_config(
name, allowed_paths, disable_write_tools, disable_search_tools, host, port
)
return
# Get logger
@@ -582,7 +590,9 @@ def install_claude_desktop_config(
args.append("--disable-search-tools")
# Create config object
config: dict[str, Any] = {"mcpServers": {name: {"command": script_path.as_posix(), "args": args}}}
config: dict[str, Any] = {
"mcpServers": {name: {"command": script_path.as_posix(), "args": args}}
}
# Check if the file already exists
if config_file.exists():
+14 -5
View File
@@ -344,7 +344,10 @@ def apply_cli_overrides(args: argparse.Namespace) -> Dict[str, Any]:
vector_config["enabled"] = True
if hasattr(args, "vector_store_path") and args.vector_store_path:
vector_config["data_path"] = args.vector_store_path
if hasattr(args, "embedding_model") and args.embedding_model != "text-embedding-3-small":
if (
hasattr(args, "embedding_model")
and args.embedding_model != "text-embedding-3-small"
):
vector_config["embedding_model"] = args.embedding_model
if vector_config:
@@ -377,7 +380,9 @@ def list_tools(settings: HanzoMCPSettings) -> None:
logger.info(f"\nTotal: {len(TOOL_REGISTRY)} tools")
enabled_count = len(settings.get_enabled_tools())
logger.info(f"Enabled: {enabled_count}, Disabled: {len(TOOL_REGISTRY) - enabled_count}")
logger.info(
f"Enabled: {enabled_count}, Disabled: {len(TOOL_REGISTRY) - enabled_count}"
)
def main() -> None:
@@ -437,11 +442,15 @@ def main() -> None:
agent_base_url=settings.agent.base_url,
agent_max_iterations=settings.agent.max_iterations,
agent_max_tool_uses=settings.agent.max_tool_uses,
enable_agent_tool=settings.agent.enabled or settings.is_tool_enabled("dispatch_agent"),
enable_agent_tool=settings.agent.enabled
or settings.is_tool_enabled("dispatch_agent"),
disable_write_tools=not any(
settings.is_tool_enabled(t) for t in ["write", "edit", "multi_edit", "content_replace"]
settings.is_tool_enabled(t)
for t in ["write", "edit", "multi_edit", "content_replace"]
),
disable_search_tools=not any(
settings.is_tool_enabled(t) for t in ["grep", "grep_ast"]
),
disable_search_tools=not any(settings.is_tool_enabled(t) for t in ["grep", "grep_ast"]),
host=settings.server.host,
port=settings.server.port,
enabled_tools=settings.enabled_tools, # Pass individual tool configuration
+9 -3
View File
@@ -53,7 +53,9 @@ Examples:
create_plugin_template(output_dir, args.name)
print("\n✅ Plugin template created successfully!")
print("\nTo use your plugin:")
print(f"1. Edit the tool implementation in {output_dir / f'{args.name}_tool.py'}")
print(
f"1. Edit the tool implementation in {output_dir / f'{args.name}_tool.py'}"
)
print("2. Restart Hanzo MCP to load the plugin")
print(f"3. Add '{args.name}' to your mode's tool list")
except Exception as e:
@@ -79,9 +81,13 @@ Examples:
print(f"\n {name}:")
print(f" Source: {plugin.source_path}")
if plugin.metadata:
print(f" Version: {plugin.metadata.get('version', 'unknown')}")
print(
f" Version: {plugin.metadata.get('version', 'unknown')}"
)
print(f" Author: {plugin.metadata.get('author', 'unknown')}")
print(f" Description: {plugin.metadata.get('description', '')}")
print(
f" Description: {plugin.metadata.get('description', '')}"
)
except Exception as e:
print(f"❌ Error listing plugins: {e}", file=sys.stderr)
sys.exit(1)
+20 -5
View File
@@ -52,7 +52,10 @@ def get_project_config_path() -> Optional[Path]:
return None
def load_config(global_config_path: Optional[Path] = None, project_config_path: Optional[Path] = None) -> PluginConfig:
def load_config(
global_config_path: Optional[Path] = None,
project_config_path: Optional[Path] = None,
) -> PluginConfig:
"""Load configuration from global and project files, with project overriding global."""
global_path = global_config_path or get_global_config_path()
project_path = project_config_path or get_project_config_path()
@@ -61,7 +64,11 @@ def load_config(global_config_path: Optional[Path] = None, project_config_path:
config_data = {
"enabled_backends": ["sqlite"],
"backend_configs": {
"sqlite": {"enabled": True, "path": str(Path.home() / ".hanzo" / "memory.db"), "settings": {}}
"sqlite": {
"enabled": True,
"path": str(Path.home() / ".hanzo" / "memory.db"),
"settings": {},
}
},
"default_user_id": "default",
"default_project_id": "default",
@@ -75,7 +82,9 @@ def load_config(global_config_path: Optional[Path] = None, project_config_path:
# Update config with global settings
for key, value in global_data.items():
if key in config_data:
if isinstance(config_data[key], dict) and isinstance(value, dict):
if isinstance(config_data[key], dict) and isinstance(
value, dict
):
# Deep merge dictionaries
config_data[key].update(value)
else:
@@ -91,7 +100,9 @@ def load_config(global_config_path: Optional[Path] = None, project_config_path:
# Update config with project settings
for key, value in project_data.items():
if key in config_data:
if isinstance(config_data[key], dict) and isinstance(value, dict):
if isinstance(config_data[key], dict) and isinstance(
value, dict
):
# Deep merge dictionaries
config_data[key].update(value)
else:
@@ -115,6 +126,10 @@ def get_default_config() -> PluginConfig:
return PluginConfig(
enabled_backends=["sqlite"],
backend_configs={
"sqlite": BackendConfig(enabled=True, path=str(Path.home() / ".hanzo" / "memory.db"), settings={})
"sqlite": BackendConfig(
enabled=True,
path=str(Path.home() / ".hanzo" / "memory.db"),
settings={},
)
},
)
+14 -4
View File
@@ -135,7 +135,9 @@ class BaseAgent(ABC, Generic[TContext]):
# Log start
if context and isinstance(context, AgentContext):
await context.log(f"Starting {self.name} with model {self.config.model}")
await context.log(
f"Starting {self.name} with model {self.config.model}"
)
# Execute with retries
result = await self._execute_with_retries(prompt, context, env, **kwargs)
@@ -153,7 +155,11 @@ class BaseAgent(ABC, Generic[TContext]):
except Exception as e:
self._end_time = datetime.now()
duration = (self._end_time - self._start_time).total_seconds() if self._start_time else None
duration = (
(self._end_time - self._start_time).total_seconds()
if self._start_time
else None
)
logger.error(f"Agent {self.name} failed: {e}")
@@ -235,7 +241,9 @@ class BaseAgent(ABC, Generic[TContext]):
if attempt < self.config.max_retries - 1:
await asyncio.sleep(2**attempt)
raise Exception(f"All {self.config.max_retries} attempts failed. Last error: {last_error}")
raise Exception(
f"All {self.config.max_retries} attempts failed. Last error: {last_error}"
)
@abstractmethod
async def _execute_impl(
@@ -331,7 +339,9 @@ class CLIAgent(BaseAgent[TContext]):
)
except asyncio.TimeoutError:
process.kill()
raise asyncio.TimeoutError(f"Command timed out after {self.config.timeout} seconds")
raise asyncio.TimeoutError(
f"Command timed out after {self.config.timeout} seconds"
)
# Check for errors
if process.returncode != 0:
+15 -5
View File
@@ -159,8 +159,12 @@ class DevServer:
# Since MCP servers run in the same process, we need to handle this differently
# For now, we'll log a message indicating a restart is needed
logger = logging.getLogger(__name__)
logger.warning("\n⚠️ Server restart required. Please restart the MCP client to reload changes.")
logger.info("💡 Tip: In development, consider using the MCP test client for easier reloading.")
logger.warning(
"\n⚠️ Server restart required. Please restart the MCP client to reload changes."
)
logger.info(
"💡 Tip: In development, consider using the MCP test client for easier reloading."
)
async def run_async(self, transport: str = "stdio"):
"""Run the development server asynchronously."""
@@ -214,8 +218,12 @@ def run_dev_server():
"""Entry point for development server."""
import argparse
parser = argparse.ArgumentParser(description="Run Hanzo AI in development mode with hot reload")
parser.add_argument("--name", type=str, default="hanzo-dev", help="Name of the MCP server")
parser = argparse.ArgumentParser(
description="Run Hanzo AI in development mode with hot reload"
)
parser.add_argument(
"--name", type=str, default="hanzo-dev", help="Name of the MCP server"
)
parser.add_argument("--project-dir", type=str, help="Project directory to serve")
parser.add_argument(
"--allowed-path",
@@ -231,7 +239,9 @@ def run_dev_server():
choices=["stdio", "sse"],
help="Transport type (default: stdio)",
)
parser.add_argument("--host", type=str, default="127.0.0.1", help="Host for SSE transport")
parser.add_argument(
"--host", type=str, default="127.0.0.1", help="Host for SSE transport"
)
parser.add_argument("--port", type=int, default=3000, help="Port for SSE transport")
args = parser.parse_args()
+66 -14
View File
@@ -36,7 +36,9 @@ class DevToolsCore:
async def edit(
self,
target: str,
op: Literal["rename", "code_action", "organize_imports", "apply_workspace_edit"],
op: Literal[
"rename", "code_action", "organize_imports", "apply_workspace_edit"
],
file: Optional[str] = None,
pos: Optional[Dict[str, int]] = None,
range_: Optional[Dict[str, Dict[str, int]]] = None,
@@ -64,7 +66,9 @@ class DevToolsCore:
await self.backend.log_execution("edit", args, result)
return result
async def fmt(self, target: str, local_prefix: Optional[str] = None, **target_opts) -> ToolResult:
async def fmt(
self, target: str, local_prefix: Optional[str] = None, **target_opts
) -> ToolResult:
"""Format code and organize imports"""
target_spec = TargetSpec(target=target, **target_opts)
@@ -90,7 +94,13 @@ class DevToolsCore:
args = {
"opts": {
k: v
for k, v in {"run": run, "count": count, "race": race, "filter": filter_, "watch": watch}.items()
for k, v in {
"run": run,
"count": count,
"race": race,
"filter": filter_,
"watch": watch,
}.items()
if v is not None
}
}
@@ -100,18 +110,30 @@ class DevToolsCore:
return result
async def build(
self, target: str, release: Optional[bool] = None, features: Optional[List[str]] = None, **target_opts
self,
target: str,
release: Optional[bool] = None,
features: Optional[List[str]] = None,
**target_opts,
) -> ToolResult:
"""Compile/build artifacts narrowly by default"""
target_spec = TargetSpec(target=target, **target_opts)
args = {"opts": {k: v for k, v in {"release": release, "features": features}.items() if v is not None}}
args = {
"opts": {
k: v
for k, v in {"release": release, "features": features}.items()
if v is not None
}
}
result = await self.backend.execute_build(target_spec, args)
await self.backend.log_execution("build", args, result)
return result
async def lint(self, target: str, fix: Optional[bool] = None, **target_opts) -> ToolResult:
async def lint(
self, target: str, fix: Optional[bool] = None, **target_opts
) -> ToolResult:
"""Lint and typecheck code"""
target_spec = TargetSpec(target=target, **target_opts)
@@ -121,7 +143,9 @@ class DevToolsCore:
await self.backend.log_execution("lint", args, result)
return result
async def guard(self, target: str, rules: Optional[List[Dict[str, Any]]] = None, **target_opts) -> ToolResult:
async def guard(
self, target: str, rules: Optional[List[Dict[str, Any]]] = None, **target_opts
) -> ToolResult:
"""Check repository invariants and boundaries"""
target_spec = TargetSpec(target=target, **target_opts)
@@ -133,13 +157,19 @@ class DevToolsCore:
# Composition patterns
async def multi_language_rename(
self, symbol_name: str, new_name: str, languages: List[str], workspace: str = "ws"
self,
symbol_name: str,
new_name: str,
languages: List[str],
workspace: str = "ws",
) -> List[ToolResult]:
"""Multi-language rename operation"""
results = []
for lang in languages:
result = await self.edit(target=workspace, op="rename", new_name=new_name, language=lang)
result = await self.edit(
target=workspace, op="rename", new_name=new_name, language=lang
)
results.append(result)
# Format changed files
@@ -156,13 +186,18 @@ class DevToolsCore:
return results
async def wide_refactor_go_workspace(self, workspace: str = "ws") -> List[ToolResult]:
async def wide_refactor_go_workspace(
self, workspace: str = "ws"
) -> List[ToolResult]:
"""Wide refactor in Go workspace"""
results = []
# Fix all and organize imports
edit_result = await self.edit(
target="pkg:./...", op="code_action", only=["source.fixAll", "source.organizeImports"], language="go"
target="pkg:./...",
op="code_action",
only=["source.fixAll", "source.organizeImports"],
language="go",
)
results.append(edit_result)
@@ -184,7 +219,12 @@ class DevToolsCore:
"glob": "sdk/**",
"forbid_import_prefix": "github.com/luxfi/node/",
},
{"id": "no_generated_edits", "type": "generated", "glob": "api/pb/**", "forbid_writes": True},
{
"id": "no_generated_edits",
"type": "generated",
"glob": "api/pb/**",
"forbid_writes": True,
},
],
)
results.append(guard_result)
@@ -321,7 +361,13 @@ async def mcp_lint(
"""MCP wrapper for lint tool"""
tools = DevToolsCore()
result = await tools.lint(
target=target, fix=fix, language=language, backend=backend, root=root, env=env or {}, dry_run=dry_run
target=target,
fix=fix,
language=language,
backend=backend,
root=root,
env=env or {},
dry_run=dry_run,
)
return asdict(result)
@@ -338,7 +384,13 @@ async def mcp_guard(
"""MCP wrapper for guard tool"""
tools = DevToolsCore()
result = await tools.guard(
target=target, rules=rules, language=language, backend=backend, root=root, env=env or {}, dry_run=dry_run
target=target,
rules=rules,
language=language,
backend=backend,
root=root,
env=env or {},
dry_run=dry_run,
)
return asdict(result)
+204 -41
View File
@@ -55,12 +55,28 @@ class ExactHanzoMCPServer:
},
"language": {
"type": "string",
"enum": ["auto", "go", "ts", "py", "rs", "cc", "sol", "schema"],
"enum": [
"auto",
"go",
"ts",
"py",
"rs",
"cc",
"sol",
"schema",
],
"default": "auto",
"description": "Language override",
},
"backend": {"type": "string", "default": "auto", "description": "Backend override"},
"root": {"type": "string", "description": "Explicit workspace root override"},
"backend": {
"type": "string",
"default": "auto",
"description": "Backend override",
},
"root": {
"type": "string",
"description": "Explicit workspace root override",
},
"env": {
"type": "object",
"additionalProperties": {"type": "string"},
@@ -73,13 +89,24 @@ class ExactHanzoMCPServer:
},
"op": {
"type": "string",
"enum": ["rename", "code_action", "organize_imports", "apply_workspace_edit"],
"enum": [
"rename",
"code_action",
"organize_imports",
"apply_workspace_edit",
],
"description": "Operation to perform",
},
"file": {"type": "string", "description": "File path for rename/code_action operations"},
"file": {
"type": "string",
"description": "File path for rename/code_action operations",
},
"pos": {
"type": "object",
"properties": {"line": {"type": "integer"}, "character": {"type": "integer"}},
"properties": {
"line": {"type": "integer"},
"character": {"type": "integer"},
},
"description": "Position for rename/code_action",
},
"range": {
@@ -87,22 +114,35 @@ class ExactHanzoMCPServer:
"properties": {
"start": {
"type": "object",
"properties": {"line": {"type": "integer"}, "character": {"type": "integer"}},
"properties": {
"line": {"type": "integer"},
"character": {"type": "integer"},
},
},
"end": {
"type": "object",
"properties": {"line": {"type": "integer"}, "character": {"type": "integer"}},
"properties": {
"line": {"type": "integer"},
"character": {"type": "integer"},
},
},
},
"description": "Range for code actions",
},
"new_name": {"type": "string", "description": "New name for rename operation"},
"new_name": {
"type": "string",
"description": "New name for rename operation",
},
"only": {
"type": "array",
"items": {"type": "string"},
"description": "LSP codeAction kinds filter",
},
"apply": {"type": "boolean", "default": True, "description": "Apply edits to disk"},
"apply": {
"type": "boolean",
"default": True,
"description": "Apply edits to disk",
},
"workspace_edit": {
"type": "object",
"description": "WorkspaceEdit payload for apply_workspace_edit",
@@ -124,7 +164,16 @@ class ExactHanzoMCPServer:
},
"language": {
"type": "string",
"enum": ["auto", "go", "ts", "py", "rs", "cc", "sol", "schema"],
"enum": [
"auto",
"go",
"ts",
"py",
"rs",
"cc",
"sol",
"schema",
],
"default": "auto",
},
"backend": {
@@ -132,7 +181,10 @@ class ExactHanzoMCPServer:
"default": "auto",
"description": "Backend override (goimports, prettier, ruff, etc.)",
},
"root": {"type": "string", "description": "Workspace root override"},
"root": {
"type": "string",
"description": "Workspace root override",
},
"env": {
"type": "object",
"additionalProperties": {"type": "string"},
@@ -166,7 +218,16 @@ class ExactHanzoMCPServer:
},
"language": {
"type": "string",
"enum": ["auto", "go", "ts", "py", "rs", "cc", "sol", "schema"],
"enum": [
"auto",
"go",
"ts",
"py",
"rs",
"cc",
"sol",
"schema",
],
"default": "auto",
},
"backend": {
@@ -174,20 +235,50 @@ class ExactHanzoMCPServer:
"default": "auto",
"description": "Backend override (go, npm, pytest, cargo, etc.)",
},
"root": {"type": "string", "description": "Workspace root override"},
"env": {"type": "object", "additionalProperties": {"type": "string"}},
"root": {
"type": "string",
"description": "Workspace root override",
},
"env": {
"type": "object",
"additionalProperties": {"type": "string"},
},
"dry_run": {"type": "boolean", "default": False},
"opts": {
"type": "object",
"properties": {
"run": {"type": "string", "description": "Go test filter"},
"count": {"type": "integer", "description": "Go test count"},
"race": {"type": "boolean", "description": "Go race detection"},
"filter": {"type": "string", "description": "TS test filter"},
"watch": {"type": "boolean", "description": "TS watch mode"},
"k": {"type": "string", "description": "Python test filter"},
"m": {"type": "string", "description": "Python test marker"},
"p": {"type": "string", "description": "Rust package"},
"run": {
"type": "string",
"description": "Go test filter",
},
"count": {
"type": "integer",
"description": "Go test count",
},
"race": {
"type": "boolean",
"description": "Go race detection",
},
"filter": {
"type": "string",
"description": "TS test filter",
},
"watch": {
"type": "boolean",
"description": "TS watch mode",
},
"k": {
"type": "string",
"description": "Python test filter",
},
"m": {
"type": "string",
"description": "Python test marker",
},
"p": {
"type": "string",
"description": "Rust package",
},
"features": {
"type": "array",
"items": {"type": "string"},
@@ -213,18 +304,43 @@ class ExactHanzoMCPServer:
},
"language": {
"type": "string",
"enum": ["auto", "go", "ts", "py", "rs", "cc", "sol", "schema"],
"enum": [
"auto",
"go",
"ts",
"py",
"rs",
"cc",
"sol",
"schema",
],
"default": "auto",
},
"backend": {"type": "string", "default": "auto", "description": "Backend override"},
"root": {"type": "string", "description": "Workspace root override"},
"env": {"type": "object", "additionalProperties": {"type": "string"}},
"backend": {
"type": "string",
"default": "auto",
"description": "Backend override",
},
"root": {
"type": "string",
"description": "Workspace root override",
},
"env": {
"type": "object",
"additionalProperties": {"type": "string"},
},
"dry_run": {"type": "boolean", "default": False},
"opts": {
"type": "object",
"properties": {
"release": {"type": "boolean", "description": "Release build"},
"features": {"type": "array", "items": {"type": "string"}},
"release": {
"type": "boolean",
"description": "Release build",
},
"features": {
"type": "array",
"items": {"type": "string"},
},
},
"description": "Build-specific options",
},
@@ -245,17 +361,39 @@ class ExactHanzoMCPServer:
},
"language": {
"type": "string",
"enum": ["auto", "go", "ts", "py", "rs", "cc", "sol", "schema"],
"enum": [
"auto",
"go",
"ts",
"py",
"rs",
"cc",
"sol",
"schema",
],
"default": "auto",
},
"backend": {"type": "string", "default": "auto", "description": "Backend override"},
"root": {"type": "string", "description": "Workspace root override"},
"env": {"type": "object", "additionalProperties": {"type": "string"}},
"backend": {
"type": "string",
"default": "auto",
"description": "Backend override",
},
"root": {
"type": "string",
"description": "Workspace root override",
},
"env": {
"type": "object",
"additionalProperties": {"type": "string"},
},
"dry_run": {"type": "boolean", "default": False},
"opts": {
"type": "object",
"properties": {
"fix": {"type": "boolean", "description": "Auto-fix issues where possible"}
"fix": {
"type": "boolean",
"description": "Auto-fix issues where possible",
}
},
"description": "Lint-specific options",
},
@@ -276,12 +414,27 @@ class ExactHanzoMCPServer:
},
"language": {
"type": "string",
"enum": ["auto", "go", "ts", "py", "rs", "cc", "sol", "schema"],
"enum": [
"auto",
"go",
"ts",
"py",
"rs",
"cc",
"sol",
"schema",
],
"default": "auto",
},
"backend": {"type": "string", "default": "auto"},
"root": {"type": "string", "description": "Workspace root override"},
"env": {"type": "object", "additionalProperties": {"type": "string"}},
"root": {
"type": "string",
"description": "Workspace root override",
},
"env": {
"type": "object",
"additionalProperties": {"type": "string"},
},
"dry_run": {"type": "boolean", "default": False},
"rules": {
"type": "array",
@@ -289,7 +442,10 @@ class ExactHanzoMCPServer:
"type": "object",
"properties": {
"id": {"type": "string"},
"type": {"type": "string", "enum": ["regex", "import", "generated"]},
"type": {
"type": "string",
"enum": ["regex", "import", "generated"],
},
"glob": {"type": "string"},
"pattern": {"type": "string"},
"forbid_import_prefix": {"type": "string"},
@@ -357,10 +513,16 @@ class ExactHanzoMCPServer:
else:
return [TextContent(type="text", text=f"Unknown tool: {name}")]
return [TextContent(type="text", text=self._format_exact_result(result, name))]
return [
TextContent(
type="text", text=self._format_exact_result(result, name)
)
]
except Exception as e:
return [TextContent(type="text", text=f"Error executing {name}: {str(e)}")]
return [
TextContent(type="text", text=f"Error executing {name}: {str(e)}")
]
def _format_exact_result(self, result, tool_name: str) -> str:
"""Format tool result according to exact specification"""
@@ -407,7 +569,8 @@ class ExactHanzoMCPServer:
server_name="hanzo-mcp-exact",
server_version="1.0.0",
capabilities=self.server.get_capabilities(
notification_options=NotificationOptions(), experimental_capabilities={}
notification_options=NotificationOptions(),
experimental_capabilities={},
),
),
)
+272 -64
View File
@@ -55,11 +55,15 @@ class ToolResult:
# Shared Input Schema
class TargetSpec(StrictModel):
target: str = Field(..., description="file:<path>, dir:<path>, pkg:<spec>, ws, or changed")
target: str = Field(
..., description="file:<path>, dir:<path>, pkg:<spec>, ws, or changed"
)
language: str = Field(default="auto", description="auto|go|ts|py|rs|cc|sol|schema")
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")
env: Dict[str, str] = Field(
default_factory=dict, description="Environment variables"
)
dry_run: bool = Field(default=False, description="Preview mode")
@@ -68,7 +72,9 @@ class EditArgs(StrictModel):
op: Literal["rename", "code_action", "organize_imports", "apply_workspace_edit"]
file: Optional[str] = None
pos: Optional[Dict[str, int]] = None # {line, character}
range: Optional[Dict[str, Dict[str, int]]] = None # {start: {line, ch}, end: {line, ch}}
range: Optional[Dict[str, Dict[str, int]]] = (
None # {start: {line, ch}, end: {line, ch}}
)
new_name: Optional[str] = None
only: List[str] = Field(default_factory=list) # LSP codeAction kinds
apply: bool = True
@@ -80,7 +86,9 @@ class FmtArgs(StrictModel):
class TestArgs(StrictModel):
opts: Dict[str, Any] = Field(default_factory=dict) # go: {run?, count?, race?}, ts: {filter?, watch?}, etc.
opts: Dict[str, Any] = Field(
default_factory=dict
) # go: {run?, count?, race?}, ts: {filter?, watch?}, etc.
class BuildArgs(StrictModel):
@@ -150,7 +158,12 @@ class WorkspaceDetector:
# Then check other workspace types
for current in parents:
if (current / "go.mod").is_file():
return {"root": str(current), "type": "go_module", "config": current / "go.mod", "language": "go"}
return {
"root": str(current),
"type": "go_module",
"config": current / "go.mod",
"language": "go",
}
elif (current / "package.json").is_file():
with open(current / "package.json") as f:
pkg_data = json.load(f)
@@ -183,13 +196,32 @@ class WorkspaceDetector:
"language": "cc",
}
elif (current / "buf.yaml").is_file() or (current / "buf.yml").is_file():
config_file = current / "buf.yaml" if (current / "buf.yaml").is_file() else current / "buf.yml"
return {"root": str(current), "type": "buf_workspace", "config": config_file, "language": "schema"}
config_file = (
current / "buf.yaml"
if (current / "buf.yaml").is_file()
else current / "buf.yml"
)
return {
"root": str(current),
"type": "buf_workspace",
"config": config_file,
"language": "schema",
}
elif (current / ".git").exists():
return {"root": str(current), "type": "git_repository", "config": current / ".git", "language": "auto"}
return {
"root": str(current),
"type": "git_repository",
"config": current / ".git",
"language": "auto",
}
# Default to current directory
return {"root": str(path), "type": "directory", "config": None, "language": "auto"}
return {
"root": str(path),
"type": "directory",
"config": None,
"language": "auto",
}
class TargetResolver:
@@ -218,7 +250,9 @@ class TargetResolver:
def _resolve_file(self, file_path: str, target_spec: TargetSpec) -> Dict[str, Any]:
"""Resolve single file target"""
path = Path(file_path).absolute()
workspace = self.workspace_detector.detect(str(path), root_hint=target_spec.root)
workspace = self.workspace_detector.detect(
str(path), root_hint=target_spec.root
)
return {
"type": "file",
@@ -230,7 +264,9 @@ class TargetResolver:
def _resolve_dir(self, dir_path: str, target_spec: TargetSpec) -> Dict[str, Any]:
"""Resolve directory subtree target"""
path = Path(dir_path).absolute()
workspace = self.workspace_detector.detect(str(path), root_hint=target_spec.root)
workspace = self.workspace_detector.detect(
str(path), root_hint=target_spec.root
)
language = self._infer_language(path, workspace, target_spec.language)
# Find relevant files based on language
@@ -240,12 +276,25 @@ class TargetResolver:
for ext in extensions:
files.extend(path.rglob(f"*{ext}"))
return {"type": "directory", "paths": [str(f) for f in files], "workspace": workspace, "language": language}
return {
"type": "directory",
"paths": [str(f) for f in files],
"workspace": workspace,
"language": language,
}
def _resolve_package(self, pkg_spec: str, target_spec: TargetSpec) -> Dict[str, Any]:
def _resolve_package(
self, pkg_spec: str, target_spec: TargetSpec
) -> Dict[str, Any]:
"""Resolve package specification"""
workspace = self.workspace_detector.detect(target_spec.root or ".", root_hint=target_spec.root)
language = workspace["language"] if target_spec.language == "auto" else target_spec.language
workspace = self.workspace_detector.detect(
target_spec.root or ".", root_hint=target_spec.root
)
language = (
workspace["language"]
if target_spec.language == "auto"
else target_spec.language
)
if language == "go":
return self._resolve_go_package(pkg_spec, workspace, target_spec)
@@ -256,12 +305,20 @@ class TargetResolver:
elif language == "rs":
return self._resolve_rust_package(pkg_spec, workspace, target_spec)
else:
raise ValueError(f"Package resolution not supported for language: {language}")
raise ValueError(
f"Package resolution not supported for language: {language}"
)
def _resolve_workspace(self, target_spec: TargetSpec) -> Dict[str, Any]:
"""Resolve workspace root"""
workspace = self.workspace_detector.detect(target_spec.root or ".", root_hint=target_spec.root)
language = workspace["language"] if target_spec.language == "auto" else target_spec.language
workspace = self.workspace_detector.detect(
target_spec.root or ".", root_hint=target_spec.root
)
language = (
workspace["language"]
if target_spec.language == "auto"
else target_spec.language
)
root = Path(workspace["root"])
extensions = self._get_extensions_for_language(language)
@@ -270,11 +327,18 @@ class TargetResolver:
for ext in extensions:
files.extend(root.rglob(f"*{ext}"))
return {"type": "workspace", "paths": [str(f) for f in files], "workspace": workspace, "language": language}
return {
"type": "workspace",
"paths": [str(f) for f in files],
"workspace": workspace,
"language": language,
}
def _resolve_changed(self, target_spec: TargetSpec) -> Dict[str, Any]:
"""Resolve git changed files"""
workspace = self.workspace_detector.detect(target_spec.root or ".", root_hint=target_spec.root)
workspace = self.workspace_detector.detect(
target_spec.root or ".", root_hint=target_spec.root
)
try:
result = subprocess.run(
@@ -284,7 +348,9 @@ class TargetResolver:
text=True,
check=True,
)
changed_files = result.stdout.strip().split("\n") if result.stdout.strip() else []
changed_files = (
result.stdout.strip().split("\n") if result.stdout.strip() else []
)
# Make paths absolute and filter existing files
root_path = Path(workspace["root"])
@@ -309,7 +375,9 @@ class TargetResolver:
"error": f"Git command failed: {e}",
}
def _resolve_go_package(self, pkg_spec: str, workspace: Dict, target_spec: TargetSpec) -> Dict[str, Any]:
def _resolve_go_package(
self, pkg_spec: str, workspace: Dict, target_spec: TargetSpec
) -> Dict[str, Any]:
"""Resolve Go package specification"""
Path(workspace["root"])
@@ -327,7 +395,14 @@ class TargetResolver:
env = os.environ.copy()
env["GOWORK"] = "auto" # Always use go.work if available
result = subprocess.run(cmd, cwd=workspace["root"], capture_output=True, text=True, env=env, check=True)
result = subprocess.run(
cmd,
cwd=workspace["root"],
capture_output=True,
text=True,
env=env,
check=True,
)
packages = result.stdout.strip().split("\n")
@@ -369,7 +444,9 @@ class TargetResolver:
"error": f"Go list failed: {e}",
}
def _resolve_ts_package(self, pkg_spec: str, workspace: Dict, target_spec: TargetSpec) -> Dict[str, Any]:
def _resolve_ts_package(
self, pkg_spec: str, workspace: Dict, target_spec: TargetSpec
) -> Dict[str, Any]:
"""Resolve TypeScript/Node package specification"""
# Handle npm/pnpm workspace filters
if pkg_spec.startswith("--filter "):
@@ -386,12 +463,16 @@ class TargetResolver:
# Default to directory-based resolution
return self._resolve_dir(pkg_spec, target_spec)
def _resolve_py_package(self, pkg_spec: str, workspace: Dict, target_spec: TargetSpec) -> Dict[str, Any]:
def _resolve_py_package(
self, pkg_spec: str, workspace: Dict, target_spec: TargetSpec
) -> Dict[str, Any]:
"""Resolve Python package specification"""
# Could use importlib or package discovery here
return self._resolve_dir(pkg_spec, target_spec)
def _resolve_rust_package(self, pkg_spec: str, workspace: Dict, target_spec: TargetSpec) -> Dict[str, Any]:
def _resolve_rust_package(
self, pkg_spec: str, workspace: Dict, target_spec: TargetSpec
) -> Dict[str, Any]:
"""Resolve Rust package specification"""
if pkg_spec.startswith("-p "):
package_name = pkg_spec[3:]
@@ -486,7 +567,9 @@ class TargetResolver:
".proto",
],
}
return extension_map.get(language, [".go", ".ts", ".js", ".py", ".rs", ".c", ".cpp"])
return extension_map.get(
language, [".go", ".ts", ".js", ".py", ".rs", ".c", ".cpp"]
)
class BackendSelector:
@@ -499,7 +582,13 @@ class BackendSelector:
return backend_hint
backend_map = {
"go": {"fmt": "goimports", "test": "go", "build": "go", "lint": "golangci-lint", "edit": "gopls"},
"go": {
"fmt": "goimports",
"test": "go",
"build": "go",
"lint": "golangci-lint",
"edit": "gopls",
},
"ts": {
"fmt": "prettier",
"test": "npm", # or pnpm/yarn
@@ -507,9 +596,27 @@ class BackendSelector:
"lint": "eslint",
"edit": "typescript-language-server",
},
"py": {"fmt": "ruff", "test": "pytest", "build": "build", "lint": "ruff", "edit": "pyright"},
"rs": {"fmt": "cargo", "test": "cargo", "build": "cargo", "lint": "cargo", "edit": "rust-analyzer"},
"cc": {"fmt": "clang-format", "test": "ctest", "build": "cmake", "lint": "clang-tidy", "edit": "clangd"},
"py": {
"fmt": "ruff",
"test": "pytest",
"build": "build",
"lint": "ruff",
"edit": "pyright",
},
"rs": {
"fmt": "cargo",
"test": "cargo",
"build": "cargo",
"lint": "cargo",
"edit": "rust-analyzer",
},
"cc": {
"fmt": "clang-format",
"test": "ctest",
"build": "cmake",
"lint": "clang-tidy",
"edit": "clangd",
},
"sol": {
"fmt": "prettier",
"test": "hardhat",
@@ -517,7 +624,13 @@ class BackendSelector:
"lint": "slither",
"edit": "solidity-language-server",
},
"schema": {"fmt": "buf", "test": "buf", "build": "buf", "lint": "buf", "edit": "buf"},
"schema": {
"fmt": "buf",
"test": "buf",
"build": "buf",
"lint": "buf",
"edit": "buf",
},
}
return backend_map.get(language, {}).get(tool, "unknown")
@@ -595,7 +708,9 @@ class LSPBridge:
errors.append(str(data["error"]))
return {"touched_files": sorted(set(touched)), "errors": errors}
async def apply_workspace_edit(self, edit: Dict[str, Any], workspace_root: str) -> Dict[str, Any]:
async def apply_workspace_edit(
self, edit: Dict[str, Any], workspace_root: str
) -> Dict[str, Any]:
"""Apply a WorkspaceEdit directly."""
applied, errors = self.tool._apply_workspace_edit(edit, workspace_root)
return {"touched_files": applied, "errors": errors}
@@ -610,7 +725,9 @@ class HanzoTools:
self.backend_selector = BackendSelector()
self.lsp_bridge = LSPBridge()
def _prepare_environment(self, workspace: Dict, target_spec: TargetSpec) -> Dict[str, str]:
def _prepare_environment(
self, workspace: Dict, target_spec: TargetSpec
) -> Dict[str, str]:
"""Prepare environment variables for tool execution"""
env = os.environ.copy()
@@ -632,7 +749,9 @@ class HanzoTools:
workspace = resolved["workspace"]
language = resolved["language"]
backend = self.backend_selector.select_backend(language, "edit", target_spec.backend)
backend = self.backend_selector.select_backend(
language, "edit", target_spec.backend
)
touched_files = []
stdout_parts = []
@@ -654,10 +773,14 @@ class HanzoTools:
)
if "error" in result:
raise RuntimeError(str(result["error"]))
touched_files = result.get("applied_files", result.get("touched_files", []))
touched_files = result.get(
"applied_files", result.get("touched_files", [])
)
errors.extend(result.get("apply_errors", []))
if apply:
stdout_parts.append(f"Renamed symbol to '{args.new_name}' in {len(touched_files)} files")
stdout_parts.append(
f"Renamed symbol to '{args.new_name}' in {len(touched_files)} files"
)
else:
stdout_parts.append(
f"Would rename symbol to '{args.new_name}' at {args.file}:{args.pos['line']}:{args.pos['character']}"
@@ -676,10 +799,14 @@ class HanzoTools:
)
if "error" in result:
raise RuntimeError(str(result["error"]))
touched_files = result.get("applied_files", result.get("touched_files", []))
touched_files = result.get(
"applied_files", result.get("touched_files", [])
)
errors.extend(result.get("apply_errors", []))
if apply:
stdout_parts.append(f"Applied code actions to {len(touched_files)} files")
stdout_parts.append(
f"Applied code actions to {len(touched_files)} files"
)
else:
stdout_parts.append(f"Would apply code actions to {args.file}")
@@ -690,20 +817,30 @@ class HanzoTools:
touched_files = result.get("touched_files", [])
errors.extend(result.get("errors", []))
if apply:
stdout_parts.append(f"Organized imports in {len(touched_files)} files")
stdout_parts.append(
f"Organized imports in {len(touched_files)} files"
)
else:
stdout_parts.append(f"Would organize imports in {len(resolved['paths'])} files")
stdout_parts.append(
f"Would organize imports in {len(resolved['paths'])} files"
)
elif args.op == "apply_workspace_edit":
if not args.workspace_edit:
raise ValueError("apply_workspace_edit requires workspace_edit")
result = await self.lsp_bridge.apply_workspace_edit(args.workspace_edit, workspace["root"])
result = await self.lsp_bridge.apply_workspace_edit(
args.workspace_edit, workspace["root"]
)
touched_files = result.get("touched_files", [])
errors.extend(result.get("errors", []))
if apply:
stdout_parts.append(f"Applied WorkspaceEdit to {len(touched_files)} files")
stdout_parts.append(
f"Applied WorkspaceEdit to {len(touched_files)} files"
)
else:
stdout_parts.append(f"Would apply WorkspaceEdit to {len(touched_files)} files")
stdout_parts.append(
f"Would apply WorkspaceEdit to {len(touched_files)} files"
)
return ToolResult(
ok=len(errors) == 0,
@@ -743,7 +880,9 @@ class HanzoTools:
workspace = resolved["workspace"]
language = resolved["language"]
backend = self.backend_selector.select_backend(language, "fmt", target_spec.backend)
backend = self.backend_selector.select_backend(
language, "fmt", target_spec.backend
)
env = self._prepare_environment(workspace, target_spec)
touched_files = []
@@ -785,14 +924,21 @@ class HanzoTools:
try:
result = subprocess.run(
cmd, cwd=workspace["root"], env=env, capture_output=True, text=True, timeout=30
cmd,
cwd=workspace["root"],
env=env,
capture_output=True,
text=True,
timeout=30,
)
if result.returncode == 0:
touched_files.append(file_path)
stdout_parts.append(f"Formatted: {file_path}")
else:
stderr_parts.append(f"Failed to format {file_path}: {result.stderr}")
stderr_parts.append(
f"Failed to format {file_path}: {result.stderr}"
)
except subprocess.TimeoutExpired:
stderr_parts.append(f"Timeout formatting {file_path}")
@@ -837,7 +983,9 @@ class HanzoTools:
workspace = resolved["workspace"]
language = resolved["language"]
backend = self.backend_selector.select_backend(language, "test", target_spec.backend)
backend = self.backend_selector.select_backend(
language, "test", target_spec.backend
)
env = self._prepare_environment(workspace, target_spec)
# Build test command based on language and scope
@@ -950,7 +1098,9 @@ class HanzoTools:
workspace = resolved["workspace"]
language = resolved["language"]
backend = self.backend_selector.select_backend(language, "build", target_spec.backend)
backend = self.backend_selector.select_backend(
language, "build", target_spec.backend
)
env = self._prepare_environment(workspace, target_spec)
# Build command based on language
@@ -996,7 +1146,14 @@ class HanzoTools:
execution_time=(datetime.utcnow() - start_time).total_seconds(),
)
result = subprocess.run(cmd, cwd=workspace["root"], env=env, capture_output=True, text=True, timeout=300)
result = subprocess.run(
cmd,
cwd=workspace["root"],
env=env,
capture_output=True,
text=True,
timeout=300,
)
return ToolResult(
ok=result.returncode == 0,
@@ -1036,7 +1193,9 @@ class HanzoTools:
workspace = resolved["workspace"]
language = resolved["language"]
backend = self.backend_selector.select_backend(language, "lint", target_spec.backend)
backend = self.backend_selector.select_backend(
language, "lint", target_spec.backend
)
env = self._prepare_environment(workspace, target_spec)
# Build lint command based on language
@@ -1094,7 +1253,14 @@ class HanzoTools:
execution_time=(datetime.utcnow() - start_time).total_seconds(),
)
result = subprocess.run(cmd, cwd=workspace["root"], env=env, capture_output=True, text=True, timeout=120)
result = subprocess.run(
cmd,
cwd=workspace["root"],
env=env,
capture_output=True,
text=True,
timeout=120,
)
# Lint tools often return non-zero for warnings
success = result.returncode == 0
@@ -1140,7 +1306,9 @@ class HanzoTools:
violations = []
for rule in args.rules:
rule_violations = await self._check_guard_rule(rule, resolved["paths"], workspace["root"], language)
rule_violations = await self._check_guard_rule(
rule, resolved["paths"], workspace["root"], language
)
violations.extend(rule_violations)
stdout_lines = []
@@ -1163,7 +1331,9 @@ class HanzoTools:
stdout="\n".join(stdout_lines),
stderr="",
exit_code=0 if len(violations) == 0 else 1,
errors=[f"{v.rule_id}: {v.file}:{v.line}:{v.column}" for v in violations],
errors=[
f"{v.rule_id}: {v.file}:{v.line}:{v.column}" for v in violations
],
violations=[v.model_dump() for v in violations],
execution_time=(datetime.utcnow() - start_time).total_seconds(),
)
@@ -1200,12 +1370,18 @@ class HanzoTools:
matched_files.append(file_path)
if rule.type == "import" and rule.forbid_import_prefix:
wants_go = language in ["go", "auto"] and any(p.endswith(".go") for p in matched_files)
wants_go = language in ["go", "auto"] and any(
p.endswith(".go") for p in matched_files
)
wants_ts = language in ["ts", "js", "auto"] and any(
p.endswith((".ts", ".tsx", ".js", ".jsx")) for p in matched_files
)
go_index = await self._go_package_index(workspace_root) if wants_go else None
ts_graph = self._build_ts_dependency_graph(workspace_root) if wants_ts else None
go_index = (
await self._go_package_index(workspace_root) if wants_go else None
)
ts_graph = (
self._build_ts_dependency_graph(workspace_root) if wants_ts else None
)
else:
go_index = None
ts_graph = None
@@ -1223,7 +1399,11 @@ class HanzoTools:
if pattern.search(line):
violations.append(
GuardViolation(
file=file_path, line=line_num, column=1, text=line.strip(), rule_id=rule.id
file=file_path,
line=line_num,
column=1,
text=line.strip(),
rule_id=rule.id,
)
)
@@ -1333,7 +1513,13 @@ class HanzoTools:
import_path = self._extract_go_import_path(line)
if import_path:
column = raw.find(import_path) + 1
imports.append({"import_path": import_path, "line": idx, "column": max(1, column)})
imports.append(
{
"import_path": import_path,
"line": idx,
"column": max(1, column),
}
)
return imports
def _extract_go_import_path(self, line: str) -> Optional[str]:
@@ -1360,7 +1546,13 @@ class HanzoTools:
if match:
module = match.group(1)
column = raw.find(module) + 1
imports.append({"import_path": module, "line": idx, "column": max(1, column)})
imports.append(
{
"import_path": module,
"line": idx,
"column": max(1, column),
}
)
return imports
def _build_ts_dependency_graph(self, workspace_root: str) -> Dict[str, Any]:
@@ -1453,7 +1645,11 @@ class HanzoTools:
import_path = pkg.get("ImportPath", "")
deps = set(pkg.get("Deps", []) or [])
pkg_deps[import_path] = deps
for name in pkg.get("GoFiles", []) + pkg.get("CgoFiles", []) + pkg.get("CompiledGoFiles", []):
for name in (
pkg.get("GoFiles", [])
+ pkg.get("CgoFiles", [])
+ pkg.get("CompiledGoFiles", [])
):
file_to_pkg[str(Path(pkg.get("Dir", "")) / name)] = import_path
return {"file_to_pkg": file_to_pkg, "pkg_deps": pkg_deps}
@@ -1470,7 +1666,9 @@ if __name__ == "__main__":
async def main():
parser = argparse.ArgumentParser(description="Hanzo MCP Tools")
parser.add_argument("tool", choices=["edit", "fmt", "test", "build", "lint", "guard"])
parser.add_argument(
"tool", choices=["edit", "fmt", "test", "build", "lint", "guard"]
)
parser.add_argument("target", help="Target specification")
parser.add_argument("--language", default="auto")
parser.add_argument("--backend", default="auto")
@@ -1482,7 +1680,12 @@ if __name__ == "__main__":
args = parser.parse_args()
target_spec = TargetSpec(target=args.target, language=args.language, backend=args.backend, dry_run=args.dry_run)
target_spec = TargetSpec(
target=args.target,
language=args.language,
backend=args.backend,
dry_run=args.dry_run,
)
if args.tool == "edit":
if not args.op:
@@ -1516,7 +1719,12 @@ if __name__ == "__main__":
elif args.tool == "guard":
# Example guard rules
example_rules = [
GuardRule(id="no-node-in-sdk", type="import", glob="sdk/**/*.py", forbid_import_prefix="node")
GuardRule(
id="no-node-in-sdk",
type="import",
glob="sdk/**/*.py",
forbid_import_prefix="node",
)
]
result = await tools.guard(target_spec, GuardArgs(rules=example_rules))
+233 -57
View File
@@ -49,7 +49,12 @@ class HanzoMCPServer:
},
"op": {
"type": "string",
"enum": ["rename", "code_action", "organize_imports", "apply_workspace_edit"],
"enum": [
"rename",
"code_action",
"organize_imports",
"apply_workspace_edit",
],
"description": "Operation to perform",
},
"language": {
@@ -57,17 +62,30 @@ class HanzoMCPServer:
"default": "auto",
"description": "Language override (auto, go, ts, py, rs, cc, sol)",
},
"backend": {"type": "string", "default": "auto", "description": "Backend override"},
"root": {"type": "string", "description": "Workspace root override"},
"backend": {
"type": "string",
"default": "auto",
"description": "Backend override",
},
"root": {
"type": "string",
"description": "Workspace root override",
},
"env": {
"type": "object",
"additionalProperties": {"type": "string"},
"description": "Environment variables",
},
"file": {"type": "string", "description": "File path for rename/code_action operations"},
"file": {
"type": "string",
"description": "File path for rename/code_action operations",
},
"pos": {
"type": "object",
"properties": {"line": {"type": "integer"}, "character": {"type": "integer"}},
"properties": {
"line": {"type": "integer"},
"character": {"type": "integer"},
},
"description": "Position for rename/code_action",
},
"range": {
@@ -75,16 +93,25 @@ class HanzoMCPServer:
"properties": {
"start": {
"type": "object",
"properties": {"line": {"type": "integer"}, "character": {"type": "integer"}},
"properties": {
"line": {"type": "integer"},
"character": {"type": "integer"},
},
},
"end": {
"type": "object",
"properties": {"line": {"type": "integer"}, "character": {"type": "integer"}},
"properties": {
"line": {"type": "integer"},
"character": {"type": "integer"},
},
},
},
"description": "Range for code actions",
},
"new_name": {"type": "string", "description": "New name for rename operation"},
"new_name": {
"type": "string",
"description": "New name for rename operation",
},
"only": {
"type": "array",
"items": {"type": "string"},
@@ -119,9 +146,20 @@ class HanzoMCPServer:
"type": "string",
"description": "Target: file:<path>, dir:<path>, pkg:<spec>, ws, or changed",
},
"language": {"type": "string", "default": "auto", "description": "Language override"},
"backend": {"type": "string", "default": "auto", "description": "Backend override"},
"root": {"type": "string", "description": "Workspace root override"},
"language": {
"type": "string",
"default": "auto",
"description": "Language override",
},
"backend": {
"type": "string",
"default": "auto",
"description": "Backend override",
},
"root": {
"type": "string",
"description": "Workspace root override",
},
"env": {
"type": "object",
"additionalProperties": {"type": "string"},
@@ -137,7 +175,11 @@ class HanzoMCPServer:
},
"description": "Tool-specific options",
},
"dry_run": {"type": "boolean", "default": False, "description": "Preview mode"},
"dry_run": {
"type": "boolean",
"default": False,
"description": "Preview mode",
},
},
"required": ["target"],
"additionalProperties": False,
@@ -153,9 +195,20 @@ class HanzoMCPServer:
"type": "string",
"description": "Target: file:<path>, dir:<path>, pkg:<spec>, ws, or changed",
},
"language": {"type": "string", "default": "auto", "description": "Language override"},
"backend": {"type": "string", "default": "auto", "description": "Backend override"},
"root": {"type": "string", "description": "Workspace root override"},
"language": {
"type": "string",
"default": "auto",
"description": "Language override",
},
"backend": {
"type": "string",
"default": "auto",
"description": "Backend override",
},
"root": {
"type": "string",
"description": "Workspace root override",
},
"env": {
"type": "object",
"additionalProperties": {"type": "string"},
@@ -164,14 +217,31 @@ class HanzoMCPServer:
"opts": {
"type": "object",
"properties": {
"run_filter": {"type": "string", "description": "Test filter pattern"},
"count": {"type": "integer", "description": "Number of times to run each test"},
"race": {"type": "boolean", "description": "Enable race detection (Go)"},
"watch": {"type": "boolean", "default": False, "description": "Watch mode"},
"run_filter": {
"type": "string",
"description": "Test filter pattern",
},
"count": {
"type": "integer",
"description": "Number of times to run each test",
},
"race": {
"type": "boolean",
"description": "Enable race detection (Go)",
},
"watch": {
"type": "boolean",
"default": False,
"description": "Watch mode",
},
},
"description": "Test-specific options",
},
"dry_run": {"type": "boolean", "default": False, "description": "Preview mode"},
"dry_run": {
"type": "boolean",
"default": False,
"description": "Preview mode",
},
},
"required": ["target"],
"additionalProperties": False,
@@ -187,9 +257,20 @@ class HanzoMCPServer:
"type": "string",
"description": "Target: file:<path>, dir:<path>, pkg:<spec>, ws, or changed",
},
"language": {"type": "string", "default": "auto", "description": "Language override"},
"backend": {"type": "string", "default": "auto", "description": "Backend override"},
"root": {"type": "string", "description": "Workspace root override"},
"language": {
"type": "string",
"default": "auto",
"description": "Language override",
},
"backend": {
"type": "string",
"default": "auto",
"description": "Backend override",
},
"root": {
"type": "string",
"description": "Workspace root override",
},
"env": {
"type": "object",
"additionalProperties": {"type": "string"},
@@ -198,7 +279,11 @@ class HanzoMCPServer:
"opts": {
"type": "object",
"properties": {
"release": {"type": "boolean", "default": False, "description": "Release build"},
"release": {
"type": "boolean",
"default": False,
"description": "Release build",
},
"features": {
"type": "array",
"items": {"type": "string"},
@@ -207,7 +292,11 @@ class HanzoMCPServer:
},
"description": "Build-specific options",
},
"dry_run": {"type": "boolean", "default": False, "description": "Preview mode"},
"dry_run": {
"type": "boolean",
"default": False,
"description": "Preview mode",
},
},
"required": ["target"],
"additionalProperties": False,
@@ -223,9 +312,20 @@ class HanzoMCPServer:
"type": "string",
"description": "Target: file:<path>, dir:<path>, pkg:<spec>, ws, or changed",
},
"language": {"type": "string", "default": "auto", "description": "Language override"},
"backend": {"type": "string", "default": "auto", "description": "Backend override"},
"root": {"type": "string", "description": "Workspace root override"},
"language": {
"type": "string",
"default": "auto",
"description": "Language override",
},
"backend": {
"type": "string",
"default": "auto",
"description": "Backend override",
},
"root": {
"type": "string",
"description": "Workspace root override",
},
"env": {
"type": "object",
"additionalProperties": {"type": "string"},
@@ -242,7 +342,11 @@ class HanzoMCPServer:
},
"description": "Lint-specific options",
},
"dry_run": {"type": "boolean", "default": False, "description": "Preview mode"},
"dry_run": {
"type": "boolean",
"default": False,
"description": "Preview mode",
},
},
"required": ["target"],
"additionalProperties": False,
@@ -258,9 +362,20 @@ class HanzoMCPServer:
"type": "string",
"description": "Target: file:<path>, dir:<path>, pkg:<spec>, ws, or changed",
},
"language": {"type": "string", "default": "auto", "description": "Language override"},
"backend": {"type": "string", "default": "auto", "description": "Backend override"},
"root": {"type": "string", "description": "Workspace root override"},
"language": {
"type": "string",
"default": "auto",
"description": "Language override",
},
"backend": {
"type": "string",
"default": "auto",
"description": "Backend override",
},
"root": {
"type": "string",
"description": "Workspace root override",
},
"env": {
"type": "object",
"additionalProperties": {"type": "string"},
@@ -272,7 +387,10 @@ class HanzoMCPServer:
"type": "object",
"properties": {
"id": {"type": "string"},
"type": {"type": "string", "enum": ["regex", "import", "generated"]},
"type": {
"type": "string",
"enum": ["regex", "import", "generated"],
},
"glob": {"type": "string"},
"pattern": {"type": "string"},
"forbid_import_prefix": {"type": "string"},
@@ -282,7 +400,11 @@ class HanzoMCPServer:
},
"description": "Guard rules to check",
},
"dry_run": {"type": "boolean", "default": False, "description": "Preview mode"},
"dry_run": {
"type": "boolean",
"default": False,
"description": "Preview mode",
},
},
"required": ["target", "rules"],
"additionalProperties": False,
@@ -304,8 +426,15 @@ class HanzoMCPServer:
"default": "symbols",
"description": "Search type",
},
"language": {"type": "string", "description": "Filter by language"},
"limit": {"type": "integer", "default": 50, "description": "Maximum results"},
"language": {
"type": "string",
"description": "Filter by language",
},
"limit": {
"type": "integer",
"default": 50,
"description": "Maximum results",
},
},
"required": ["query"],
},
@@ -316,7 +445,11 @@ class HanzoMCPServer:
inputSchema={
"type": "object",
"properties": {
"limit": {"type": "integer", "default": 10, "description": "Number of recent sessions"}
"limit": {
"type": "integer",
"default": 10,
"description": "Number of recent sessions",
}
},
},
),
@@ -346,9 +479,13 @@ class HanzoMCPServer:
only=arguments.get("only", []),
)
backend.session_manager.log_tool_execution("edit", arguments, result)
backend.session_manager.log_tool_execution(
"edit", arguments, result
)
return [TextContent(type="text", text=self._format_tool_result(result))]
return [
TextContent(type="text", text=self._format_tool_result(result))
]
elif name == "fmt":
target_spec = TargetSpec(
@@ -360,11 +497,15 @@ class HanzoMCPServer:
dry_run=arguments.get("dry_run", False),
)
result = await backend.fmt(target_spec, opts=arguments.get("opts", {}))
result = await backend.fmt(
target_spec, opts=arguments.get("opts", {})
)
backend.session_manager.log_tool_execution("fmt", arguments, result)
return [TextContent(type="text", text=self._format_tool_result(result))]
return [
TextContent(type="text", text=self._format_tool_result(result))
]
elif name == "test":
target_spec = TargetSpec(
@@ -376,11 +517,17 @@ class HanzoMCPServer:
dry_run=arguments.get("dry_run", False),
)
result = await backend.test(target_spec, opts=arguments.get("opts", {}))
result = await backend.test(
target_spec, opts=arguments.get("opts", {})
)
backend.session_manager.log_tool_execution("test", arguments, result)
backend.session_manager.log_tool_execution(
"test", arguments, result
)
return [TextContent(type="text", text=self._format_tool_result(result))]
return [
TextContent(type="text", text=self._format_tool_result(result))
]
elif name == "build":
target_spec = TargetSpec(
@@ -392,11 +539,17 @@ class HanzoMCPServer:
dry_run=arguments.get("dry_run", False),
)
result = await backend.build(target_spec, opts=arguments.get("opts", {}))
result = await backend.build(
target_spec, opts=arguments.get("opts", {})
)
backend.session_manager.log_tool_execution("build", arguments, result)
backend.session_manager.log_tool_execution(
"build", arguments, result
)
return [TextContent(type="text", text=self._format_tool_result(result))]
return [
TextContent(type="text", text=self._format_tool_result(result))
]
elif name == "lint":
target_spec = TargetSpec(
@@ -408,11 +561,17 @@ class HanzoMCPServer:
dry_run=arguments.get("dry_run", False),
)
result = await backend.lint(target_spec, opts=arguments.get("opts", {}))
result = await backend.lint(
target_spec, opts=arguments.get("opts", {})
)
backend.session_manager.log_tool_execution("lint", arguments, result)
backend.session_manager.log_tool_execution(
"lint", arguments, result
)
return [TextContent(type="text", text=self._format_tool_result(result))]
return [
TextContent(type="text", text=self._format_tool_result(result))
]
elif name == "guard":
target_spec = TargetSpec(
@@ -426,9 +585,13 @@ class HanzoMCPServer:
result = await backend.guard(target_spec, rules=arguments["rules"])
backend.session_manager.log_tool_execution("guard", arguments, result)
backend.session_manager.log_tool_execution(
"guard", arguments, result
)
return [TextContent(type="text", text=self._format_tool_result(result))]
return [
TextContent(type="text", text=self._format_tool_result(result))
]
elif name == "search_codebase":
query = arguments["query"]
@@ -439,12 +602,22 @@ class HanzoMCPServer:
if search_type == "symbols":
results = backend.indexer.search_symbols(query, language)
results_text = "\n".join(
[f"{r['path']}:{r['line']} - {r['kind']} {r['name']}" for r in results[:limit]]
[
f"{r['path']}:{r['line']} - {r['kind']} {r['name']}"
for r in results[:limit]
]
)
else:
results_text = f"Search type '{search_type}' not implemented yet"
results_text = (
f"Search type '{search_type}' not implemented yet"
)
return [TextContent(type="text", text=f"Search results for '{query}':\n{results_text}")]
return [
TextContent(
type="text",
text=f"Search results for '{query}':\n{results_text}",
)
]
elif name == "get_session_history":
limit = arguments.get("limit", 10)
@@ -461,7 +634,9 @@ class HanzoMCPServer:
return [TextContent(type="text", text=f"Unknown tool: {name}")]
except Exception as e:
return [TextContent(type="text", text=f"Error executing {name}: {str(e)}")]
return [
TextContent(type="text", text=f"Error executing {name}: {str(e)}")
]
def _format_tool_result(self, result: ToolResult) -> str:
"""Format tool result for display"""
@@ -508,7 +683,8 @@ class HanzoMCPServer:
server_name="hanzo-mcp",
server_version="1.0.0",
capabilities=self.server.get_capabilities(
notification_options=NotificationOptions(), experimental_capabilities={}
notification_options=NotificationOptions(),
experimental_capabilities={},
),
),
)
+18 -4
View File
@@ -60,7 +60,11 @@ class PluginMemoryService:
self._initialized = False
async def store_memory(
self, content: str, metadata: Dict[str, Any], user_id: str = "default", project_id: str = "default"
self,
content: str,
metadata: Dict[str, Any],
user_id: str = "default",
project_id: str = "default",
) -> str:
"""Store a memory using active plugins."""
if not self._initialized:
@@ -76,7 +80,11 @@ class PluginMemoryService:
return await plugin.store_memory(content, metadata, user_id, project_id)
async def retrieve_memory(
self, query: str, user_id: str = "default", project_id: str = "default", limit: int = 10
self,
query: str,
user_id: str = "default",
project_id: str = "default",
limit: int = 10,
) -> List[Dict[str, Any]]:
"""Retrieve memories using active plugins."""
if not self._initialized:
@@ -92,7 +100,11 @@ class PluginMemoryService:
return await plugin.retrieve_memory(query, user_id, project_id, limit)
async def search_memory(
self, query: str, user_id: str = "default", project_id: str = "default", limit: int = 10
self,
query: str,
user_id: str = "default",
project_id: str = "default",
limit: int = 10,
) -> List[Dict[str, Any]]:
"""Search memories using active plugins."""
if not self._initialized:
@@ -107,7 +119,9 @@ class PluginMemoryService:
plugin = active_plugins[0]
return await plugin.search_memory(query, user_id, project_id, limit)
async def delete_memory(self, memory_id: str, user_id: str = "default", project_id: str = "default") -> bool:
async def delete_memory(
self, memory_id: str, user_id: str = "default", project_id: str = "default"
) -> bool:
"""Delete a memory using active plugins."""
if not self._initialized:
await self.initialize()
+18 -4
View File
@@ -40,24 +40,38 @@ class MemoryBackendPlugin(Protocol):
...
async def store_memory(
self, content: str, metadata: Dict[str, Any], user_id: str = "default", project_id: str = "default"
self,
content: str,
metadata: Dict[str, Any],
user_id: str = "default",
project_id: str = "default",
) -> str:
"""Store a memory and return its ID."""
...
async def retrieve_memory(
self, query: str, user_id: str = "default", project_id: str = "default", limit: int = 10
self,
query: str,
user_id: str = "default",
project_id: str = "default",
limit: int = 10,
) -> List[Dict[str, Any]]:
"""Retrieve memories based on query."""
...
async def search_memory(
self, query: str, user_id: str = "default", project_id: str = "default", limit: int = 10
self,
query: str,
user_id: str = "default",
project_id: str = "default",
limit: int = 10,
) -> List[Dict[str, Any]]:
"""Search memories based on query with scoring."""
...
async def delete_memory(self, memory_id: str, user_id: str = "default", project_id: str = "default") -> bool:
async def delete_memory(
self, memory_id: str, user_id: str = "default", project_id: str = "default"
) -> bool:
"""Delete a memory by ID."""
...
+6 -2
View File
@@ -52,7 +52,9 @@ def create_project_system_prompt(project_path: str):
platform, _, os_version = get_os_info()
# Get directory structure
directory_structure = get_directory_structure(working_directory, max_depth=3, include_filtered=False)
directory_structure = get_directory_structure(
working_directory, max_depth=3, include_filtered=False
)
# Get git information
git_info = get_git_info(working_directory)
@@ -76,7 +78,9 @@ def create_project_system_prompt(project_path: str):
return project_system_prompt
def register_all_prompts(mcp_server: FastMCP, projects: list[str] | None = None) -> None:
def register_all_prompts(
mcp_server: FastMCP, projects: list[str] | None = None
) -> None:
@mcp_server.prompt(name="Compact current conversation")
def compact() -> str:
"""
@@ -49,9 +49,13 @@ def format_todo_list_concise(todos: list[dict[str, Any]]) -> str:
}.get(status, "[?]")
# Create priority indicator
priority_indicator = {"high": "🔴", "medium": "🟡", "low": "🟢"}.get(priority, "⚪")
priority_indicator = {"high": "🔴", "medium": "🟡", "low": "🟢"}.get(
priority, "⚪"
)
formatted_lines.append(f"{status_indicator} {priority_indicator} {content} (id: {todo_id})")
formatted_lines.append(
f"{status_indicator} {priority_indicator} {content} (id: {todo_id})"
)
return "\n".join(formatted_lines)
@@ -101,4 +105,6 @@ def get_project_todo_reminder(session_id: str | None = None) -> str:
# Format the todo list and return the reminder with content
formatted_todos = format_todo_list_concise(todos)
return PROJECT_TODO_REMINDER.format(session_id=session_id, todo_list=formatted_todos)
return PROJECT_TODO_REMINDER.format(
session_id=session_id, todo_list=formatted_todos
)
@@ -594,7 +594,9 @@ def create_tool_category_prompt(category: str, tools: list[str]):
"batch": BATCH_TOOL_EXAMPLES,
}
base_prompt = tool_descriptions.get(category, f"# {category.title()} Tools\n\nAvailable tools in this category:\n")
base_prompt = tool_descriptions.get(
category, f"# {category.title()} Tools\n\nAvailable tools in this category:\n"
)
if category not in tool_descriptions:
base_prompt += "\n".join(f"- **{tool}**: [Tool description]" for tool in tools)
+21 -7
View File
@@ -51,7 +51,9 @@ def get_os_info() -> tuple[str, str, str]:
return system, release, version
def get_directory_structure(path: str, max_depth: int = 3, include_filtered: bool = False) -> str:
def get_directory_structure(
path: str, max_depth: int = 3, include_filtered: bool = False
) -> str:
"""Get a directory structure similar to tree tool.
Args:
@@ -100,7 +102,9 @@ def get_directory_structure(path: str, max_depth: int = 3, include_filtered: boo
try:
# Sort entries: directories first, then files alphabetically
entries = sorted(current_path.iterdir(), key=lambda x: (not x.is_dir(), x.name))
entries = sorted(
current_path.iterdir(), key=lambda x: (not x.is_dir(), x.name)
)
for entry in entries:
if entry.is_dir():
@@ -143,7 +147,9 @@ def get_directory_structure(path: str, max_depth: int = 3, include_filtered: boo
# Format based on type
if item["type"] == "directory":
if "skipped" in item:
lines.append(f"{indent}{item['name']}/ [skipped - {item['skipped']}]")
lines.append(
f"{indent}{item['name']}/ [skipped - {item['skipped']}]"
)
else:
lines.append(f"{indent}{item['name']}/")
# Add children with increased indentation if present
@@ -222,7 +228,9 @@ def get_git_info(path: str) -> dict[str, str | None]:
change_type = item.change_type
status_lines.append(f"{change_type[0].upper()} {item.a_path}")
if len(staged_files) > 25:
status_lines.append(f"... and {len(staged_files) - 25} more staged files")
status_lines.append(
f"... and {len(staged_files) - 25} more staged files"
)
# Check for unstaged changes
unstaged_files = list(repo.index.diff(None))
@@ -230,7 +238,9 @@ def get_git_info(path: str) -> dict[str, str | None]:
for item in unstaged_files[:25]: # Limit to first 25
status_lines.append(f"M {item.a_path}")
if len(unstaged_files) > 25:
status_lines.append(f"... and {len(unstaged_files) - 25} more modified files")
status_lines.append(
f"... and {len(unstaged_files) - 25} more modified files"
)
# Check for untracked files
untracked_files = repo.untracked_files
@@ -238,9 +248,13 @@ def get_git_info(path: str) -> dict[str, str | None]:
for file in untracked_files[:25]: # Limit to first 25
status_lines.append(f"?? {file}")
if len(untracked_files) > 25:
status_lines.append(f"... and {len(untracked_files) - 25} more untracked files")
status_lines.append(
f"... and {len(untracked_files) - 25} more untracked files"
)
git_status = "\n".join(status_lines) if status_lines else "Working tree clean"
git_status = (
"\n".join(status_lines) if status_lines else "Working tree clean"
)
except Exception:
git_status = "Unable to get git status"
+12 -4
View File
@@ -21,7 +21,9 @@ if TYPE_CHECKING:
# Suppress litellm deprecation warnings about event loop
warnings.filterwarnings("ignore", message="There is no current event loop", category=DeprecationWarning)
warnings.filterwarnings(
"ignore", message="There is no current event loop", category=DeprecationWarning
)
# Cached imports - lazy loaded on first use
_FastMCP = None
@@ -162,8 +164,12 @@ class HanzoMCPServer:
# Generate a secure random token if none provided
self.auth_token = secrets.token_urlsafe(32)
logger = logging.getLogger(__name__)
logger.warning(f"No auth token provided. Generated token: {self.auth_token}")
logger.warning("Set HANZO_MCP_TOKEN environment variable for persistent auth")
logger.warning(
f"No auth token provided. Generated token: {self.auth_token}"
)
logger.warning(
"Set HANZO_MCP_TOKEN environment variable for persistent auth"
)
# Initialize permissions and command executor
PermissionManager = _get_permission_manager()
@@ -268,7 +274,9 @@ class HanzoMCPServer:
signal.signal(signal.SIGINT, signal_handler)
# Start background cleanup thread for periodic cleanup
self._cleanup_thread = threading.Thread(target=self._background_cleanup, daemon=True)
self._cleanup_thread = threading.Thread(
target=self._background_cleanup, daemon=True
)
self._cleanup_thread.start()
def _background_cleanup(self) -> None:
+12 -4
View File
@@ -97,9 +97,13 @@ def register_all_tools(
# Filesystem tools
for tool in PACKAGE_TOOL_PREFIXES.get("filesystem", []):
if tool in ["write", "edit", "multi_edit"]:
resolved_enabled_tools[tool] = is_tool_enabled(tool, not disable_write_tools)
resolved_enabled_tools[tool] = is_tool_enabled(
tool, not disable_write_tools
)
elif tool in ["ast", "search"]:
resolved_enabled_tools[tool] = is_tool_enabled(tool, not disable_search_tools)
resolved_enabled_tools[tool] = is_tool_enabled(
tool, not disable_search_tools
)
else:
resolved_enabled_tools[tool] = is_tool_enabled(tool, True)
@@ -132,7 +136,9 @@ def register_all_tools(
resolved_enabled_tools[tool] = is_tool_enabled(tool, True)
# Agent tools
resolved_enabled_tools["agent"] = enable_agent_tool or is_tool_enabled("agent", False)
resolved_enabled_tools["agent"] = enable_agent_tool or is_tool_enabled(
"agent", False
)
resolved_enabled_tools["swarm"] = is_tool_enabled("swarm", False)
for tool in ["claude", "codex", "gemini", "grok", "code_auth"]:
resolved_enabled_tools[tool] = is_tool_enabled(tool, enable_agent_tool)
@@ -170,7 +176,9 @@ def register_all_tools(
discovered = loader.discover_packages()
if discovered:
logger.info(f"Discovered {len(discovered)} tool packages: {', '.join(discovered.keys())}")
logger.info(
f"Discovered {len(discovered)} tool packages: {', '.join(discovered.keys())}"
)
# Load all discovered tools
loaded = loader.load_all(
+16 -4
View File
@@ -123,7 +123,9 @@ class BuildTool(DevToolBase):
language_used=self.language,
backend_used=self.backend,
scope_resolved=self.target,
errors=["No build configuration found (tsconfig.json or package.json build script)"],
errors=[
"No build configuration found (tsconfig.json or package.json build script)"
],
)
async def _build_python(self) -> DevResult:
@@ -212,7 +214,9 @@ class BuildTool(DevToolBase):
language_used=self.language,
backend_used=self.backend,
scope_resolved=self.target,
errors=["No build system detected (CMakeLists.txt, build.ninja, or Makefile)"],
errors=[
"No build system detected (CMakeLists.txt, build.ninja, or Makefile)"
],
)
async def _build_solidity(self) -> DevResult:
@@ -328,7 +332,9 @@ class BuildTool(DevToolBase):
async def _build_with_ninja(self) -> DevResult:
"""Build with Ninja"""
result = self._run_command(["ninja"], cwd=str(Path(self.workspace["root"]) / "build"))
result = self._run_command(
["ninja"], cwd=str(Path(self.workspace["root"]) / "build")
)
return create_dev_result(
ok=result.returncode == 0,
@@ -426,7 +432,13 @@ async def build_tool_handler(
"""MCP handler for build tool"""
tool = BuildTool(
target=target, language=language, backend=backend, root=root, env=env, dry_run=dry_run, opts=opts or {}
target=target,
language=language,
backend=backend,
root=root,
env=env,
dry_run=dry_run,
opts=opts or {},
)
result = await tool.execute()
@@ -78,7 +78,13 @@ class MCPToolTimeoutManager:
return self.timeout
async def _background_tool_execution(
self, tool_func: Callable, tool_name: str, ctx: MCPContext, process_id: str, log_file: Path, **params: Any
self,
tool_func: Callable,
tool_name: str,
ctx: MCPContext,
process_id: str,
log_file: Path,
**params: Any,
) -> None:
"""Execute tool in background and log results.
@@ -131,7 +137,9 @@ class MCPToolTimeoutManager:
self.process_manager.mark_completed(process_id, 1)
def with_auto_timeout(tool_name: str, timeout_manager: Optional[MCPToolTimeoutManager] = None):
def with_auto_timeout(
tool_name: str, timeout_manager: Optional[MCPToolTimeoutManager] = None
):
"""Decorator to add automatic timeout and backgrounding to MCP tools.
Args:
@@ -153,11 +161,13 @@ def with_auto_timeout(tool_name: str, timeout_manager: Optional[MCPToolTimeoutMa
# Method call: self, ctx, **params
self_or_ctx = args[0]
ctx = args[1]
def call_func():
return func(self_or_ctx, ctx, **params)
elif len(args) == 1:
# Function call: ctx, **params
ctx = args[0]
def call_func():
return func(ctx, **params)
else:
@@ -186,7 +196,9 @@ def with_auto_timeout(tool_name: str, timeout_manager: Optional[MCPToolTimeoutMa
return f"Operation timed out after {timeout_formatted}. Backgrounding unavailable."
process_id = f"{tool_name}_{uuid.uuid4().hex[:8]}"
log_file = await timeout_manager.process_manager.create_log_file(process_id)
log_file = await timeout_manager.process_manager.create_log_file(
process_id
)
# Start background execution (need to reconstruct the call)
async def background_call():
+6 -1
View File
@@ -49,7 +49,12 @@ def with_error_logging(tool_name: str) -> Callable:
log_call_signature_error(tool_name, expected, actual, e)
# Log the error
log_tool_error(tool_name, e, params=kwargs, context="Call signature mismatch or type error")
log_tool_error(
tool_name,
e,
params=kwargs,
context="Call signature mismatch or type error",
)
# Return user-friendly error message
return (
@@ -126,7 +126,9 @@ class CLIToolFactory:
)
try:
stdout, stderr = await asyncio.wait_for(proc.communicate(), timeout=effective_timeout)
stdout, stderr = await asyncio.wait_for(
proc.communicate(), timeout=effective_timeout
)
except asyncio.TimeoutError:
proc.kill()
await proc.wait()
@@ -149,7 +151,8 @@ class CLIToolFactory:
# Register with MCP server
try:
self._mcp_server.tool(
name=tool_def.name, description=f"{tool_def.description}\n\nCommand: {tool_def.command}"
name=tool_def.name,
description=f"{tool_def.description}\n\nCommand: {tool_def.command}",
)(cli_handler)
self._registered_handlers[tool_def.name] = cli_handler
logger.info(f"Registered dynamic CLI tool: {tool_def.name}")
@@ -252,7 +255,11 @@ class CLIToolFactory:
self._save_config()
# Note: Can't unregister from FastMCP, but tool won't be re-registered on restart
return {"success": True, "name": name, "message": "Tool disabled. Restart server to remove."}
return {
"success": True,
"name": name,
"message": "Tool disabled. Restart server to remove.",
}
async def get_help(self, name: str) -> str:
"""Get help text for a CLI tool by running --help."""
@@ -305,7 +312,10 @@ Examples:
)
return json.dumps(result, indent=2)
@mcp_server.tool(name="cli_list", description="List all dynamic CLI tools that have been created.")
@mcp_server.tool(
name="cli_list",
description="List all dynamic CLI tools that have been created.",
)
async def cli_list() -> str:
tools = factory.list()
if not tools:
@@ -323,7 +333,9 @@ Examples:
result = factory.remove(name)
return json.dumps(result, indent=2)
@mcp_server.tool(name="cli_help", description="Get help text for a CLI tool by running --help.")
@mcp_server.tool(
name="cli_help", description="Get help text for a CLI tool by running --help."
)
async def cli_help(name: str) -> str:
return await factory.get_help(name)
@@ -67,7 +67,9 @@ class ToolContext:
"""
return self._mcp_context.client_id
async def set_tool_info(self, tool_name: str, execution_id: str | None = None) -> None:
async def set_tool_info(
self, tool_name: str, execution_id: str | None = None
) -> None:
"""Set information about the currently executing tool.
Args:
@@ -120,7 +120,9 @@ def _is_valid_context(ctx: Any) -> bool:
)
def mcp_tool(server: Any, name: str | None = None, description: str | None = None) -> Callable[[F], F]:
def mcp_tool(
server: Any, name: str | None = None, description: str | None = None
) -> Callable[[F], F]:
"""Enhanced MCP tool decorator that includes context normalization.
This decorator combines the standard MCP tool registration with
@@ -190,7 +192,9 @@ def create_tool_handler(server: Any, tool: Any) -> Callable[[], None]:
# Apply context normalization
normalized = with_context_normalization(func)
# Apply original decorator
return original_tool_decorator(name=name, description=description)(normalized)
return original_tool_decorator(name=name, description=description)(
normalized
)
return decorator
@@ -96,4 +96,6 @@ class AutoRegisterTool(BaseTool, ABC):
normalized_handler = with_context_normalization(handler)
# Register with the server
mcp_server.tool(name=self.name, description=self.description)(normalized_handler)
mcp_server.tool(name=self.name, description=self.description)(
normalized_handler
)
@@ -37,7 +37,11 @@ PACKAGE_TOOL_PREFIXES: dict[str, list[str]] = {
"lsp": ["lsp"],
"refactor": ["refactor"],
"database": ["sql", "graph"], # Consolidated database tools
"agent": ["agent", "iching", "review"], # Consolidated agent tools (critic is from reasoning)
"agent": [
"agent",
"iching",
"review",
], # Consolidated agent tools (critic is from reasoning)
"jupyter": ["jupyter"],
"editor": ["neovim_edit", "neovim_command", "neovim_session"],
"llm": ["llm", "consensus"], # Removed llm_manage
@@ -125,7 +129,9 @@ class EntryPointToolLoader:
discovered[ep.name] = tool_names
self._discovered_packages[ep.name] = tools_list
logger.debug(f"Discovered package '{ep.name}' with tools: {tool_names}")
logger.debug(
f"Discovered package '{ep.name}' with tools: {tool_names}"
)
except Exception as e:
logger.warning(f"Failed to load entry point '{ep.name}': {e}")
@@ -230,7 +236,9 @@ class EntryPointToolLoader:
# Build package-specific enabled_tools
package_tool_names = PACKAGE_TOOL_PREFIXES.get(package_name, [])
package_enabled = {name: enabled_tools.get(name, True) for name in package_tool_names}
package_enabled = {
name: enabled_tools.get(name, True) for name in package_tool_names
}
self.load_package(
package_name,
@@ -57,7 +57,8 @@ class MCPErrorLogger:
# Formatter
formatter = logging.Formatter(
"%(asctime)s - %(name)s - %(levelname)s - %(message)s", datefmt="%Y-%m-%d %H:%M:%S"
"%(asctime)s - %(name)s - %(levelname)s - %(message)s",
datefmt="%Y-%m-%d %H:%M:%S",
)
file_handler.setFormatter(formatter)
general_handler.setFormatter(formatter)
@@ -92,7 +93,9 @@ class MCPErrorLogger:
}
# Write to JSON log for structured parsing
json_log_file = self.log_dir / f"tool-errors-{datetime.now().strftime('%Y-%m-%d')}.jsonl"
json_log_file = (
self.log_dir / f"tool-errors-{datetime.now().strftime('%Y-%m-%d')}.jsonl"
)
try:
with open(json_log_file, "a") as f:
json.dump(error_data, f)
@@ -120,7 +123,9 @@ class MCPErrorLogger:
if context:
f.write(f"Context: {context}\n")
if params:
f.write(f"\nParameters:\n{json.dumps(error_data['params'], indent=2)}\n")
f.write(
f"\nParameters:\n{json.dumps(error_data['params'], indent=2)}\n"
)
f.write(f"\nTraceback:\n{error_data['traceback']}\n")
except Exception as e:
self.logger.error(f"Failed to write tool-specific log: {e}")
@@ -167,7 +172,9 @@ class MCPErrorLogger:
# Also log as regular tool error
self.log_tool_error(
tool_name, error, context=f"Call signature mismatch - Expected: {expected_signature}, Got: {actual_call}"
tool_name,
error,
context=f"Call signature mismatch - Expected: {expected_signature}, Got: {actual_call}",
)
def _sanitize_params(self, params: dict[str, Any]) -> dict[str, Any]:
@@ -208,7 +215,9 @@ class MCPErrorLogger:
return sanitized
def get_recent_errors(self, tool_name: Optional[str] = None, limit: int = 10) -> list[dict]:
def get_recent_errors(
self, tool_name: Optional[str] = None, limit: int = 10
) -> list[dict]:
"""Get recent errors from the JSON log.
Args:
@@ -143,7 +143,9 @@ class FastMCPPaginator(Generic[T]):
cursor_data = CursorData.from_cursor(cursor) if cursor else CursorData()
# Use provided page size or default
actual_page_size = min(page_size or cursor_data.page_size or self.page_size, self.max_page_size)
actual_page_size = min(
page_size or cursor_data.page_size or self.page_size, self.max_page_size
)
# Get starting position
start_idx = cursor_data.offset
@@ -205,7 +207,9 @@ class FastMCPPaginator(Generic[T]):
cursor_data = CursorData.from_cursor(cursor) if cursor else CursorData()
# Determine page size
limit = min(page_size or cursor_data.page_size or self.page_size, self.max_page_size)
limit = min(
page_size or cursor_data.page_size or self.page_size, self.max_page_size
)
# Execute query with cursor position
results = query_func(
@@ -254,7 +258,9 @@ class TokenAwarePaginator:
self.max_tokens = max_tokens
self.paginator = FastMCPPaginator()
def paginate_by_tokens(self, items: List[Any], cursor: Optional[str] = None, estimate_func=None) -> Dict[str, Any]:
def paginate_by_tokens(
self, items: List[Any], cursor: Optional[str] = None, estimate_func=None
) -> Dict[str, Any]:
"""Paginate items based on token count.
Args:
@@ -269,6 +275,7 @@ class TokenAwarePaginator:
# Default token estimation
if not estimate_func:
def estimate_func(x):
return estimate_tokens(json.dumps(x) if not isinstance(x, str) else x)
@@ -42,7 +42,9 @@ class ForgivingEditHelper:
return "\n".join(lines)
@staticmethod
def find_fuzzy_match(haystack: str, needle: str, threshold: float = 0.85) -> Optional[Tuple[int, int, str]]:
def find_fuzzy_match(
haystack: str, needle: str, threshold: float = 0.85
) -> Optional[Tuple[int, int, str]]:
"""Find a fuzzy match for the needle in the haystack.
Args:
@@ -79,9 +81,13 @@ class ForgivingEditHelper:
# Find end position by counting lines in needle
needle_lines = norm_needle.count("\n") + 1
end_pos = sum(len(line) + 1 for line in original_lines[: lines_before + needle_lines])
end_pos = sum(
len(line) + 1 for line in original_lines[: lines_before + needle_lines]
)
matched = "\n".join(original_lines[lines_before : lines_before + needle_lines])
matched = "\n".join(
original_lines[lines_before : lines_before + needle_lines]
)
return (start_pos, end_pos - 1, matched)
# Try fuzzy matching on lines
@@ -113,7 +119,9 @@ class ForgivingEditHelper:
candidate_norm = ForgivingEditHelper.normalize_whitespace(candidate)
needle_norm = ForgivingEditHelper.normalize_whitespace(needle)
ratio = difflib.SequenceMatcher(None, candidate_norm, needle_norm).ratio()
ratio = difflib.SequenceMatcher(
None, candidate_norm, needle_norm
).ratio()
if ratio >= threshold:
start_pos = sum(len(l) + 1 for l in haystack_lines[:i])
@@ -122,7 +130,9 @@ class ForgivingEditHelper:
return None
@staticmethod
def suggest_matches(haystack: str, needle: str, max_suggestions: int = 3) -> List[Tuple[float, str]]:
def suggest_matches(
haystack: str, needle: str, max_suggestions: int = 3
) -> List[Tuple[float, str]]:
"""Suggest possible matches when exact match fails.
Args:
@@ -144,7 +154,9 @@ class ForgivingEditHelper:
for line in haystack.split("\n"):
if line.strip(): # Skip empty lines
line_norm = ForgivingEditHelper.normalize_whitespace(line)
ratio = difflib.SequenceMatcher(None, line_norm, needle_norm).ratio()
ratio = difflib.SequenceMatcher(
None, line_norm, needle_norm
).ratio()
if ratio > 0.5: # Only reasonably similar lines
suggestions.append((ratio, line))
@@ -158,7 +170,9 @@ class ForgivingEditHelper:
candidate = "\n".join(candidate_lines)
candidate_norm = ForgivingEditHelper.normalize_whitespace(candidate)
ratio = difflib.SequenceMatcher(None, candidate_norm, needle_norm).ratio()
ratio = difflib.SequenceMatcher(
None, candidate_norm, needle_norm
).ratio()
if ratio > 0.5:
suggestions.append((ratio, candidate))
@@ -167,7 +181,9 @@ class ForgivingEditHelper:
return suggestions[:max_suggestions]
@staticmethod
def create_edit_suggestion(file_content: str, old_string: str, new_string: str) -> dict:
def create_edit_suggestion(
file_content: str, old_string: str, new_string: str
) -> dict:
"""Create a helpful edit suggestion when match fails.
Args:
@@ -196,7 +212,10 @@ class ForgivingEditHelper:
if suggestions:
return {
"error": "Could not find exact or fuzzy match",
"suggestions": [{"similarity": f"{score:.0%}", "text": text} for score, text in suggestions],
"suggestions": [
{"similarity": f"{score:.0%}", "text": text}
for score, text in suggestions
],
"hint": "Try using one of these suggestions as old_string",
}
+5 -1
View File
@@ -131,7 +131,11 @@ def register_default_modes():
def get_mode_from_env() -> Optional[str]:
"""Get mode name from environment variables."""
# Check for HANZO_MODE, PERSONALITY, or MODE env vars
return os.environ.get("HANZO_MODE") or os.environ.get("PERSONALITY") or os.environ.get("MODE")
return (
os.environ.get("HANZO_MODE")
or os.environ.get("PERSONALITY")
or os.environ.get("MODE")
)
def activate_mode_from_env():
@@ -70,7 +70,11 @@ class PaginatedBaseTool(BaseTool):
if cursor:
# For continuation, check if we have cached results
cursor_data = CursorManager.parse_cursor(cursor)
if cursor_data and "tool" in cursor_data and cursor_data["tool"] != self.name:
if (
cursor_data
and "tool" in cursor_data
and cursor_data["tool"] != self.name
):
return {"error": "Cursor is for a different tool", "code": -32602}
# Execute the tool
@@ -88,11 +92,15 @@ class PaginatedBaseTool(BaseTool):
if isinstance(paginated_result, dict) and "nextCursor" in paginated_result:
# Enhance the cursor with tool information
if "nextCursor" in paginated_result:
cursor_data = CursorManager.parse_cursor(paginated_result["nextCursor"])
cursor_data = CursorManager.parse_cursor(
paginated_result["nextCursor"]
)
if cursor_data:
cursor_data["tool"] = self.name
cursor_data["params"] = params # Store params for continuation
paginated_result["nextCursor"] = CursorManager.create_cursor(cursor_data)
paginated_result["nextCursor"] = CursorManager.create_cursor(
cursor_data
)
return paginated_result
else:
@@ -35,21 +35,31 @@ class AutoPaginatedResponse:
"""
# Handle different content types
if isinstance(content, str):
return AutoPaginatedResponse._handle_string_response(content, cursor, max_tokens)
return AutoPaginatedResponse._handle_string_response(
content, cursor, max_tokens
)
elif isinstance(content, list):
return AutoPaginatedResponse._handle_list_response(content, cursor, max_tokens)
return AutoPaginatedResponse._handle_list_response(
content, cursor, max_tokens
)
elif isinstance(content, dict):
# If dict already has pagination info, return as-is
if "nextCursor" in content or "cursor" in content:
return content
# Otherwise treat as single item
return AutoPaginatedResponse._handle_dict_response(content, cursor, max_tokens)
return AutoPaginatedResponse._handle_dict_response(
content, cursor, max_tokens
)
else:
# Convert to string for other types
return AutoPaginatedResponse._handle_string_response(str(content), cursor, max_tokens)
return AutoPaginatedResponse._handle_string_response(
str(content), cursor, max_tokens
)
@staticmethod
def _handle_string_response(content: str, cursor: Optional[str], max_tokens: int) -> Dict[str, Any]:
def _handle_string_response(
content: str, cursor: Optional[str], max_tokens: int
) -> Dict[str, Any]:
"""Handle pagination for string responses."""
# Parse cursor to get offset
offset = 0
@@ -114,7 +124,9 @@ class AutoPaginatedResponse:
return response
@staticmethod
def _handle_list_response(items: List[Any], cursor: Optional[str], max_tokens: int) -> Dict[str, Any]:
def _handle_list_response(
items: List[Any], cursor: Optional[str], max_tokens: int
) -> Dict[str, Any]:
"""Handle pagination for list responses."""
# Parse cursor to get offset
offset = 0
@@ -152,7 +164,9 @@ class AutoPaginatedResponse:
truncated = item[:5000] + "... [truncated]"
result_items.append(truncated)
else:
result_items.append({"error": "Item too large", "index": item_index})
result_items.append(
{"error": "Item too large", "index": item_index}
)
item_index += 1
break
@@ -185,7 +199,9 @@ class AutoPaginatedResponse:
return response
@staticmethod
def _handle_dict_response(content: Dict[str, Any], cursor: Optional[str], max_tokens: int) -> Dict[str, Any]:
def _handle_dict_response(
content: Dict[str, Any], cursor: Optional[str], max_tokens: int
) -> Dict[str, Any]:
"""Handle pagination for dict responses."""
# For dicts, check if it's too large as-is
content_str = json.dumps(content, indent=2)
@@ -264,13 +280,17 @@ def paginate_if_needed(
Original response if small enough, otherwise paginated dict
"""
# Quick check - if response is already paginated, return as-is
if isinstance(response, dict) and ("nextCursor" in response or "pagination_info" in response):
if isinstance(response, dict) and (
"nextCursor" in response or "pagination_info" in response
):
return response
# For small responses, don't paginate unless forced
if not force_pagination:
try:
response_str = json.dumps(response) if not isinstance(response, str) else response
response_str = (
json.dumps(response) if not isinstance(response, str) else response
)
if len(response_str) < 10000: # Quick heuristic
return response
except Exception:
@@ -154,7 +154,9 @@ class StreamPaginator(Generic[T]):
"""
self.page_size = page_size
def paginate_stream(self, stream_generator, cursor: Optional[str] = None) -> PaginatedResponse[T]:
def paginate_stream(
self, stream_generator, cursor: Optional[str] = None
) -> PaginatedResponse[T]:
"""Paginate results from a stream/generator.
Args:
@@ -183,14 +185,18 @@ class StreamPaginator(Generic[T]):
items.append(item)
if len(items) >= self.page_size:
# We have a full page, create cursor for next page
next_cursor = CursorManager.create_cursor({"skip": skip_count + len(items)})
next_cursor = CursorManager.create_cursor(
{"skip": skip_count + len(items)}
)
return PaginatedResponse(items=items, next_cursor=next_cursor)
# No more items
return PaginatedResponse(items=items, next_cursor=None)
def paginate_list(items: List[T], cursor: Optional[str] = None, page_size: int = 100) -> PaginatedResponse[T]:
def paginate_list(
items: List[T], cursor: Optional[str] = None, page_size: int = 100
) -> PaginatedResponse[T]:
"""Convenience function to paginate a list.
Args:
@@ -149,7 +149,9 @@ class PermissionManager(BasePermissionManager):
# Security check: Ensure resolved path doesn't escape allowed directories
# by checking if it's actually under an allowed path after resolution
original_path = Path(path)
if original_path.is_absolute() and str(resolved_path) != str(original_path.resolve(strict=False)):
if original_path.is_absolute() and str(resolved_path) != str(
original_path.resolve(strict=False)
):
# Path resolution changed the path significantly, might be symlink attack
# Additional check: is the resolved path still under allowed paths?
pass # Continue to normal checks
@@ -172,7 +174,10 @@ class PermissionManager(BasePermissionManager):
link_target = Path(os.readlink(resolved_path))
if link_target.is_absolute():
# Absolute symlink - check if it points within allowed paths
if not any(self._is_subpath(link_target, ap) for ap in self.allowed_paths):
if not any(
self._is_subpath(link_target, ap)
for ap in self.allowed_paths
):
return False
return True
except ValueError:
@@ -277,9 +282,13 @@ class PermissibleOperation:
"""
self.permission_manager: PermissionManager = permission_manager
self.operation: str = operation
self.get_path_fn: Callable[[list[Any], dict[str, Any]], str] | None = get_path_fn
self.get_path_fn: Callable[[list[Any], dict[str, Any]], str] | None = (
get_path_fn
)
def __call__(self, func: Callable[..., Awaitable[T]]) -> Callable[..., Awaitable[T]]:
def __call__(
self, func: Callable[..., Awaitable[T]]
) -> Callable[..., Awaitable[T]]:
"""Decorate the function.
Args:
@@ -303,7 +312,9 @@ class PermissibleOperation:
# Check permission
if not self.permission_manager.is_path_allowed(path):
raise PermissionError(f"Operation '{self.operation}' not allowed for path: {path}")
raise PermissionError(
f"Operation '{self.operation}' not allowed for path: {path}"
)
# Call the function
return await func(*args, **kwargs)
@@ -26,7 +26,10 @@ logger = logging.getLogger(__name__)
# Tool mappings for persona categories
CATEGORY_TOOL_MAPPINGS = {
"programmer": ESSENTIAL_TOOLS + ["symbols", "multi_edit"] + SEARCH_TOOLS + BUILD_TOOLS,
"programmer": ESSENTIAL_TOOLS
+ ["symbols", "multi_edit"]
+ SEARCH_TOOLS
+ BUILD_TOOLS,
"scientist": ESSENTIAL_TOOLS + ["jupyter", "critic"] + AI_TOOLS + VECTOR_TOOLS,
"philosopher": ESSENTIAL_TOOLS + ["critic", "think", "search"],
"artist": ESSENTIAL_TOOLS + ["watch", "jupyter"] + AI_TOOLS,
@@ -35,7 +38,10 @@ CATEGORY_TOOL_MAPPINGS = {
"mathematician": ESSENTIAL_TOOLS + ["jupyter", "symbols", "critic"],
"musician": ESSENTIAL_TOOLS + ["watch", "todo"],
"athlete": ESSENTIAL_TOOLS + ["todo", "watch", "process"],
"entrepreneur": ESSENTIAL_TOOLS + ["todo", "agent", "consensus"] + BUILD_TOOLS + DATABASE_TOOLS,
"entrepreneur": ESSENTIAL_TOOLS
+ ["todo", "agent", "consensus"]
+ BUILD_TOOLS
+ DATABASE_TOOLS,
"activist": ESSENTIAL_TOOLS + ["search", "todo", "rules"],
"religious_leader": ESSENTIAL_TOOLS + ["search", "critic", "think"],
"military_leader": ESSENTIAL_TOOLS + ["todo", "process", "critic"] + UNIX_TOOLS,
@@ -71,7 +77,9 @@ def persona_to_tool_personality(persona: Dict[str, Any]) -> Optional[ToolPersona
# Determine tools based on category and persona's tool preferences
category = persona.get("category", "default")
base_tools = CATEGORY_TOOL_MAPPINGS.get(category, CATEGORY_TOOL_MAPPINGS["default"])
base_tools = CATEGORY_TOOL_MAPPINGS.get(
category, CATEGORY_TOOL_MAPPINGS["default"]
)
# Add tools from persona's tool preferences
tools_config = persona.get("tools", {})
@@ -158,13 +158,17 @@ def register_default_personalities() -> None:
if loaded > 0:
import logging
logging.getLogger(__name__).debug(f"Loaded {loaded} personas from hanzo-persona package")
logging.getLogger(__name__).debug(
f"Loaded {loaded} personas from hanzo-persona package"
)
except ImportError:
pass
except Exception as e:
import logging
logging.getLogger(__name__).warning(f"Failed to load personas from package: {e}")
logging.getLogger(__name__).warning(
f"Failed to load personas from package: {e}"
)
def _register_builtin_personalities() -> None:
@@ -219,7 +223,16 @@ def _register_builtin_personalities() -> None:
+ BUILD_TOOLS
+ UNIX_TOOLS
+ VECTOR_TOOLS
+ ["todo", "rules", "browser", "jupyter", "neovim_edit", "mcp", "refactor", "lsp"]
+ [
"todo",
"rules",
"browser",
"jupyter",
"neovim_edit",
"mcp",
"refactor",
"lsp",
]
)
),
environment={"ALL_TOOLS": "enabled"},
@@ -229,7 +242,9 @@ def _register_builtin_personalities() -> None:
programmer="DevOps Engineer",
description="Automate everything",
philosophy="You build it, you run it.",
tools=list(set(ESSENTIAL_TOOLS + BUILD_TOOLS + UNIX_TOOLS + ["todo", "browser"])),
tools=list(
set(ESSENTIAL_TOOLS + BUILD_TOOLS + UNIX_TOOLS + ["todo", "browser"])
),
environment={"CI_CD": "enabled"},
cli_tools=[
CLIToolDef("docker", "docker", "Docker container management"),
@@ -88,7 +88,12 @@ class PluginLoader:
# Find tool classes
for _name, obj in inspect.getmembers(module):
if inspect.isclass(obj) and issubclass(obj, BaseTool) and obj != BaseTool and hasattr(obj, "name"):
if (
inspect.isclass(obj)
and issubclass(obj, BaseTool)
and obj != BaseTool
and hasattr(obj, "name")
):
# Load metadata if available
metadata = None
metadata_file = file_path.with_suffix(".json")
@@ -129,8 +134,15 @@ class PluginLoader:
else:
# Search for tool classes
for _name, obj in inspect.getmembers(module):
if inspect.isclass(obj) and issubclass(obj, BaseTool) and obj != BaseTool and hasattr(obj, "name"):
plugin = ToolPlugin(name=obj.name, tool_class=obj, source_path=package_dir)
if (
inspect.isclass(obj)
and issubclass(obj, BaseTool)
and obj != BaseTool
and hasattr(obj, "name")
):
plugin = ToolPlugin(
name=obj.name, tool_class=obj, source_path=package_dir
)
self.plugins[obj.name] = plugin
finally:
# Remove from path
+18 -6
View File
@@ -99,7 +99,9 @@ Example:
memory_used_gb = memory.used / (1024**3)
memory_total_gb = memory.total / (1024**3)
memory_percent = memory.percent
output.append(f"Memory: {memory_used_gb:.1f}/{memory_total_gb:.1f} GB ({memory_percent}%)")
output.append(
f"Memory: {memory_used_gb:.1f}/{memory_total_gb:.1f} GB ({memory_percent}%)"
)
if memory_percent > 90:
warnings.append(f"⚠️ HIGH MEMORY USAGE: {memory_percent}%")
@@ -109,10 +111,14 @@ Example:
disk_total_gb = disk.total / (1024**3)
disk_percent = disk.percent
disk_free_gb = disk.free / (1024**3)
output.append(f"Disk: {disk_used_gb:.1f}/{disk_total_gb:.1f} GB ({disk_percent}%)")
output.append(
f"Disk: {disk_used_gb:.1f}/{disk_total_gb:.1f} GB ({disk_percent}%)"
)
output.append(f"Free Space: {disk_free_gb:.1f} GB")
if disk_percent > 90:
warnings.append(f"⚠️ LOW DISK SPACE: Only {disk_free_gb:.1f} GB free ({100 - disk_percent:.1f}% remaining)")
warnings.append(
f"⚠️ LOW DISK SPACE: Only {disk_free_gb:.1f} GB free ({100 - disk_percent:.1f}% remaining)"
)
output.append("")
@@ -169,7 +175,9 @@ Example:
size = db_file.stat().st_size
total_db_size += size
output.append(f"Total Database Size: {total_db_size / (1024**2):.1f} MB")
output.append(
f"Total Database Size: {total_db_size / (1024**2):.1f} MB"
)
output.append(f"Active Projects: {len(self.db_manager.projects)}")
# List largest databases
@@ -195,7 +203,9 @@ Example:
output.append("=== MCP Servers ===")
mcp_servers = McpAddTool.get_servers()
if mcp_servers:
running_mcp = sum(1 for s in mcp_servers.values() if s.get("status") == "running")
running_mcp = sum(
1 for s in mcp_servers.values() if s.get("status") == "running"
)
total_mcp_tools = sum(len(s.get("tools", [])) for s in mcp_servers.values())
output.append(f"Total Servers: {len(mcp_servers)}")
@@ -217,7 +227,9 @@ Example:
output.append(f"Log Files: {log_count} ({log_size / (1024**2):.1f} MB)")
if log_size > 100 * 1024**2: # > 100MB
warnings.append(f"⚠️ Large log directory: {log_size / (1024**2):.1f} MB")
warnings.append(
f"⚠️ Large log directory: {log_size / (1024**2):.1f} MB"
)
# Config directory
config_dir = Path.home() / ".hanzo" / "mcp"
@@ -89,7 +89,20 @@ def format_timeout(seconds: float) -> str:
# Test the parser
if __name__ == "__main__":
test_cases = ["2min", "5m", "120s", "30sec", "1.5h", "0.5hr", "90", 120, 3600.0, "1hour", "2hours", "30seconds"]
test_cases = [
"2min",
"5m",
"120s",
"30sec",
"1.5h",
"0.5hr",
"90",
120,
3600.0,
"1hour",
"2hours",
"30seconds",
]
for case in test_cases:
try:
+23 -7
View File
@@ -214,14 +214,20 @@ Examples:
lines.append(f"[{cat}]")
for tool_name, config, is_enabled in sorted(tools, key=lambda x: x[0]):
status = "✓" if is_enabled else "○"
desc = config.description[:50] + "..." if len(config.description) > 50 else config.description
desc = (
config.description[:50] + "..."
if len(config.description) > 50
else config.description
)
lines.append(f" {status} {tool_name}: {desc}")
lines.append("")
# Summary
total = sum(len(t) for t in by_category.values())
enabled = sum(1 for tools in by_category.values() for _, _, e in tools if e)
lines.append(f"Total: {total} | Enabled: {enabled} | Disabled: {total - enabled}")
lines.append(
f"Total: {total} | Enabled: {enabled} | Disabled: {total - enabled}"
)
return "\n".join(lines)
@@ -329,7 +335,9 @@ Description: {config.description}"""
result = await registry.upgrade(name)
if result["success"]:
upgraded = [r["package"] for r in result.get("results", []) if r.get("success")]
upgraded = [
r["package"] for r in result.get("results", []) if r.get("success")
]
return f"✓ Upgraded: {', '.join(upgraded) if upgraded else 'none'}"
return "✗ Upgrade failed"
@@ -368,11 +376,19 @@ Description: {config.description}"""
@mcp_server.tool()
async def tool(
action: Action = "list",
name: Annotated[Optional[str], Field(description="Tool or package name")] = None,
source: Annotated[str, Field(description="Source: pypi, git, local")] = "pypi",
version: Annotated[Optional[str], Field(description="Version constraint")] = None,
name: Annotated[
Optional[str], Field(description="Tool or package name")
] = None,
source: Annotated[
str, Field(description="Source: pypi, git, local")
] = "pypi",
version: Annotated[
Optional[str], Field(description="Version constraint")
] = None,
persist: Annotated[bool, Field(description="Persist changes")] = True,
category: Annotated[Optional[str], Field(description="Filter by category")] = None,
category: Annotated[
Optional[str], Field(description="Filter by category")
] = None,
disabled: Annotated[bool, Field(description="Show only disabled")] = False,
enabled: Annotated[bool, Field(description="Show only enabled")] = False,
ctx: MCPContext = None,
@@ -127,7 +127,9 @@ Use 'tool_enable' to re-enable disabled tools.
]
if not persist:
output.append("\nNote: This change is temporary and will be lost on restart.")
output.append(
"\nNote: This change is temporary and will be lost on restart."
)
# Warn about commonly used tools
common_tools = {"grep", "read", "write", "bash", "edit"}
@@ -137,7 +139,9 @@ Use 'tool_enable' to re-enable disabled tools.
)
# Count disabled tools
disabled_count = sum(1 for enabled in ToolEnableTool._tool_states.values() if not enabled)
disabled_count = sum(
1 for enabled in ToolEnableTool._tool_states.values() if not enabled
)
output.append(f"\nTotal disabled tools: {disabled_count}")
return "\n".join(output)
@@ -135,11 +135,15 @@ Sources:
result = await registry.upgrade(package)
if result["success"]:
upgraded = [r["package"] for r in result.get("results", []) if r.get("success")]
upgraded = [
r["package"] for r in result.get("results", []) if r.get("success")
]
return f"✓ Upgraded: {', '.join(upgraded) if upgraded else 'none'}"
else:
errors = [
f"{r['package']}: {r.get('error')}" for r in result.get("results", []) if not r.get("success")
f"{r['package']}: {r.get('error')}"
for r in result.get("results", [])
if not r.get("success")
]
return "✗ Some upgrades failed:\n" + "\n".join(errors)
@@ -165,7 +169,9 @@ Sources:
lines = ["Installed tool packages:", ""]
for pkg in packages:
status = "✓" if pkg["enabled"] else "○"
lines.append(f"{status} {pkg['name']} v{pkg['version']} ({pkg['source']})")
lines.append(
f"{status} {pkg['name']} v{pkg['version']} ({pkg['source']})"
)
if pkg["tools"]:
lines.append(f" Tools: {', '.join(pkg['tools'])}")
@@ -193,9 +199,15 @@ Sources:
@mcp_server.tool()
async def tool_install(
action: Action,
package: Annotated[str | None, Field(description="Package name or URL")] = None,
source: Annotated[str, Field(description="Source: pypi, git, or local")] = "pypi",
version: Annotated[str | None, Field(description="Version constraint")] = None,
package: Annotated[
str | None, Field(description="Package name or URL")
] = None,
source: Annotated[
str, Field(description="Source: pypi, git, or local")
] = "pypi",
version: Annotated[
str | None, Field(description="Version constraint")
] = None,
ctx: MCPContext = None,
) -> str:
"""Install, update, and manage tool packages dynamically.
@@ -207,7 +207,9 @@ Use 'tool_enable' and 'tool_disable' to change tool status.
# Iterate through categories
categories = (
[category_filter] if category_filter and category_filter in self.TOOL_INFO else self.TOOL_INFO.keys()
[category_filter]
if category_filter and category_filter in self.TOOL_INFO
else self.TOOL_INFO.keys()
)
for category in categories:
@@ -242,7 +244,9 @@ Use 'tool_enable' and 'tool_disable' to change tool status.
max_name_len = max(len(name) for name, _, _ in category_shown)
for tool_name, description, status in category_shown:
output.append(f"{status} {tool_name.ljust(max_name_len)} - {description}")
output.append(
f"{status} {tool_name.ljust(max_name_len)} - {description}"
)
output.append("")
@@ -54,7 +54,9 @@ class PackageManager:
self._tools: dict[str, Any] = {} # name -> tool instance
self._tool_classes: dict[str, Type] = {} # name -> tool class
self._config_path = Path.home() / ".hanzo" / "mcp" / "registry.json"
self._tools_path = Path.home() / ".hanzo" / "tools" # Tool packages install here
self._tools_path = (
Path.home() / ".hanzo" / "tools"
) # Tool packages install here
self._reload_callbacks: list[Callable] = []
self._mcp_server = None
@@ -331,7 +333,10 @@ class PackageManager:
try:
# Clear existing module from cache
modules_to_remove = [
mod for mod in sys.modules if mod.startswith(package) or mod.startswith(package.replace("-", "_"))
mod
for mod in sys.modules
if mod.startswith(package)
or mod.startswith(package.replace("-", "_"))
]
for mod in modules_to_remove:
del sys.modules[mod]
@@ -357,11 +362,15 @@ class PackageManager:
tool_instance.register(self._mcp_server)
self._tools[tool_name] = tool_instance
except Exception as e:
logger.warning(f"Failed to register tool {tool_name}: {e}")
logger.warning(
f"Failed to register tool {tool_name}: {e}"
)
# Check for register_* functions
for attr_name in dir(pkg_module):
if attr_name.startswith("register_") and callable(getattr(pkg_module, attr_name)):
if attr_name.startswith("register_") and callable(
getattr(pkg_module, attr_name)
):
register_func = getattr(pkg_module, attr_name)
if self._mcp_server:
try:
@@ -391,7 +400,11 @@ class PackageManager:
}
except ImportError as e:
return {"success": False, "error": f"Import failed: {e}", "package": package}
return {
"success": False,
"error": f"Import failed: {e}",
"package": package,
}
except Exception as e:
logger.exception(f"Failed to reload package: {package}")
@@ -404,7 +417,15 @@ class PackageManager:
from hanzo_mcp import __version__ as current_version
# Check for updates
cmd = [sys.executable, "-m", "uv", "pip", "install", "--upgrade", "hanzo-mcp"]
cmd = [
sys.executable,
"-m",
"uv",
"pip",
"install",
"--upgrade",
"hanzo-mcp",
]
result = await asyncio.create_subprocess_exec(
*cmd,
+55 -11
View File
@@ -65,19 +65,44 @@ class WorkspaceDetector:
while current != current.parent:
# Check for workspace files in order of preference
if (current / "go.work").exists():
return {"root": str(current), "type": "go", "config": "go.work", "primary_language": "go"}
return {
"root": str(current),
"type": "go",
"config": "go.work",
"primary_language": "go",
}
if (current / "pnpm-workspace.yaml").exists():
return {"root": str(current), "type": "pnpm", "config": "pnpm-workspace.yaml", "primary_language": "ts"}
return {
"root": str(current),
"type": "pnpm",
"config": "pnpm-workspace.yaml",
"primary_language": "ts",
}
if (current / "package.json").exists():
return {"root": str(current), "type": "npm", "config": "package.json", "primary_language": "ts"}
return {
"root": str(current),
"type": "npm",
"config": "package.json",
"primary_language": "ts",
}
if (current / "pyproject.toml").exists():
return {"root": str(current), "type": "python", "config": "pyproject.toml", "primary_language": "py"}
return {
"root": str(current),
"type": "python",
"config": "pyproject.toml",
"primary_language": "py",
}
if (current / "Cargo.toml").exists():
return {"root": str(current), "type": "rust", "config": "Cargo.toml", "primary_language": "rs"}
return {
"root": str(current),
"type": "rust",
"config": "Cargo.toml",
"primary_language": "rs",
}
current = current.parent
@@ -176,7 +201,15 @@ class TargetResolver:
files = []
# Common source file patterns
patterns = ["**/*.go", "**/*.ts", "**/*.tsx", "**/*.js", "**/*.jsx", "**/*.py", "**/*.rs"]
patterns = [
"**/*.go",
"**/*.ts",
"**/*.tsx",
"**/*.js",
"**/*.jsx",
"**/*.py",
"**/*.rs",
]
for pattern in patterns:
files.extend([str(f) for f in path.glob(pattern) if f.is_file()])
@@ -221,7 +254,10 @@ class TargetResolver:
"""Get changed files from git"""
try:
result = subprocess.run(
["git", "diff", "--name-only", "HEAD"], cwd=workspace["root"], capture_output=True, text=True
["git", "diff", "--name-only", "HEAD"],
cwd=workspace["root"],
capture_output=True,
text=True,
)
if result.returncode == 0:
files = [f.strip() for f in result.stdout.split("\n") if f.strip()]
@@ -251,7 +287,9 @@ class DevToolBase:
self.dry_run = dry_run
# Detect workspace
workspace_root = root or TargetResolver.resolve(target, {"root": "."})["package"]
workspace_root = (
root or TargetResolver.resolve(target, {"root": "."})["package"]
)
self.workspace = WorkspaceDetector.detect(workspace_root)
# Resolve target
@@ -293,7 +331,9 @@ class DevToolBase:
backend_map = {
"go": "go",
"ts": "pnpm" if ws_type == "pnpm" else "npm",
"py": "uv" if Path(self.workspace["root"], "uv.lock").exists() else "pytest",
"py": "uv"
if Path(self.workspace["root"], "uv.lock").exists()
else "pytest",
"rs": "cargo",
"cc": "cmake",
"sol": "forge",
@@ -302,7 +342,9 @@ class DevToolBase:
return backend_map.get(lang, "auto")
def _run_command(self, cmd: List[str], cwd: Optional[str] = None) -> subprocess.CompletedProcess:
def _run_command(
self, cmd: List[str], cwd: Optional[str] = None
) -> subprocess.CompletedProcess:
"""Run command with proper environment"""
env = {**os.environ, **self.env}
@@ -315,7 +357,9 @@ class DevToolBase:
if self.dry_run:
return subprocess.CompletedProcess(cmd, 0, f"DRY RUN: {' '.join(cmd)}", "")
return subprocess.run(cmd, cwd=work_dir, env=env, capture_output=True, text=True)
return subprocess.run(
cmd, cwd=work_dir, env=env, capture_output=True, text=True
)
# Individual tool implementations will be in separate files
+127 -32
View File
@@ -75,7 +75,11 @@ BACKEND_PARAM = {
COMMON_PARAMS = {
"root": {"type": "string", "description": "Workspace root override"},
"env": {"type": "object", "description": "Additional environment variables"},
"dry_run": {"type": "boolean", "default": False, "description": "Show planned operations without executing"},
"dry_run": {
"type": "boolean",
"default": False,
"description": "Show planned operations without executing",
},
}
@@ -92,13 +96,24 @@ async def list_tools() -> List[Tool]:
"target": TARGET_PARAM,
"op": {
"type": "string",
"enum": ["rename", "code_action", "organize_imports", "apply_workspace_edit"],
"enum": [
"rename",
"code_action",
"organize_imports",
"apply_workspace_edit",
],
"description": "Edit operation to perform",
},
"file": {"type": "string", "description": "File path for rename/code_action operations"},
"file": {
"type": "string",
"description": "File path for rename/code_action operations",
},
"pos": {
"type": "object",
"properties": {"line": {"type": "integer"}, "character": {"type": "integer"}},
"properties": {
"line": {"type": "integer"},
"character": {"type": "integer"},
},
"description": "Position for rename/code_action (0-based)",
},
"range": {
@@ -106,18 +121,35 @@ async def list_tools() -> List[Tool]:
"properties": {
"start": {
"type": "object",
"properties": {"line": {"type": "integer"}, "character": {"type": "integer"}},
"properties": {
"line": {"type": "integer"},
"character": {"type": "integer"},
},
},
"end": {
"type": "object",
"properties": {"line": {"type": "integer"}, "character": {"type": "integer"}},
"properties": {
"line": {"type": "integer"},
"character": {"type": "integer"},
},
},
},
"description": "Range for code actions",
},
"new_name": {"type": "string", "description": "New name for rename operation"},
"only": {"type": "array", "items": {"type": "string"}, "description": "Code action kinds to apply"},
"apply": {"type": "boolean", "default": True, "description": "Whether to apply edits to disk"},
"new_name": {
"type": "string",
"description": "New name for rename operation",
},
"only": {
"type": "array",
"items": {"type": "string"},
"description": "Code action kinds to apply",
},
"apply": {
"type": "boolean",
"default": True,
"description": "Whether to apply edits to disk",
},
"language": LANGUAGE_PARAM,
"backend": BACKEND_PARAM,
**COMMON_PARAMS,
@@ -161,14 +193,32 @@ async def list_tools() -> List[Tool]:
"opts": {
"type": "object",
"properties": {
"run": {"type": "string", "description": "Test name pattern (Go -run)"},
"count": {"type": "integer", "description": "Test count (Go -count)"},
"race": {"type": "boolean", "description": "Enable race detection (Go -race)"},
"filter": {"type": "string", "description": "Test filter (JS --filter, Python -k)"},
"run": {
"type": "string",
"description": "Test name pattern (Go -run)",
},
"count": {
"type": "integer",
"description": "Test count (Go -count)",
},
"race": {
"type": "boolean",
"description": "Enable race detection (Go -race)",
},
"filter": {
"type": "string",
"description": "Test filter (JS --filter, Python -k)",
},
"k": {"type": "string", "description": "Pytest -k filter"},
"m": {"type": "string", "description": "Pytest -m marker"},
"p": {"type": "string", "description": "Rust package filter"},
"features": {"type": "string", "description": "Rust features"},
"p": {
"type": "string",
"description": "Rust package filter",
},
"features": {
"type": "string",
"description": "Rust features",
},
},
"description": "Test options",
},
@@ -189,11 +239,26 @@ async def list_tools() -> List[Tool]:
"opts": {
"type": "object",
"properties": {
"race": {"type": "boolean", "description": "Enable race detection (Go)"},
"release": {"type": "boolean", "description": "Release build (Rust)"},
"tags": {"type": "string", "description": "Build tags (Go)"},
"ldflags": {"type": "string", "description": "Linker flags (Go)"},
"features": {"type": "string", "description": "Features (Rust)"},
"race": {
"type": "boolean",
"description": "Enable race detection (Go)",
},
"release": {
"type": "boolean",
"description": "Release build (Rust)",
},
"tags": {
"type": "string",
"description": "Build tags (Go)",
},
"ldflags": {
"type": "string",
"description": "Linker flags (Go)",
},
"features": {
"type": "string",
"description": "Features (Rust)",
},
},
"description": "Build options",
},
@@ -214,7 +279,11 @@ async def list_tools() -> List[Tool]:
"opts": {
"type": "object",
"properties": {
"fix": {"type": "boolean", "default": False, "description": "Apply fixes where supported"}
"fix": {
"type": "boolean",
"default": False,
"description": "Apply fixes where supported",
}
},
"description": "Lint options",
},
@@ -235,8 +304,14 @@ async def list_tools() -> List[Tool]:
"items": {
"type": "object",
"properties": {
"id": {"type": "string", "description": "Rule identifier"},
"glob": {"type": "string", "description": "File glob pattern"},
"id": {
"type": "string",
"description": "Rule identifier",
},
"glob": {
"type": "string",
"description": "File glob pattern",
},
"pattern": {
"type": "string",
"description": "Regex pattern to match (for regex rules)",
@@ -249,8 +324,14 @@ async def list_tools() -> List[Tool]:
"type": "boolean",
"description": "Forbid writes to matched files (for generated rules)",
},
"message": {"type": "string", "description": "Custom violation message"},
"description": {"type": "string", "description": "Rule description"},
"message": {
"type": "string",
"description": "Custom violation message",
},
"description": {
"type": "string",
"description": "Rule description",
},
},
"required": ["id", "glob"],
},
@@ -303,7 +384,9 @@ async def call_tool(name: str, arguments: Dict[str, Any]) -> List[TextContent]:
for file in result["touched_files"][:10]: # Limit to first 10
output_lines.append(f" • {file}")
if len(result["touched_files"]) > 10:
output_lines.append(f" ... and {len(result['touched_files']) - 10} more")
output_lines.append(
f" ... and {len(result['touched_files']) - 10} more"
)
if result["stdout"]:
output_lines.append("\n📤 Output:")
@@ -351,7 +434,9 @@ async def _compose_rename_and_test(args: Dict[str, Any]) -> List[TextContent]:
# 1. Rename
rename_result = await edit_tool_handler(
target=args["target"], op="rename", **{k: v for k, v in args.items() if k not in ["target"]}
target=args["target"],
op="rename",
**{k: v for k, v in args.items() if k not in ["target"]},
)
results.append(("rename", rename_result))
@@ -375,10 +460,14 @@ async def _compose_rename_and_test(args: Dict[str, Any]) -> List[TextContent]:
output_lines = ["🔄 RENAME AND TEST WORKFLOW"]
for step, result in results:
status = "✅" if result["ok"] else "❌"
output_lines.append(f"{status} {step.upper()}: {result.get('stdout', '')[:100]}")
output_lines.append(
f"{status} {step.upper()}: {result.get('stdout', '')[:100]}"
)
overall_success = all(result["ok"] for _, result in results)
output_lines.append(f"\n{'✅ Workflow completed successfully' if overall_success else '❌ Workflow failed'}")
output_lines.append(
f"\n{'✅ Workflow completed successfully' if overall_success else '❌ Workflow failed'}"
)
return [TextContent(type="text", text="\n".join(output_lines))]
@@ -403,10 +492,14 @@ async def _compose_fix_and_verify(args: Dict[str, Any]) -> List[TextContent]:
output_lines = ["🔧 FIX AND VERIFY WORKFLOW"]
for step, result in results:
status = "✅" if result["ok"] else "❌"
output_lines.append(f"{status} {step.upper()}: {result.get('stdout', '')[:100]}")
output_lines.append(
f"{status} {step.upper()}: {result.get('stdout', '')[:100]}"
)
overall_success = all(result["ok"] for _, result in results)
output_lines.append(f"\n{'✅ All checks passed' if overall_success else '❌ Some checks failed'}")
output_lines.append(
f"\n{'✅ All checks passed' if overall_success else '❌ Some checks failed'}"
)
return [TextContent(type="text", text="\n".join(output_lines))]
@@ -439,7 +532,9 @@ async def _compose_full_check(args: Dict[str, Any]) -> List[TextContent]:
output_lines.append(f"{status} {step.upper()}: {summary}")
overall_success = all(result["ok"] for _, result in results)
output_lines.append(f"\n{'🎉 All checks passed!' if overall_success else '⚠️ Some checks failed'}")
output_lines.append(
f"\n{'🎉 All checks passed!' if overall_success else '⚠️ Some checks failed'}"
)
return [TextContent(type="text", text="\n".join(output_lines))]
+6 -2
View File
@@ -120,7 +120,9 @@ class EditTool(DevToolBase):
return await self._typescript_organize_imports()
elif self.language == "py":
# Use ruff or isort
result = self._run_command(["ruff", "check", "--select", "I", "--fix"] + self.resolved["files"])
result = self._run_command(
["ruff", "check", "--select", "I", "--fix"] + self.resolved["files"]
)
else:
return create_dev_result(
ok=False,
@@ -128,7 +130,9 @@ class EditTool(DevToolBase):
language_used=self.language,
backend_used=self.backend,
scope_resolved=self.target,
errors=[f"Organize imports not supported for language: {self.language}"],
errors=[
f"Organize imports not supported for language: {self.language}"
],
)
return create_dev_result(
+19 -3
View File
@@ -103,7 +103,11 @@ class FmtTool(DevToolBase):
async def _format_typescript(self) -> DevResult:
"""Format TypeScript/JavaScript code"""
files = self.resolved["files"]
ts_files = [f for f in files if any(f.endswith(ext) for ext in [".ts", ".tsx", ".js", ".jsx"])]
ts_files = [
f
for f in files
if any(f.endswith(ext) for ext in [".ts", ".tsx", ".js", ".jsx"])
]
if not ts_files:
return create_dev_result(
@@ -193,7 +197,13 @@ class FmtTool(DevToolBase):
async def _format_cpp(self) -> DevResult:
"""Format C/C++ code using clang-format"""
files = self.resolved["files"]
cpp_files = [f for f in files if any(f.endswith(ext) for ext in [".c", ".cpp", ".cc", ".cxx", ".h", ".hpp"])]
cpp_files = [
f
for f in files
if any(
f.endswith(ext) for ext in [".c", ".cpp", ".cc", ".cxx", ".h", ".hpp"]
)
]
if not cpp_files:
return create_dev_result(
@@ -364,7 +374,13 @@ async def fmt_tool_handler(
"""MCP handler for fmt tool"""
tool = FmtTool(
target=target, language=language, backend=backend, root=root, env=env, dry_run=dry_run, opts=opts or {}
target=target,
language=language,
backend=backend,
root=root,
env=env,
dry_run=dry_run,
opts=opts or {},
)
result = await tool.execute()
+25 -7
View File
@@ -148,7 +148,9 @@ class GuardTool(DevToolBase):
glob=glob_pattern,
description=description,
forbid_writes=rule_data["forbid_writes"],
message=rule_data.get("message", "Modification of generated files forbidden"),
message=rule_data.get(
"message", "Modification of generated files forbidden"
),
)
)
@@ -226,7 +228,9 @@ class GuardTool(DevToolBase):
return matching_files
async def _check_regex_rule(self, rule: RegexRule, files: List[str]) -> List[Violation]:
async def _check_regex_rule(
self, rule: RegexRule, files: List[str]
) -> List[Violation]:
"""Check regex rule against files"""
violations = []
pattern = re.compile(rule.pattern)
@@ -251,7 +255,9 @@ class GuardTool(DevToolBase):
return violations
async def _check_import_rule(self, rule: ImportRule, files: List[str]) -> List[Violation]:
async def _check_import_rule(
self, rule: ImportRule, files: List[str]
) -> List[Violation]:
"""Check import rule against files"""
violations = []
@@ -260,7 +266,9 @@ class GuardTool(DevToolBase):
with open(file_path, "r", encoding="utf-8", errors="ignore") as f:
for line_num, line in enumerate(f, 1):
# Check for import statements containing forbidden prefix
if self._line_has_forbidden_import(line, rule.forbid_import_prefix):
if self._line_has_forbidden_import(
line, rule.forbid_import_prefix
):
violations.append(
Violation(
file=file_path,
@@ -275,7 +283,9 @@ class GuardTool(DevToolBase):
return violations
async def _check_generated_rule(self, rule: GeneratedRule, files: List[str]) -> List[Violation]:
async def _check_generated_rule(
self, rule: GeneratedRule, files: List[str]
) -> List[Violation]:
"""Check generated file rule"""
violations = []
@@ -348,7 +358,9 @@ class GuardTool(DevToolBase):
for rule_id, rule_violations in by_rule.items():
output.append(f"Rule: {rule_id}")
for violation in rule_violations:
output.append(f" {violation.file}:{violation.line} - {violation.message}")
output.append(
f" {violation.file}:{violation.line} - {violation.message}"
)
output.append(f" {violation.text}")
output.append("")
@@ -416,7 +428,13 @@ async def guard_tool_handler(
all_rules.extend(rules)
tool = GuardTool(
target=target, language=language, backend=backend, root=root, env=env, dry_run=dry_run, rules=all_rules
target=target,
language=language,
backend=backend,
root=root,
env=env,
dry_run=dry_run,
rules=all_rules,
)
result = await tool.execute()
+30 -8
View File
@@ -82,8 +82,12 @@ class LintTool(DevToolBase):
# Combine results
overall_ok = all(result.returncode == 0 for _, result in results)
combined_stdout = "\n".join(f"=== {name} ===\n{result.stdout}" for name, result in results)
combined_stderr = "\n".join(f"=== {name} ===\n{result.stderr}" for name, result in results)
combined_stdout = "\n".join(
f"=== {name} ===\n{result.stdout}" for name, result in results
)
combined_stderr = "\n".join(
f"=== {name} ===\n{result.stderr}" for name, result in results
)
return create_dev_result(
ok=overall_ok,
@@ -111,8 +115,12 @@ class LintTool(DevToolBase):
# Combine results
overall_ok = all(result.returncode == 0 for _, result in results)
combined_stdout = "\n".join(f"=== {name} ===\n{result.stdout}" for name, result in results)
combined_stderr = "\n".join(f"=== {name} ===\n{result.stderr}" for name, result in results)
combined_stdout = "\n".join(
f"=== {name} ===\n{result.stdout}" for name, result in results
)
combined_stderr = "\n".join(
f"=== {name} ===\n{result.stderr}" for name, result in results
)
backend = " + ".join(name for name, _ in results)
@@ -156,8 +164,12 @@ class LintTool(DevToolBase):
# Combine results
overall_ok = all(result.returncode == 0 for _, result in results)
combined_stdout = "\n".join(f"=== {name} ===\n{result.stdout}" for name, result in results)
combined_stderr = "\n".join(f"=== {name} ===\n{result.stderr}" for name, result in results)
combined_stdout = "\n".join(
f"=== {name} ===\n{result.stdout}" for name, result in results
)
combined_stderr = "\n".join(
f"=== {name} ===\n{result.stderr}" for name, result in results
)
backend = " + ".join(name for name, _ in results)
@@ -215,7 +227,11 @@ class LintTool(DevToolBase):
stdout="clang-tidy not available, skipping C++ linting",
)
files = [f for f in self.resolved["files"] if any(f.endswith(ext) for ext in [".c", ".cpp", ".cc", ".cxx"])]
files = [
f
for f in self.resolved["files"]
if any(f.endswith(ext) for ext in [".c", ".cpp", ".cc", ".cxx"])
]
if not files:
return create_dev_result(
@@ -485,7 +501,13 @@ async def lint_tool_handler(
"""MCP handler for lint tool"""
tool = LintTool(
target=target, language=language, backend=backend, root=root, env=env, dry_run=dry_run, opts=opts or {}
target=target,
language=language,
backend=backend,
root=root,
env=env,
dry_run=dry_run,
opts=opts or {},
)
result = await tool.execute()
+15 -3
View File
@@ -126,7 +126,9 @@ class TestTool(DevToolBase):
language_used=self.language,
backend_used=self.backend,
scope_resolved=self.target,
errors=["No test runner detected (jest, vitest, or package.json script)"],
errors=[
"No test runner detected (jest, vitest, or package.json script)"
],
)
async def _test_python(self) -> DevResult:
@@ -345,7 +347,11 @@ class TestTool(DevToolBase):
return str(path)
# Look for corresponding test files
patterns = [f"**/*{path.stem}*.test.*", f"**/*{path.stem}*.spec.*", f"**/test*{path.stem}*"]
patterns = [
f"**/*{path.stem}*.test.*",
f"**/*{path.stem}*.spec.*",
f"**/test*{path.stem}*",
]
return patterns[0] # Return first pattern as fallback
@@ -383,7 +389,13 @@ async def test_tool_handler(
"""MCP handler for test tool"""
tool = TestTool(
target=target, language=language, backend=backend, root=root, env=env, dry_run=dry_run, opts=opts or {}
target=target,
language=language,
backend=backend,
root=root,
env=env,
dry_run=dry_run,
opts=opts or {},
)
result = await tool.execute()
+14 -4
View File
@@ -51,8 +51,14 @@ class MCPResourceDocument:
total = stats.get("total", len(results))
time_ms = stats.get("time_ms", {})
if time_ms:
total_time = sum(time_ms.values()) if isinstance(time_ms, dict) else time_ms
lines.append(f"# Search: '{query}' ({total} results, {total_time}ms)")
total_time = (
sum(time_ms.values())
if isinstance(time_ms, dict)
else time_ms
)
lines.append(
f"# Search: '{query}' ({total} results, {total_time}ms)"
)
else:
lines.append(f"# Found {total} results")
lines.append("")
@@ -70,7 +76,9 @@ class MCPResourceDocument:
lines.append(f"{i}. {loc}")
if match:
# Truncate long matches
match_preview = match[:200] + "..." if len(match) > 200 else match
match_preview = (
match[:200] + "..." if len(match) > 200 else match
)
lines.append(f" {match_preview}")
if rtype:
lines.append(f" [{rtype}]")
@@ -85,7 +93,9 @@ class MCPResourceDocument:
total = pagination.get("total", 0)
has_next = pagination.get("has_next", False)
if has_next:
lines.append(f"\n... showing page {page} of {(total // 50) + 1}")
lines.append(
f"\n... showing page {page} of {(total // 50) + 1}"
)
else:
# Generic dict - format as key-value pairs
for key, value in self.data.items():
+71 -19
View File
@@ -51,11 +51,15 @@ class ToolResult:
class TargetSpec(BaseModel):
"""Target specification for tool operations"""
target: str = Field(..., description="file:<path>, dir:<path>, pkg:<spec>, ws, or changed")
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")
env: Dict[str, str] = Field(
default_factory=dict, description="Environment variables"
)
dry_run: bool = Field(default=False, description="Preview mode")
@@ -71,7 +75,12 @@ class WorkspaceDetector:
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"}
return {
"root": str(current),
"type": "go_workspace",
"config": current / "go.work",
"language": "go",
}
elif (current / "package.json").exists():
return {
"root": str(current),
@@ -94,7 +103,12 @@ class WorkspaceDetector:
"language": "rs",
}
elif (current / ".git").exists():
return {"root": str(current), "type": "git_repository", "config": current / ".git", "language": "auto"}
return {
"root": str(current),
"type": "git_repository",
"config": current / ".git",
"language": "auto",
}
# Default to current directory
return {
@@ -133,7 +147,9 @@ class SessionManager:
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]:
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:
@@ -142,7 +158,9 @@ class SessionManager:
"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)),
"tools_used": list(
set(entry["tool"] for entry in session_data)
),
}
)
return sessions
@@ -219,7 +237,6 @@ class CodebaseIndexer:
(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
@@ -240,7 +257,13 @@ class CodebaseIndexer:
cursor = conn.execute(sql, params)
return [
{"path": row[0], "name": row[1], "kind": row[2], "line": row[3], "definition": row[4]}
{
"path": row[0],
"name": row[1],
"kind": row[2],
"line": row[3],
"definition": row[4],
}
for row in cursor.fetchall()
]
@@ -266,7 +289,9 @@ class LSPBridge:
# and handle communication via JSON-RPC
pass
async def rename_symbol(self, file_path: str, line: int, character: int, new_name: str):
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
@@ -302,7 +327,11 @@ class UnifiedBackend:
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}
return {
"type": "directory",
"paths": [str(f) for f in files],
"workspace": workspace,
}
elif target.startswith("pkg:"):
pkg_spec = target[4:]
@@ -321,17 +350,30 @@ class UnifiedBackend:
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}
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
["git", "diff", "--name-only", "HEAD"],
capture_output=True,
text=True,
check=True,
)
changed_files = (
result.stdout.strip().split("\n") if result.stdout.strip() else []
)
changed_files = result.stdout.strip().split("\n") if result.stdout.strip() else []
workspace = WorkspaceDetector.detect(".")
return {"type": "changed", "paths": changed_files, "workspace": workspace}
return {
"type": "changed",
"paths": changed_files,
"workspace": workspace,
}
except subprocess.CalledProcessError:
return {
"type": "changed",
@@ -381,7 +423,9 @@ class UnifiedBackend:
new_name = kwargs.get("new_name")
if target.dry_run:
result.stdout = f"Would rename symbol at {file_path}:{pos} to {new_name}"
result.stdout = (
f"Would rename symbol at {file_path}:{pos} to {new_name}"
)
else:
# Actual LSP rename
await self.lsp_bridge.rename_symbol(
@@ -396,7 +440,9 @@ class UnifiedBackend:
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)
file_path,
range_spec.get("start", {}).get("line", 0),
range_spec.get("end", {}).get("line", 0),
)
result.stdout = f"Available actions: {actions}"
@@ -475,11 +521,15 @@ class UnifiedBackend:
cmd.extend(["-local", local_prefix])
cmd.append(file_path)
proc_result = subprocess.run(cmd, capture_output=True, text=True)
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}")
result.errors.append(
f"Failed to format {file_path}: {proc_result.stderr}"
)
# Add other language formatting logic
@@ -518,7 +568,9 @@ class UnifiedBackend:
# Implementation for linting
pass
async def guard(self, target: TargetSpec, rules: List[Dict], **kwargs) -> ToolResult:
async def guard(
self, target: TargetSpec, rules: List[Dict], **kwargs
) -> ToolResult:
"""Guard tool: repo invariants"""
# Implementation for guard rules
pass
+59 -19
View File
@@ -241,8 +241,14 @@ def apply_transformations(record: Any, params: Dict[str, Any]) -> Any:
"source_size_bytes": total_size,
"database_size_bytes": db_size,
"storage_ratio": storage_ratio,
"files_per_second": (len(python_files) / indexing_time if indexing_time > 0 else 0),
"mb_per_second": ((total_size / 1024 / 1024) / indexing_time if indexing_time > 0 else 0),
"files_per_second": (
len(python_files) / indexing_time if indexing_time > 0 else 0
),
"mb_per_second": (
(total_size / 1024 / 1024) / indexing_time
if indexing_time > 0
else 0
),
}
print(f"Indexing completed in {indexing_time:.2f} seconds")
@@ -264,7 +270,9 @@ def apply_transformations(record: Any, params: Dict[str, Any]) -> Any:
"indexing_time_seconds": time.time() - start_time,
}
async def benchmark_search_performance(self, project_path: Path, queries: List[str]) -> Dict[str, Any]:
async def benchmark_search_performance(
self, project_path: Path, queries: List[str]
) -> Dict[str, Any]:
"""Benchmark search performance across different query types."""
print("\\n=== Benchmarking Search Performance ===")
@@ -294,7 +302,9 @@ def apply_transformations(record: Any, params: Dict[str, Any]) -> Any:
try:
# Mock path validation methods
unified_tool.validate_path = lambda x: type("obj", (object,), {"is_error": False})()
unified_tool.validate_path = lambda x: type(
"obj", (object,), {"is_error": False}
)()
unified_tool.check_path_allowed = lambda x, y: (True, None)
unified_tool.check_path_exists = lambda x, y: (True, None)
@@ -325,7 +335,9 @@ def apply_transformations(record: Any, params: Dict[str, Any]) -> Any:
valid_times = [t for t in times if t != float("inf")]
search_results[query] = {
"avg_time_seconds": (statistics.mean(valid_times) if valid_times else float("inf")),
"avg_time_seconds": (
statistics.mean(valid_times) if valid_times else float("inf")
),
"min_time_seconds": min(valid_times) if valid_times else float("inf"),
"max_time_seconds": max(valid_times) if valid_times else float("inf"),
"avg_results": statistics.mean(result_counts) if result_counts else 0,
@@ -374,9 +386,13 @@ def apply_transformations(record: Any, params: Dict[str, Any]) -> Any:
"total_files": len(python_files),
"successful_analyses": len(valid_times),
"total_time_seconds": total_time,
"avg_time_per_file": (statistics.mean(valid_times) if valid_times else float("inf")),
"avg_time_per_file": (
statistics.mean(valid_times) if valid_times else float("inf")
),
"total_symbols": sum(symbol_counts),
"avg_symbols_per_file": (statistics.mean(symbol_counts) if symbol_counts else 0),
"avg_symbols_per_file": (
statistics.mean(symbol_counts) if symbol_counts else 0
),
"files_per_second": len(valid_times) / total_time if total_time > 0 else 0,
}
@@ -421,10 +437,14 @@ def apply_transformations(record: Any, params: Dict[str, Any]) -> Any:
size_results["indexing"] = await self.benchmark_indexing(test_project)
# 2. Benchmark AST analysis
size_results["ast_analysis"] = await self.benchmark_ast_analysis(test_project)
size_results["ast_analysis"] = await self.benchmark_ast_analysis(
test_project
)
# 3. Benchmark search performance
size_results["search_performance"] = await self.benchmark_search_performance(test_project, test_queries)
size_results[
"search_performance"
] = await self.benchmark_search_performance(test_project, test_queries)
benchmark_results["results"][size] = size_results
@@ -446,16 +466,24 @@ def apply_transformations(record: Any, params: Dict[str, Any]) -> Any:
indexing = size_results["indexing"]
report.append("### Indexing Performance")
if indexing["success"]:
report.append(f"- **Indexing Time**: {indexing['indexing_time_seconds']:.2f} seconds")
report.append(
f"- **Indexing Time**: {indexing['indexing_time_seconds']:.2f} seconds"
)
report.append(f"- **Files Indexed**: {indexing['files_count']}")
report.append(f"- **Source Size**: {indexing['source_size_bytes'] / 1024 / 1024:.2f} MB")
report.append(f"- **Database Size**: {indexing['database_size_bytes'] / 1024 / 1024:.2f} MB")
report.append(
f"- **Source Size**: {indexing['source_size_bytes'] / 1024 / 1024:.2f} MB"
)
report.append(
f"- **Database Size**: {indexing['database_size_bytes'] / 1024 / 1024:.2f} MB"
)
report.append(f"- **Storage Ratio**: {indexing['storage_ratio']:.2f}x")
report.append(
f"- **Performance**: {indexing['files_per_second']:.1f} files/sec, {indexing['mb_per_second']:.1f} MB/sec"
)
else:
report.append(f"- **Status**: Failed - {indexing.get('error', 'Unknown error')}")
report.append(
f"- **Status**: Failed - {indexing.get('error', 'Unknown error')}"
)
report.append("")
# AST Analysis results
@@ -464,9 +492,13 @@ def apply_transformations(record: Any, params: Dict[str, Any]) -> Any:
report.append(f"- **Total Files**: {ast['total_files']}")
report.append(f"- **Successful Analyses**: {ast['successful_analyses']}")
report.append(f"- **Total Time**: {ast['total_time_seconds']:.2f} seconds")
report.append(f"- **Avg Time per File**: {ast['avg_time_per_file']:.3f} seconds")
report.append(
f"- **Avg Time per File**: {ast['avg_time_per_file']:.3f} seconds"
)
report.append(f"- **Total Symbols**: {ast['total_symbols']}")
report.append(f"- **Avg Symbols per File**: {ast['avg_symbols_per_file']:.1f}")
report.append(
f"- **Avg Symbols per File**: {ast['avg_symbols_per_file']:.1f}"
)
report.append(f"- **Files per Second**: {ast['files_per_second']:.1f}")
report.append("")
@@ -486,7 +518,9 @@ def apply_transformations(record: Any, params: Dict[str, Any]) -> Any:
else:
time_str = f"{avg_time:.3f}"
report.append(f"| `{query}` | {time_str} | {avg_results:.1f} | {success_rate:.1f}% |")
report.append(
f"| `{query}` | {time_str} | {avg_results:.1f} | {success_rate:.1f}% |"
)
report.append("")
@@ -500,9 +534,15 @@ def apply_transformations(record: Any, params: Dict[str, Any]) -> Any:
if indexing["success"]:
report.append(f"**{size.upper()} Project:**")
report.append(f"- Indexed {indexing['files_count']} files in {indexing['indexing_time_seconds']:.2f}s")
report.append(f"- Database compression: {indexing['storage_ratio']:.2f}x")
report.append(f"- AST analysis: {ast['files_per_second']:.1f} files/sec")
report.append(
f"- Indexed {indexing['files_count']} files in {indexing['indexing_time_seconds']:.2f}s"
)
report.append(
f"- Database compression: {indexing['storage_ratio']:.2f}x"
)
report.append(
f"- AST analysis: {ast['files_per_second']:.1f} files/sec"
)
report.append("")
return "\\n".join(report)
+28 -8
View File
@@ -60,7 +60,9 @@ async def simple_benchmark():
analysis_times.append(analysis_time)
symbol_counts.append(len(file_ast.symbols) if file_ast else 0)
print(f" {file_path.name}: {analysis_time:.3f}s, {len(file_ast.symbols) if file_ast else 0} symbols")
print(
f" {file_path.name}: {analysis_time:.3f}s, {len(file_ast.symbols) if file_ast else 0} symbols"
)
except Exception as e:
print(f" {file_path.name}: FAILED - {e}")
@@ -68,7 +70,9 @@ async def simple_benchmark():
if analysis_times:
avg_time = sum(analysis_times) / len(analysis_times)
avg_symbols = sum(symbol_counts) / len(symbol_counts)
print(f"\\nAverage: {avg_time:.3f}s per file, {avg_symbols:.1f} symbols per file")
print(
f"\\nAverage: {avg_time:.3f}s per file, {avg_symbols:.1f} symbols per file"
)
print()
@@ -83,8 +87,12 @@ async def simple_benchmark():
# Mock the validation methods
unified_tool.validate_path = lambda x: type("obj", (object,), {"is_error": False})()
unified_tool.check_path_allowed = lambda x, y: asyncio.coroutine(lambda: (True, None))()
unified_tool.check_path_exists = lambda x, y: asyncio.coroutine(lambda: (True, None))()
unified_tool.check_path_allowed = lambda x, y: asyncio.coroutine(
lambda: (True, None)
)()
unified_tool.check_path_exists = lambda x, y: asyncio.coroutine(
lambda: (True, None)
)()
# Test queries
queries = [
@@ -101,9 +109,15 @@ async def simple_benchmark():
# Test grep search
start_time = time.time()
try:
grep_result = await grep_tool.call(MockContext(), pattern=query, path=str(project_root), include="*.py")
grep_result = await grep_tool.call(
MockContext(), pattern=query, path=str(project_root), include="*.py"
)
grep_time = time.time() - start_time
grep_matches = grep_result.count("\\n") if grep_result and "Found" in grep_result else 0
grep_matches = (
grep_result.count("\\n")
if grep_result and "Found" in grep_result
else 0
)
print(f" Grep: {grep_time:.3f}s, ~{grep_matches} matches")
except Exception as e:
@@ -120,7 +134,11 @@ async def simple_benchmark():
line_number=True,
)
ast_time = time.time() - start_time
ast_matches = ast_result.count("\\n") if ast_result and not ast_result.startswith("No matches") else 0
ast_matches = (
ast_result.count("\\n")
if ast_result and not ast_result.startswith("No matches")
else 0
)
print(f" AST: {ast_time:.3f}s, ~{ast_matches} matches")
except Exception as e:
@@ -137,7 +155,9 @@ async def simple_benchmark():
max_results=20,
)
unified_time = time.time() - start_time
unified_matches = unified_result.count("Result ") if "Result " in unified_result else 0
unified_matches = (
unified_result.count("Result ") if "Result " in unified_result else 0
)
print(f" Unified: {unified_time:.3f}s, {unified_matches} results")
except Exception as e:
+6 -2
View File
@@ -36,8 +36,12 @@ os.environ["PYTEST_CURRENT_TEST"] = "1" # Mark as pytest run
def pytest_configure(config):
"""Configure pytest."""
# Register custom markers
config.addinivalue_line("markers", "requires_hanzo_agents: mark test as requiring hanzo-agents SDK")
config.addinivalue_line("markers", "requires_memory_tools: mark test as requiring hanzo-memory package")
config.addinivalue_line(
"markers", "requires_hanzo_agents: mark test as requiring hanzo-agents SDK"
)
config.addinivalue_line(
"markers", "requires_memory_tools: mark test as requiring hanzo-memory package"
)
config.addinivalue_line("markers", "slow: mark test as slow running")
config.addinivalue_line("markers", "integration: mark test as integration test")
@@ -42,7 +42,9 @@ async def test_filters():
print("No results")
print("\nTest 2: Time filter (modified_after='12 hours ago')")
result = await find_tool.run(pattern="*.txt", path=tmpdir, modified_after="12 hours ago")
result = await find_tool.run(
pattern="*.txt", path=tmpdir, modified_after="12 hours ago"
)
if result.data:
results = result.data.get("results", [])
@@ -23,7 +23,9 @@ def test_parse_size():
for size_str, expected in test_cases:
result = tool._parse_size(size_str)
print(f"'{size_str}' -> {result} bytes (expected: {expected}, match: {result == expected})")
print(
f"'{size_str}' -> {result} bytes (expected: {expected}, match: {result == expected})"
)
if __name__ == "__main__":
@@ -42,7 +42,9 @@ def check_api_connection():
elif "insufficient_quota" in str(e).lower():
print("\nPossible cause: Your OpenAI account may be out of credits.")
print("Solution: Check your usage and billing information in the OpenAI dashboard.")
print(
"Solution: Check your usage and billing information in the OpenAI dashboard."
)
elif "connection" in str(e).lower():
print("\nPossible cause: Network connectivity issue.")
@@ -59,9 +59,17 @@ class TestAgentTool:
"""Test agent tool initialization."""
assert agent_tool.name == "agent"
assert "agent" in agent_tool.description.lower()
assert hasattr(agent_tool, "model_override") and agent_tool.model_override is None
assert hasattr(agent_tool, "api_key_override") and agent_tool.api_key_override is None
assert hasattr(agent_tool, "max_tokens_override") and agent_tool.max_tokens_override is None
assert (
hasattr(agent_tool, "model_override") and agent_tool.model_override is None
)
assert (
hasattr(agent_tool, "api_key_override")
and agent_tool.api_key_override is None
)
assert (
hasattr(agent_tool, "max_tokens_override")
and agent_tool.max_tokens_override is None
)
assert hasattr(agent_tool, "max_iterations") and agent_tool.max_iterations == 10
assert hasattr(agent_tool, "max_tool_uses") and agent_tool.max_tool_uses == 30
@@ -80,8 +88,14 @@ class TestAgentTool:
hasattr(agent_tool_with_params, "max_tokens_override")
and agent_tool_with_params.max_tokens_override == 2000
)
assert hasattr(agent_tool_with_params, "max_iterations") and agent_tool_with_params.max_iterations == 40
assert hasattr(agent_tool_with_params, "max_tool_uses") and agent_tool_with_params.max_tool_uses == 150
assert (
hasattr(agent_tool_with_params, "max_iterations")
and agent_tool_with_params.max_iterations == 40
)
assert (
hasattr(agent_tool_with_params, "max_tool_uses")
and agent_tool_with_params.max_tool_uses == 150
)
# Parameters are not exposed directly in the new interface
# def test_parameters(self, tool_helper, agent_tool):
@@ -161,7 +175,9 @@ class TestAgentTool:
tool_ctx.error.assert_called()
@pytest.mark.asyncio
async def test_call_with_valid_prompt_string(self, tool_helper, agent_tool, mcp_context, mock_tools):
async def test_call_with_valid_prompt_string(
self, tool_helper, agent_tool, mcp_context, mock_tools
):
"""Test agent tool call with valid prompt as string - without hanzo-agents SDK."""
# Mock the tool context
tool_ctx = MagicMock()
@@ -180,14 +196,18 @@ class TestAgentTool:
return_value=tool_ctx,
):
# Update the test to use a list instead of a string
result = await agent_tool.call(ctx=mcp_context, prompts=["Test prompt /home/test/path"])
result = await agent_tool.call(
ctx=mcp_context, prompts=["Test prompt /home/test/path"]
)
tool_helper.assert_in_result("Error", result)
tool_helper.assert_in_result("hanzo-agents SDK is required", result)
tool_ctx.error.assert_called()
@pytest.mark.asyncio
async def test_call_with_multiple_prompts(self, tool_helper, agent_tool, mcp_context, mock_tools):
async def test_call_with_multiple_prompts(
self, tool_helper, agent_tool, mcp_context, mock_tools
):
"""Test agent tool call with multiple prompts - without hanzo-agents SDK."""
# Mock the tool context
tool_ctx = MagicMock()
@@ -219,7 +239,9 @@ class TestAgentTool:
tool_ctx.error.assert_called()
@pytest.mark.asyncio
async def test_call_with_empty_prompt_list(self, tool_helper, agent_tool, mcp_context):
async def test_call_with_empty_prompt_list(
self, tool_helper, agent_tool, mcp_context
):
"""Test agent tool call with an empty prompt list."""
# Mock the tool context
tool_ctx = MagicMock()
@@ -260,7 +282,9 @@ class TestAgentTool:
tool_ctx.error.assert_called()
@pytest.mark.asyncio
async def test_get_agent_class_default_model(self, tool_helper, agent_tool, mcp_context):
async def test_get_agent_class_default_model(
self, tool_helper, agent_tool, mcp_context
):
"""Test _get_agent_class returns appropriate agent class for default model."""
agent_class = agent_tool._get_agent_class(None, mcp_context)
@@ -269,7 +293,9 @@ class TestAgentTool:
assert "DynamicMCPAgent" in agent_class.__name__
@pytest.mark.asyncio
async def test_get_agent_class_with_custom_model(self, tool_helper, agent_tool, mcp_context):
async def test_get_agent_class_with_custom_model(
self, tool_helper, agent_tool, mcp_context
):
"""Test _get_agent_class with a custom model string."""
agent_class = agent_tool._get_agent_class("model://openai/gpt-4", mcp_context)
@@ -1,6 +1,5 @@
"""Test LiteLLM with different providers."""
import litellm
import pytest
from hanzo_tools.agent.tool_adapter import convert_tools_to_openai_functions
@@ -64,7 +63,9 @@ def test_convert_echo_tool_to_openai_functions(echo_tool):
assert len(openai_functions) == 1
assert openai_functions[0]["type"] == "function"
assert openai_functions[0]["function"]["name"] == "echo"
assert openai_functions[0]["function"]["description"] == "Echo back the input message."
assert (
openai_functions[0]["function"]["description"] == "Echo back the input message."
)
assert "parameters" in openai_functions[0]["function"]
@@ -84,7 +85,9 @@ def test_litellm_openai_provider_mocked():
mock_response = MagicMock()
mock_response.choices = [mock_choice]
with patch.object(litellm, "completion", return_value=mock_response) as mock_completion:
with patch.object(
litellm, "completion", return_value=mock_response
) as mock_completion:
response = litellm.completion(
model="openai/gpt-3.5-turbo",
messages=messages,
@@ -113,7 +116,9 @@ def test_litellm_anthropic_provider_mocked():
mock_response = MagicMock()
mock_response.choices = [mock_choice]
with patch.object(litellm, "completion", return_value=mock_response) as mock_completion:
with patch.object(
litellm, "completion", return_value=mock_response
) as mock_completion:
response = litellm.completion(
model="anthropic/claude-3-haiku-20240307",
messages=messages,
@@ -98,8 +98,13 @@ class TestPrompt:
# Test with model override - explicitly with TEST_MODE to avoid provider prefix
os.environ["TEST_MODE"] = "1"
assert get_default_model("openai/gpt-4o") == "openai/gpt-4o"
assert get_default_model("gpt-4o-mini") == "gpt-4o-mini" # In test mode, no prefix added
assert get_default_model("anthropic/claude-3-sonnet") == "anthropic/claude-3-sonnet"
assert (
get_default_model("gpt-4o-mini") == "gpt-4o-mini"
) # In test mode, no prefix added
assert (
get_default_model("anthropic/claude-3-sonnet")
== "anthropic/claude-3-sonnet"
)
# Test with provider prefixing in non-test mode
del os.environ["TEST_MODE"]
@@ -54,10 +54,14 @@ class TestToolAdapter:
tool.required = ["thought"]
return tool
def test_convert_tools_to_openai_functions(self, tool_helper, mock_tool, mock_simple_tool):
def test_convert_tools_to_openai_functions(
self, tool_helper, mock_tool, mock_simple_tool
):
"""Test convert_tools_to_openai_functions."""
# Convert tools
openai_functions = convert_tools_to_openai_functions([mock_tool, mock_simple_tool])
openai_functions = convert_tools_to_openai_functions(
[mock_tool, mock_simple_tool]
)
# Verify result
assert len(openai_functions) == 2
@@ -65,7 +69,10 @@ class TestToolAdapter:
# Check first tool
assert openai_functions[0]["type"] == "function"
assert openai_functions[0]["function"]["name"] == "read_files"
assert openai_functions[0]["function"]["description"] == "Read files from the file system"
assert (
openai_functions[0]["function"]["description"]
== "Read files from the file system"
)
assert "parameters" in openai_functions[0]["function"]
# Check second tool
+5 -1
View File
@@ -40,7 +40,11 @@ class TestAgentTools:
tool = IChingTool()
assert tool.name == "iching"
desc_lower = tool.description.lower()
assert "i ching" in desc_lower or "wisdom" in desc_lower or "hexagram" in desc_lower
assert (
"i ching" in desc_lower
or "wisdom" in desc_lower
or "hexagram" in desc_lower
)
def test_review_tool_creation(self):
"""Test ReviewTool can be created."""
@@ -137,7 +137,9 @@ class TestBatchToolEdgeCases:
mock_tool_ctx.info = AsyncMock()
# Make tools return large output
batch_tool.tools["tool1"].call = AsyncMock(return_value="X" * 100000) # 100KB output
batch_tool.tools["tool1"].call = AsyncMock(
return_value="X" * 100000
) # 100KB output
# Create many invocations
invocations = [{"tool_name": "tool1", "input": {"id": i}} for i in range(50)]
@@ -146,7 +148,9 @@ class TestBatchToolEdgeCases:
"hanzo_mcp.tools.common.context.create_tool_context",
return_value=mock_tool_ctx,
):
result = await batch_tool.call(ctx=mock_ctx, description="Large batch", invocations=invocations)
result = await batch_tool.call(
ctx=mock_ctx, description="Large batch", invocations=invocations
)
# Should handle pagination
tool_helper.assert_in_result("results", result)
@@ -172,7 +176,9 @@ class TestBatchToolEdgeCases:
invocations = [{"tool_name": "tool1", "input": {"id": i}} for i in range(20)]
start_time = asyncio.get_event_loop().time()
await batch_tool.call(ctx=mock_ctx, description="Concurrent test", invocations=invocations)
await batch_tool.call(
ctx=mock_ctx, description="Concurrent test", invocations=invocations
)
end_time = asyncio.get_event_loop().time()
# Check that execution was concurrent but limited
@@ -227,7 +233,9 @@ class TestBatchToolEdgeCases:
{"tool_name": "Test_Tool", "input": {}},
]
result = await batch_tool.call(ctx=mock_ctx, description="Name normalization", invocations=invocations)
result = await batch_tool.call(
ctx=mock_ctx, description="Name normalization", invocations=invocations
)
# Should handle all variations - but batch tool doesn't normalize names
assert isinstance(result, str)
+109 -60
View File
@@ -16,9 +16,10 @@ class TestCLI:
def test_main_server_run(self) -> None:
"""Test the main function running the server."""
with patch("argparse.ArgumentParser.parse_args") as mock_parse_args, patch(
"hanzo_mcp.server.HanzoMCPServer"
) as mock_server_class:
with (
patch("argparse.ArgumentParser.parse_args") as mock_parse_args,
patch("hanzo_mcp.server.HanzoMCPServer") as mock_server_class,
):
# Mock parsed arguments
mock_args = MagicMock()
mock_args.name = "test-server"
@@ -83,9 +84,10 @@ class TestCLI:
def test_main_with_install(self) -> None:
"""Test the main function with install option."""
with patch("argparse.ArgumentParser.parse_args") as mock_parse_args, patch(
"hanzo_mcp.cli.install_claude_desktop_config"
) as mock_install:
with (
patch("argparse.ArgumentParser.parse_args") as mock_parse_args,
patch("hanzo_mcp.cli.install_claude_desktop_config") as mock_install,
):
# Mock parsed arguments
mock_args = MagicMock()
mock_args.name = "test-server"
@@ -124,9 +126,11 @@ class TestCLI:
def test_main_without_allowed_paths(self) -> None:
"""Test the main function without specified allowed paths."""
with patch("argparse.ArgumentParser.parse_args") as mock_parse_args, patch(
"hanzo_mcp.server.HanzoMCPServer"
) as mock_server_class, patch("os.path.expanduser", return_value="/home/testuser"):
with (
patch("argparse.ArgumentParser.parse_args") as mock_parse_args,
patch("hanzo_mcp.server.HanzoMCPServer") as mock_server_class,
patch("os.path.expanduser", return_value="/home/testuser"),
):
# Mock parsed arguments
mock_args = MagicMock()
mock_args.name = "test-server"
@@ -189,9 +193,10 @@ class TestCLI:
def test_main_with_disable_write_tools(self) -> None:
"""Test the main function with disable_write_tools=True."""
with patch("argparse.ArgumentParser.parse_args") as mock_parse_args, patch(
"hanzo_mcp.server.HanzoMCPServer"
) as mock_server_class:
with (
patch("argparse.ArgumentParser.parse_args") as mock_parse_args,
patch("hanzo_mcp.server.HanzoMCPServer") as mock_server_class,
):
# Mock parsed arguments
mock_args = MagicMock()
mock_args.name = "test-server"
@@ -255,9 +260,10 @@ class TestCLI:
def test_main_with_disable_search_tools(self) -> None:
"""Test the main function with disable_search_tools=True."""
with patch("argparse.ArgumentParser.parse_args") as mock_parse_args, patch(
"hanzo_mcp.server.HanzoMCPServer"
) as mock_server_class:
with (
patch("argparse.ArgumentParser.parse_args") as mock_parse_args,
patch("hanzo_mcp.server.HanzoMCPServer") as mock_server_class,
):
# Mock parsed arguments
mock_args = MagicMock()
mock_args.name = "test-server"
@@ -337,17 +343,22 @@ class TestInstallClaudeDesktopConfig:
# Restore original platform
monkeypatch.setattr(sys, "platform", original_platform)
def test_install_config_macos(self, mock_platform: Callable[[str], str], tmp_path: Path) -> None:
def test_install_config_macos(
self, mock_platform: Callable[[str], str], tmp_path: Path
) -> None:
"""Test installing config on macOS."""
# Set platform to macOS
mock_platform("darwin")
# Mock home directory and config path
with patch("pathlib.Path.home", return_value=Path(tmp_path)), patch(
"sys.executable", "/usr/bin/python3"
), patch("json.dump") as mock_json_dump, patch("builtins.open", create=True) as mock_open, patch(
"pathlib.Path.exists", return_value=False
), patch("pathlib.Path.mkdir") as mock_mkdir:
with (
patch("pathlib.Path.home", return_value=Path(tmp_path)),
patch("sys.executable", "/usr/bin/python3"),
patch("json.dump") as mock_json_dump,
patch("builtins.open", create=True) as mock_open,
patch("pathlib.Path.exists", return_value=False),
patch("pathlib.Path.mkdir") as mock_mkdir,
):
# Construct expected config path
config_dir = tmp_path / "Library" / "Application Support" / "Claude"
config_file = config_dir / "claude_desktop_config.json"
@@ -375,21 +386,31 @@ class TestInstallClaudeDesktopConfig:
config_data = mock_json_dump.call_args[0][0]
assert "mcpServers" in config_data
assert "test-server" in config_data["mcpServers"]
assert "/usr/bin/python3" in config_data["mcpServers"]["test-server"]["command"]
assert "--allow-path" in str(config_data["mcpServers"]["test-server"]["args"])
assert (
"/usr/bin/python3"
in config_data["mcpServers"]["test-server"]["command"]
)
assert "--allow-path" in str(
config_data["mcpServers"]["test-server"]["args"]
)
assert "/test/path" in str(config_data["mcpServers"]["test-server"]["args"])
def test_install_config_windows(self, mock_platform: Callable[[str], str], tmp_path: Path) -> None:
def test_install_config_windows(
self, mock_platform: Callable[[str], str], tmp_path: Path
) -> None:
"""Test installing config on Windows."""
# Set platform to Windows
mock_platform("win32")
# Mock environment variable
with patch.dict(os.environ, {"APPDATA": str(tmp_path)}), patch(
"sys.executable", "C:\\Python\\python.exe"
), patch("json.dump") as mock_json_dump, patch("builtins.open", create=True) as mock_open, patch(
"pathlib.Path.exists", return_value=False
), patch("pathlib.Path.mkdir") as mock_mkdir:
with (
patch.dict(os.environ, {"APPDATA": str(tmp_path)}),
patch("sys.executable", "C:\\Python\\python.exe"),
patch("json.dump") as mock_json_dump,
patch("builtins.open", create=True) as mock_open,
patch("pathlib.Path.exists", return_value=False),
patch("pathlib.Path.mkdir") as mock_mkdir,
):
# Construct expected config path
config_dir = Path(tmp_path) / "Claude"
config_file = config_dir / "claude_desktop_config.json"
@@ -415,7 +436,9 @@ class TestInstallClaudeDesktopConfig:
assert "mcpServers" in config_data
assert "test-server" in config_data["mcpServers"]
def test_install_config_merge_existing(self, mock_platform: Callable[[str], str], tmp_path: Path) -> None:
def test_install_config_merge_existing(
self, mock_platform: Callable[[str], str], tmp_path: Path
) -> None:
"""Test merging with existing config file."""
# Set platform to Linux
mock_platform("linux")
@@ -432,11 +455,15 @@ class TestInstallClaudeDesktopConfig:
}
# Mock home directory and config path
with patch("pathlib.Path.home", return_value=Path(tmp_path)), patch(
"sys.executable", "/usr/bin/python3"
), patch("json.dump") as mock_json_dump, patch("json.load", return_value=existing_config), patch(
"builtins.open", create=True
) as mock_open, patch("pathlib.Path.exists", return_value=True), patch("pathlib.Path.mkdir") as mock_mkdir:
with (
patch("pathlib.Path.home", return_value=Path(tmp_path)),
patch("sys.executable", "/usr/bin/python3"),
patch("json.dump") as mock_json_dump,
patch("json.load", return_value=existing_config),
patch("builtins.open", create=True) as mock_open,
patch("pathlib.Path.exists", return_value=True),
patch("pathlib.Path.mkdir") as mock_mkdir,
):
# Construct expected config path
config_dir = tmp_path / ".config" / "claude"
config_dir / "claude_desktop_config.json"
@@ -462,17 +489,22 @@ class TestInstallClaudeDesktopConfig:
assert "test-server" in config_data["mcpServers"]
assert "otherSetting" in config_data
def test_install_config_default_paths(self, mock_platform: Callable[[str], str], tmp_path: Path) -> None:
def test_install_config_default_paths(
self, mock_platform: Callable[[str], str], tmp_path: Path
) -> None:
"""Test installing config with default allowed paths."""
# Set platform to macOS
mock_platform("darwin")
# Mock home directory and config path
with patch("pathlib.Path.home", return_value=Path(tmp_path)), patch(
"sys.executable", "/usr/bin/python3"
), patch("json.dump") as mock_json_dump, patch("builtins.open", create=True) as mock_open, patch(
"pathlib.Path.exists", return_value=False
), patch("pathlib.Path.mkdir"):
with (
patch("pathlib.Path.home", return_value=Path(tmp_path)),
patch("sys.executable", "/usr/bin/python3"),
patch("json.dump") as mock_json_dump,
patch("builtins.open", create=True) as mock_open,
patch("pathlib.Path.exists", return_value=False),
patch("pathlib.Path.mkdir"),
):
# Mock file opening
mock_file = MagicMock()
mock_open.return_value.__enter__.return_value = mock_file
@@ -493,23 +525,30 @@ class TestInstallClaudeDesktopConfig:
# Verify --disable-write-tools flag is not present
assert "--disable-write-tools" not in server_args
def test_install_config_with_disable_write_tools(self, mock_platform: Callable[[str], str], tmp_path: Path) -> None:
def test_install_config_with_disable_write_tools(
self, mock_platform: Callable[[str], str], tmp_path: Path
) -> None:
"""Test installing config with disable_write_tools=True."""
# Set platform to macOS
mock_platform("darwin")
# Mock home directory and config path
with patch("pathlib.Path.home", return_value=Path(tmp_path)), patch(
"sys.executable", "/usr/bin/python3"
), patch("json.dump") as mock_json_dump, patch("builtins.open", create=True) as mock_open, patch(
"pathlib.Path.exists", return_value=False
), patch("pathlib.Path.mkdir"):
with (
patch("pathlib.Path.home", return_value=Path(tmp_path)),
patch("sys.executable", "/usr/bin/python3"),
patch("json.dump") as mock_json_dump,
patch("builtins.open", create=True) as mock_open,
patch("pathlib.Path.exists", return_value=False),
patch("pathlib.Path.mkdir"),
):
# Mock file opening
mock_file = MagicMock()
mock_open.return_value.__enter__.return_value = mock_file
# Call the install function with disable_write_tools=True
install_claude_desktop_config("test-server", allowed_paths=["/test/path"], disable_write_tools=True)
install_claude_desktop_config(
"test-server", allowed_paths=["/test/path"], disable_write_tools=True
)
# Verify correct config was written
mock_json_dump.assert_called_once()
@@ -532,17 +571,22 @@ class TestInstallClaudeDesktopConfig:
mock_platform("darwin")
# Mock home directory and config path
with patch("pathlib.Path.home", return_value=Path(tmp_path)), patch(
"sys.executable", "/usr/bin/python3"
), patch("json.dump") as mock_json_dump, patch("builtins.open", create=True) as mock_open, patch(
"pathlib.Path.exists", return_value=False
), patch("pathlib.Path.mkdir"):
with (
patch("pathlib.Path.home", return_value=Path(tmp_path)),
patch("sys.executable", "/usr/bin/python3"),
patch("json.dump") as mock_json_dump,
patch("builtins.open", create=True) as mock_open,
patch("pathlib.Path.exists", return_value=False),
patch("pathlib.Path.mkdir"),
):
# Mock file opening
mock_file = MagicMock()
mock_open.return_value.__enter__.return_value = mock_file
# Call the install function with disable_search_tools=True
install_claude_desktop_config("test-server", allowed_paths=["/test/path"], disable_search_tools=True)
install_claude_desktop_config(
"test-server", allowed_paths=["/test/path"], disable_search_tools=True
)
# Verify correct config was written
mock_json_dump.assert_called_once()
@@ -557,17 +601,22 @@ class TestInstallClaudeDesktopConfig:
# Verify --disable-search-tools flag is present
assert "--disable-search-tools" in server_args
def test_install_config_with_both_flags(self, mock_platform: Callable[[str], str], tmp_path: Path) -> None:
def test_install_config_with_both_flags(
self, mock_platform: Callable[[str], str], tmp_path: Path
) -> None:
"""Test installing config with both disable_write_tools and disable_search_tools set to True."""
# Set platform to macOS
mock_platform("darwin")
# Mock home directory and config path
with patch("pathlib.Path.home", return_value=Path(tmp_path)), patch(
"sys.executable", "/usr/bin/python3"
), patch("json.dump") as mock_json_dump, patch("builtins.open", create=True) as mock_open, patch(
"pathlib.Path.exists", return_value=False
), patch("pathlib.Path.mkdir"):
with (
patch("pathlib.Path.home", return_value=Path(tmp_path)),
patch("sys.executable", "/usr/bin/python3"),
patch("json.dump") as mock_json_dump,
patch("builtins.open", create=True) as mock_open,
patch("pathlib.Path.exists", return_value=False),
patch("pathlib.Path.mkdir"),
):
# Mock file opening
mock_file = MagicMock()
mock_open.return_value.__enter__.return_value = mock_file
+41 -12
View File
@@ -52,7 +52,12 @@ class TestCLITools:
with patch.object(tool, "execute_cli", new_callable=AsyncMock) as mock_exec:
mock_exec.return_value = "Claude response"
result = await tool.call(mock_context, prompt="Test prompt", model="claude-3-opus-20240229", timeout=300)
result = await tool.call(
mock_context,
prompt="Test prompt",
model="claude-3-opus-20240229",
timeout=300,
)
assert result == "Claude response"
mock_exec.assert_called_once()
@@ -77,7 +82,9 @@ class TestCLITools:
with patch.object(tool, "execute_cli", new_callable=AsyncMock) as mock_exec:
mock_exec.return_value = "GPT-4 response"
result = await tool.call(mock_context, prompt="Generate code", model="gpt-4-turbo")
result = await tool.call(
mock_context, prompt="Generate code", model="gpt-4-turbo"
)
assert result == "GPT-4 response"
@@ -92,7 +99,9 @@ class TestCLITools:
with patch.object(tool, "execute_cli", new_callable=AsyncMock) as mock_exec:
mock_exec.return_value = "Gemini response"
result = await tool.call(mock_context, prompt="Analyze image", model="gemini-1.5-pro")
result = await tool.call(
mock_context, prompt="Analyze image", model="gemini-1.5-pro"
)
assert result == "Gemini response"
@@ -107,7 +116,9 @@ class TestCLITools:
with patch.object(tool, "execute_cli", new_callable=AsyncMock) as mock_exec:
mock_exec.return_value = "Grok response"
result = await tool.call(mock_context, prompt="Real-time analysis", model="grok-2")
result = await tool.call(
mock_context, prompt="Real-time analysis", model="grok-2"
)
assert result == "Grok response"
@@ -122,7 +133,9 @@ class TestCLITools:
with patch.object(tool, "execute_cli", new_callable=AsyncMock) as mock_exec:
mock_exec.return_value = "OpenHands execution complete"
result = await tool.call(mock_context, prompt="Build feature", working_dir="/project")
result = await tool.call(
mock_context, prompt="Build feature", working_dir="/project"
)
assert result == "OpenHands execution complete"
@@ -145,7 +158,11 @@ class TestCLITools:
with patch.object(tool, "execute_cli", new_callable=AsyncMock) as mock_exec:
mock_exec.return_value = "Hanzo Dev completed task"
result = await tool.call(mock_context, prompt="Implement feature", model="claude-3-5-sonnet-20241022")
result = await tool.call(
mock_context,
prompt="Implement feature",
model="claude-3-5-sonnet-20241022",
)
assert result == "Hanzo Dev completed task"
@@ -160,7 +177,9 @@ class TestCLITools:
with patch.object(tool, "execute_cli", new_callable=AsyncMock) as mock_exec:
mock_exec.return_value = "Cline autonomous coding complete"
result = await tool.call(mock_context, prompt="Fix bugs", working_dir="/src")
result = await tool.call(
mock_context, prompt="Fix bugs", working_dir="/src"
)
assert result == "Cline autonomous coding complete"
@@ -175,7 +194,9 @@ class TestCLITools:
with patch.object(tool, "execute_cli", new_callable=AsyncMock) as mock_exec:
mock_exec.return_value = "Aider pair programming complete"
result = await tool.call(mock_context, prompt="Refactor module", model="gpt-4-turbo")
result = await tool.call(
mock_context, prompt="Refactor module", model="gpt-4-turbo"
)
assert result == "Aider pair programming complete"
@@ -223,7 +244,9 @@ class TestCLITools:
# Mock a subprocess error
with patch("asyncio.create_subprocess_exec") as mock_subprocess:
mock_process = MagicMock()
mock_process.communicate = AsyncMock(return_value=(b"", b"Error: API key not found"))
mock_process.communicate = AsyncMock(
return_value=(b"", b"Error: API key not found")
)
mock_process.returncode = 1
mock_subprocess.return_value = mock_process
@@ -287,7 +310,9 @@ class TestBatchWithCLITools:
{"tool_name": "gemini", "input": {"prompt": "Review code"}},
]
result = await batch_tool.call(mock_context, description="Multi-AI analysis", invocations=invocations)
result = await batch_tool.call(
mock_context, description="Multi-AI analysis", invocations=invocations
)
# Check that all tools were called
assert "claude response" in result
@@ -308,7 +333,9 @@ class TestBatchWithCLITools:
},
]
result = await batch_tool.call(mock_context, description="Test aliases", invocations=invocations)
result = await batch_tool.call(
mock_context, description="Test aliases", invocations=invocations
)
assert "cc response" in result
assert "oh response" in result
@@ -334,7 +361,9 @@ class TestBatchWithCLITools:
]
start = time.time()
result = await batch_tool.call(mock_context, description="Parallel test", invocations=invocations)
result = await batch_tool.call(
mock_context, description="Parallel test", invocations=invocations
)
duration = time.time() - start
# If executed in parallel, should take ~100ms, not 400ms
@@ -26,12 +26,16 @@ class TestHiddenFilePermissions:
# Test paths with the fixed implementation
# The workflow example and gitignore files should be allowed
assert manager.is_path_allowed(problem_path), "Should allow .github-workflow-example.yml"
assert manager.is_path_allowed(problem_path), (
"Should allow .github-workflow-example.yml"
)
assert manager.is_path_allowed(gitignore_file), "Should allow .gitignore"
assert manager.is_path_allowed(git_related_file), "Should allow git-tutorial.md"
# Since .git is now allowed by default, this should also be allowed
assert manager.is_path_allowed(actual_github_dir), "Should allow actual .github directory"
assert manager.is_path_allowed(actual_github_dir), (
"Should allow actual .github directory"
)
def test_various_hidden_files(self, tool_helper, temp_dir: str):
"""Test a variety of hidden files and paths to ensure correct behavior."""
@@ -89,11 +93,15 @@ class TestHiddenFilePermissions:
# Test full component paths (should be excluded)
for path in full_component_paths:
assert not manager.is_path_allowed(path), f"Should exclude full component: {path}"
assert not manager.is_path_allowed(path), (
f"Should exclude full component: {path}"
)
# Test partial component paths (should be allowed)
for path in partial_component_paths:
assert manager.is_path_allowed(path), f"Should allow partial component: {path}"
assert manager.is_path_allowed(path), (
f"Should allow partial component: {path}"
)
def test_wildcard_patterns(self, tool_helper, temp_dir: str):
"""Test that wildcard patterns work correctly."""
@@ -119,7 +127,9 @@ class TestHiddenFilePermissions:
# Test wildcard matching paths (should be excluded)
for path in wildcard_matches:
assert not manager.is_path_allowed(path), f"Should exclude wildcard match: {path}"
assert not manager.is_path_allowed(path), (
f"Should exclude wildcard match: {path}"
)
# Test non-matching paths (should be allowed)
for path in wildcard_non_matches:
@@ -110,7 +110,9 @@ class TestPermissibleOperation:
"""Test the PermissibleOperation decorator."""
@pytest.mark.asyncio
async def test_permissible_operation_with_allowed_path(self, tool_helper, temp_dir: str):
async def test_permissible_operation_with_allowed_path(
self, tool_helper, temp_dir: str
):
"""Test the decorator with an allowed path."""
manager = PermissionManager()
manager.add_allowed_path(temp_dir)
@@ -126,7 +128,9 @@ class TestPermissibleOperation:
assert result == f"Read {temp_dir}"
@pytest.mark.asyncio
async def test_permissible_operation_with_custom_path_fn(self, tool_helper, temp_dir: str):
async def test_permissible_operation_with_custom_path_fn(
self, tool_helper, temp_dir: str
):
"""Test the decorator with a custom path function."""
manager = PermissionManager()
manager.add_allowed_path(temp_dir)
@@ -146,7 +150,9 @@ class TestPermissibleOperation:
assert result == f"Read {temp_dir}"
@pytest.mark.asyncio
async def test_permissible_operation_with_invalid_path(self, tool_helper, temp_dir: str):
async def test_permissible_operation_with_invalid_path(
self, tool_helper, temp_dir: str
):
"""Test the decorator with an invalid path type."""
manager = PermissionManager()
+27 -8
View File
@@ -8,7 +8,9 @@ import pytest
# Import guard for optional hanzo_network dependency
try:
# Add hanzo-network to path
sys.path.insert(0, str(Path(__file__).parent.parent.parent / "hanzo-network" / "src"))
sys.path.insert(
0, str(Path(__file__).parent.parent.parent / "hanzo-network" / "src")
)
from hanzo_network import (
check_local_llm_status,
@@ -24,7 +26,8 @@ except ImportError:
# Skip entire module if hanzo_network is not available
pytestmark = pytest.mark.skipif(
not HANZO_NETWORK_AVAILABLE, reason="hanzo_network package not installed or numpy not available"
not HANZO_NETWORK_AVAILABLE,
reason="hanzo_network package not installed or numpy not available",
)
@@ -54,7 +57,11 @@ async def test_e2e_local_inference():
name="echo_agent",
description="Echoes messages",
system="You are an echo agent. Use the echo_message tool when asked to echo.",
tools=[create_tool(name="echo_message", description="Echo a message", handler=echo_message)],
tools=[
create_tool(
name="echo_message", description="Echo a message", handler=echo_message
)
],
local_model="llama3.2",
)
@@ -62,7 +69,11 @@ async def test_e2e_local_inference():
name="math_agent",
description="Does math",
system="You are a math agent. Use the add_numbers tool when asked to add.",
tools=[create_tool(name="add_numbers", description="Add numbers", handler=add_numbers)],
tools=[
create_tool(
name="add_numbers", description="Add numbers", handler=add_numbers
)
],
local_model="llama3.2",
)
@@ -87,7 +98,9 @@ async def test_e2e_local_inference():
assert "math_agent" in status["local_agents"]
# Test echo agent
result = await network.run(prompt="Echo the message 'Hello from E2E test'", initial_agent=echo_agent)
result = await network.run(
prompt="Echo the message 'Hello from E2E test'", initial_agent=echo_agent
)
assert result["success"]
# The dummy model returns generic responses, so just check for success
assert result["final_output"] is not None
@@ -137,7 +150,9 @@ async def test_e2e_multi_agent_collaboration():
await network.start(wait_for_peers=0)
# Test collaboration
result = await network.run(prompt="Research what distributed inference is and write a brief explanation")
result = await network.run(
prompt="Research what distributed inference is and write a brief explanation"
)
assert result["success"]
await network.stop()
@@ -162,7 +177,9 @@ async def test_e2e_mcp_tools_with_local_llm():
description="File system operations",
system="You handle file system operations using the available tools.",
tools=[
create_tool(name="read_test_file", description="Read a file", handler=read_test_file),
create_tool(
name="read_test_file", description="Read a file", handler=read_test_file
),
create_tool(
name="list_test_files",
description="List files",
@@ -183,7 +200,9 @@ async def test_e2e_mcp_tools_with_local_llm():
await network.start(wait_for_peers=0)
# Test file operations
result = await network.run(prompt="List the files in the test directory", initial_agent=fs_agent)
result = await network.run(
prompt="List the files in the test directory", initial_agent=fs_agent
)
assert result["success"]
# The dummy model returns generic responses, so just check for success
assert result["final_output"] is not None
+11 -3
View File
@@ -17,7 +17,9 @@ def test_imports():
assert network_version is not None
assert mcp_version is not None
print(f"✅ All imports successful! hanzo-network: {network_version}, hanzo-mcp: {mcp_version}")
print(
f"✅ All imports successful! hanzo-network: {network_version}, hanzo-mcp: {mcp_version}"
)
def test_hanzo_net_provider():
@@ -41,7 +43,11 @@ def test_local_agent_creation():
name="test_agent",
description="Test agent",
system="You are a test agent",
tools=[create_tool(name="dummy_tool", description="A dummy tool", handler=dummy_tool)],
tools=[
create_tool(
name="dummy_tool", description="A dummy tool", handler=dummy_tool
)
],
local_model="llama3.2",
)
@@ -56,7 +62,9 @@ def test_network_config():
"""Test distributed network configuration."""
from hanzo_network import create_local_agent, create_local_distributed_network
agent = create_local_agent(name="test_agent", description="Test agent", local_model="llama3.2")
agent = create_local_agent(
name="test_agent", description="Test agent", local_model="llama3.2"
)
network = create_local_distributed_network(
agents=[agent], name="test-network", listen_port=16100, broadcast_port=16100
+12 -3
View File
@@ -37,7 +37,12 @@ class TestMCPErrorLogger:
params = {"file_path": "/test/path", "limit": 100}
# Log the error
logger.log_tool_error(tool_name="read", error=test_error, params=params, context="Testing error logging")
logger.log_tool_error(
tool_name="read",
error=test_error,
params=params,
context="Testing error logging",
)
# Check that log files were created
tool_log = tmp_path / "read-errors.log"
@@ -110,7 +115,9 @@ class TestMCPErrorLogger:
# Log multiple errors
for i in range(5):
error = ValueError(f"Error {i}")
logger.log_tool_error(tool_name=f"tool_{i}", error=error, params={"index": i})
logger.log_tool_error(
tool_name=f"tool_{i}", error=error, params={"index": i}
)
# Get recent errors
recent = logger.get_recent_errors(limit=3)
@@ -145,7 +152,9 @@ class TestMCPErrorLogger:
# Test log_call_signature_error
sig_error = TypeError("signature mismatch")
log_call_signature_error("test_tool", "expected signature", "actual call", sig_error)
log_call_signature_error(
"test_tool", "expected signature", "actual call", sig_error
)
sig_log = tmp_path / "signature-errors.log"
assert sig_log.exists()
@@ -12,7 +12,9 @@ async def test_targetspec_rejects_unknown_keys():
@pytest.mark.asyncio
async def test_dry_run_envelope_and_no_fs_changes(tmp_path: Path, monkeypatch: pytest.MonkeyPatch):
async def test_dry_run_envelope_and_no_fs_changes(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
):
root = tmp_path
(root / "go.work").write_text("go 1.21\n\nuse (\n ./mod\n)\n")
mod_dir = root / "mod"
@@ -33,7 +35,13 @@ async def test_dry_run_envelope_and_no_fs_changes(tmp_path: Path, monkeypatch: p
before = file_path.read_text()
edit_result = await tools.edit(
target, EditArgs(op="rename", file=str(file_path), pos={"line": 1, "character": 1}, new_name="Main")
target,
EditArgs(
op="rename",
file=str(file_path),
pos={"line": 1, "character": 1},
new_name="Main",
),
)
assert hasattr(edit_result, "ok")
assert hasattr(edit_result, "root")
@@ -73,7 +81,9 @@ async def test_workspace_detection_go_work_priority(tmp_path: Path):
(b / "go.mod").write_text("module example.com/b\n\ngo 1.21\n")
tools = HanzoTools()
resolved = tools.target_resolver.resolve(TargetSpec(target="ws", root=str(root), language="go", dry_run=True))
resolved = tools.target_resolver.resolve(
TargetSpec(target="ws", root=str(root), language="go", dry_run=True)
)
assert resolved["workspace"]["root"] == str(root)
@@ -86,7 +96,9 @@ async def test_workspace_detection_root_boundary(tmp_path: Path):
(inner / "go.mod").write_text("module example.com/inner\n\ngo 1.21\n")
tools = HanzoTools()
resolved = tools.target_resolver.resolve(TargetSpec(target="ws", root=str(inner), language="go", dry_run=True))
resolved = tools.target_resolver.resolve(
TargetSpec(target="ws", root=str(inner), language="go", dry_run=True)
)
assert resolved["workspace"]["root"] == str(inner)
@@ -94,12 +106,21 @@ async def test_workspace_detection_root_boundary(tmp_path: Path):
async def test_guard_transitive_go_import(tmp_path: Path):
root = tmp_path
(root / "go.mod").write_text("module example.com/root\n\ngo 1.21\n")
(root / "main.go").write_text('package main\n\nimport "net/http"\n\nfunc main() {}\n')
(root / "main.go").write_text(
'package main\n\nimport "net/http"\n\nfunc main() {}\n'
)
tools = HanzoTools()
target = TargetSpec(target=f"dir:{root}", language="go", dry_run=True)
guard = GuardArgs(
rules=[GuardRule(id="no-net-http", type="import", glob="**/*.go", forbid_import_prefix="net/http")]
rules=[
GuardRule(
id="no-net-http",
type="import",
glob="**/*.go",
forbid_import_prefix="net/http",
)
]
)
result = await tools.guard(target, guard)
assert result.exit_code == 1
+3 -1
View File
@@ -17,7 +17,9 @@ def test_no_api_key():
try:
# Try to create swarm without API key
api_key = os.environ.get("ANTHROPIC_API_KEY") or os.environ.get("CLAUDE_API_KEY")
api_key = os.environ.get("ANTHROPIC_API_KEY") or os.environ.get(
"CLAUDE_API_KEY"
)
if not api_key:
print("✓ Correctly detected missing API key")
@@ -100,7 +100,9 @@ class TestRefactoredFileTools:
return_value=tool_ctx,
):
# Call the tool directly
result = await write_file_tool.call(ctx=mcp_context, file_path=test_path, content=test_content)
result = await write_file_tool.call(
ctx=mcp_context, file_path=test_path, content=test_content
)
# Verify result
tool_helper.assert_in_result("Successfully wrote file", result)
+29 -10
View File
@@ -70,7 +70,8 @@ class TestFindToolFFind:
# Create files with different sizes and content
files = {
"src/main.py": "# Main application\nimport sys\n\ndef main():\n pass\n" * 100,
"src/main.py": "# Main application\nimport sys\n\ndef main():\n pass\n"
* 100,
"src/utils.py": "# Utilities\ndef helper():\n return 'TODO: implement'\n",
"src/config.json": '{"debug": true, "port": 8080}\n',
"src/modules/auth.py": "# Authentication module\nclass Auth:\n pass\n",
@@ -138,7 +139,9 @@ class TestFindToolFFind:
async def test_find_with_size_filter(self, tool_helper, find_tool, test_directory):
"""Test finding files by size."""
# Find large files (> 500KB)
result = await find_tool.run(pattern="*", path=test_directory, type="file", min_size="500KB")
result = await find_tool.run(
pattern="*", path=test_directory, type="file", min_size="500KB"
)
results = result.data["results"]
# Should only find large_file.dat
@@ -168,9 +171,13 @@ class TestFindToolFFind:
assert "api" not in dir_names
@pytest.mark.asyncio
async def test_find_with_pattern_in_content(self, tool_helper, find_tool, test_directory):
async def test_find_with_pattern_in_content(
self, tool_helper, find_tool, test_directory
):
"""Test finding files containing specific text."""
result = await find_tool.run(pattern="TODO", path=test_directory, in_content=True, type="file")
result = await find_tool.run(
pattern="TODO", path=test_directory, in_content=True, type="file"
)
results = result.data["results"]
file_names = [r["name"] for r in results]
@@ -233,7 +240,9 @@ class TestFindToolFFind:
find_tool._available_backends = None
start_time = time.time()
result_python = await find_tool.run(pattern="*.py", path=test_directory, type="file")
result_python = await find_tool.run(
pattern="*.py", path=test_directory, type="file"
)
python_time = time.time() - start_time
# Restore ffind if available
@@ -242,7 +251,9 @@ class TestFindToolFFind:
if FFIND_AVAILABLE:
start_time = time.time()
result_ffind = await find_tool.run(pattern="*.py", path=test_directory, type="file")
result_ffind = await find_tool.run(
pattern="*.py", path=test_directory, type="file"
)
ffind_time = time.time() - start_time
print("\nPerformance comparison:")
@@ -251,7 +262,9 @@ class TestFindToolFFind:
print(f"Speedup: {python_time / ffind_time:.1f}x")
# Results should be the same
assert len(result_python.data["results"]) == len(result_ffind.data["results"])
assert len(result_python.data["results"]) == len(
result_ffind.data["results"]
)
@pytest.mark.asyncio
async def test_fuzzy_search(self, tool_helper, find_tool, test_directory):
@@ -270,7 +283,9 @@ class TestFindToolFFind:
assert any("test" in name for name in file_names)
@pytest.mark.asyncio
async def test_case_insensitive_search(self, tool_helper, find_tool, test_directory):
async def test_case_insensitive_search(
self, tool_helper, find_tool, test_directory
):
"""Test case-insensitive search."""
result = await find_tool.run(
pattern="*.MD", # Uppercase extension
@@ -302,7 +317,9 @@ class TestFindToolFFind:
@pytest.mark.asyncio
async def test_gitignore_respect(self, tool_helper, find_tool, test_directory):
"""Test that .gitignore patterns are respected."""
result = await find_tool.run(pattern="*", path=test_directory, respect_gitignore=True)
result = await find_tool.run(
pattern="*", path=test_directory, respect_gitignore=True
)
results = result.data["results"]
file_paths = [r["path"] for r in results]
@@ -313,7 +330,9 @@ class TestFindToolFFind:
assert not any(".pyc" in path for path in file_paths)
# Test with gitignore disabled
result_no_ignore = await find_tool.run(pattern="*", path=test_directory, respect_gitignore=False)
result_no_ignore = await find_tool.run(
pattern="*", path=test_directory, respect_gitignore=False
)
results_no_ignore = result_no_ignore.data["results"]
file_paths_no_ignore = [r["path"] for r in results_no_ignore]
@@ -177,7 +177,9 @@ async def test_find_tool_advanced_filters(tool_helper):
find_tool = create_find_tool()
# Test modified_after filter
result = await find_tool.run(pattern="*.txt", path=tmpdir, modified_after="2 days ago")
result = await find_tool.run(
pattern="*.txt", path=tmpdir, modified_after="2 days ago"
)
recent_files = result.data["results"]
recent_names = [f["name"] for f in recent_files]
@@ -186,7 +188,9 @@ async def test_find_tool_advanced_filters(tool_helper):
assert "new_file.txt" in recent_names
# Test modified_before filter
result = await find_tool.run(pattern="*.txt", path=tmpdir, modified_before="30 minutes ago")
result = await find_tool.run(
pattern="*.txt", path=tmpdir, modified_before="30 minutes ago"
)
older_files = result.data["results"]
older_names = [f["name"] for f in older_files]
@@ -203,7 +207,9 @@ async def test_find_tool_advanced_filters(tool_helper):
assert sorted_files[-1]["name"] == "new_file.txt"
# Test reverse sorting
result = await find_tool.run(pattern="*.txt", path=tmpdir, sort_by="modified", reverse=True)
result = await find_tool.run(
pattern="*.txt", path=tmpdir, sort_by="modified", reverse=True
)
reverse_sorted = result.data["results"]
assert reverse_sorted[0]["name"] == "new_file.txt"
@@ -16,7 +16,9 @@ def test_find_tool_in_filesystem_tools():
pm = PermissionManager()
# Register filesystem tools
tools = register_filesystem_tools(mcp_server=mcp, permission_manager=pm, enabled_tools={"find": True})
tools = register_filesystem_tools(
mcp_server=mcp, permission_manager=pm, enabled_tools={"find": True}
)
# Check that tools were registered
assert len(tools) > 0
@@ -35,7 +37,9 @@ def test_find_tool_in_filesystem_tools():
def test_find_tool_in_server():
"""Test that FindTool is registered when creating a server."""
# Create server with search tools enabled
server = HanzoMCPServer(name="test-server", disable_search_tools=False, enabled_tools={"find": True})
server = HanzoMCPServer(
name="test-server", disable_search_tools=False, enabled_tools={"find": True}
)
# The server registers tools during initialization
# We can't directly access _tool_handlers, but we know tools are registered
@@ -54,7 +58,9 @@ def test_tool_registration_flow():
pm = PermissionManager()
# Test filesystem tools registration directly
filesystem_tools = register_filesystem_tools(mcp_server=mcp, permission_manager=pm, enabled_tools={"find": True})
filesystem_tools = register_filesystem_tools(
mcp_server=mcp, permission_manager=pm, enabled_tools={"find": True}
)
assert len(filesystem_tools) > 0
@@ -89,7 +95,9 @@ async def test_find_tool_usage():
assert "results" in result.data
assert isinstance(result.data["results"], list)
print(f"✓ FindTool executed successfully, found {len(result.data['results'])} files")
print(
f"✓ FindTool executed successfully, found {len(result.data['results'])} files"
)
if __name__ == "__main__":
+29 -9
View File
@@ -26,13 +26,19 @@ class TestGitIngestion:
cwd=repo_path,
check=True,
)
subprocess.run(["git", "config", "user.name", "Test User"], cwd=repo_path, check=True)
subprocess.run(
["git", "config", "user.name", "Test User"], cwd=repo_path, check=True
)
# Create initial commit
readme = repo_path / "README.md"
readme.write_text("# Test Project\n\nThis is a test project for git ingestion.")
readme.write_text(
"# Test Project\n\nThis is a test project for git ingestion."
)
subprocess.run(["git", "add", "README.md"], cwd=repo_path, check=True)
subprocess.run(["git", "commit", "-m", "Initial commit"], cwd=repo_path, check=True)
subprocess.run(
["git", "commit", "-m", "Initial commit"], cwd=repo_path, check=True
)
# Add Python file
main_py = repo_path / "main.py"
@@ -49,7 +55,9 @@ if __name__ == "__main__":
'''
)
subprocess.run(["git", "add", "main.py"], cwd=repo_path, check=True)
subprocess.run(["git", "commit", "-m", "Add main.py"], cwd=repo_path, check=True)
subprocess.run(
["git", "commit", "-m", "Add main.py"], cwd=repo_path, check=True
)
# Add more files and commits
utils_py = repo_path / "utils.py"
@@ -100,7 +108,9 @@ if __name__ == "__main__":
)
# Create a feature branch
subprocess.run(["git", "checkout", "-b", "feature/testing"], cwd=repo_path, check=True)
subprocess.run(
["git", "checkout", "-b", "feature/testing"], cwd=repo_path, check=True
)
test_py = repo_path / "test_main.py"
test_py.write_text(
@@ -117,7 +127,9 @@ class TestMain(unittest.TestCase):
'''
)
subprocess.run(["git", "add", "test_main.py"], cwd=repo_path, check=True)
subprocess.run(["git", "commit", "-m", "Add tests"], cwd=repo_path, check=True)
subprocess.run(
["git", "commit", "-m", "Add tests"], cwd=repo_path, check=True
)
# Switch back to main branch
subprocess.run(["git", "checkout", "main"], cwd=repo_path, check=True)
@@ -156,7 +168,9 @@ class TestMain(unittest.TestCase):
parts = line.split("\t")
if len(parts) >= 2:
status, filename = parts[0], parts[1]
current_commit["files"].append({"status": status, "filename": filename})
current_commit["files"].append(
{"status": status, "filename": filename}
)
assert len(commits) >= 4 # Should have at least 4 commits
assert any(c["message"] == "Initial commit" for c in commits)
@@ -267,7 +281,11 @@ class TestMain(unittest.TestCase):
"history_count": len(history),
"first_commit": history[-1]["commit"] if history else None,
"last_commit": history[0]["commit"] if history else None,
"last_modified": (datetime.fromtimestamp(history[0]["timestamp"]).isoformat() if history else None),
"last_modified": (
datetime.fromtimestamp(history[0]["timestamp"]).isoformat()
if history
else None
),
}
assert metadata["history_count"] >= 2
@@ -281,7 +299,9 @@ class TestGitProjectManager:
def project_manager(self):
"""Create a project manager."""
with tempfile.TemporaryDirectory() as tmpdir:
manager = ProjectVectorManager(global_db_path=str(Path(tmpdir) / "global_db"))
manager = ProjectVectorManager(
global_db_path=str(Path(tmpdir) / "global_db")
)
yield manager, tmpdir
def test_detect_git_project(self, tool_helper, project_manager):
@@ -61,7 +61,9 @@ try:
print("\n✓ Web3Agent config imported")
config = Web3AgentConfig(wallet_enabled=True, tee_enabled=True, task_price_eth=0.01)
print(f"✓ Created Web3 config: wallet={config.wallet_enabled}, tee={config.tee_enabled}")
print(
f"✓ Created Web3 config: wallet={config.wallet_enabled}, tee={config.tee_enabled}"
)
except Exception as e:
print(f"\n✗ Web3Agent test failed: {e}")
@@ -73,7 +73,9 @@ class TestHanzoMCPIntegration:
assert "success" in result_text.lower()
# Test read operation
read_result = await mcp_server.mcp.call_tool("read", arguments={"file_path": str(test_file)})
read_result = await mcp_server.mcp.call_tool(
"read", arguments={"file_path": str(test_file)}
)
# read_result is a tuple (content_list, metadata)
if isinstance(read_result, tuple) and len(read_result) > 0:
content_list = read_result[0]
@@ -95,7 +97,9 @@ class TestHanzoMCPIntegration:
)
# Verify edit
read_after_edit = await mcp_server.mcp.call_tool("read", arguments={"file_path": str(test_file)})
read_after_edit = await mcp_server.mcp.call_tool(
"read", arguments={"file_path": str(test_file)}
)
# read_after_edit is a tuple (content_list, metadata)
if isinstance(read_after_edit, tuple) and len(read_after_edit) > 0:
content_list = read_after_edit[0]
@@ -120,7 +124,9 @@ def function_{i}():
)
# Test search
search_result = await mcp_server.mcp.call_tool("search", arguments={"pattern": "TODO", "path": str(temp_dir)})
search_result = await mcp_server.mcp.call_tool(
"search", arguments={"pattern": "TODO", "path": str(temp_dir)}
)
# Handle tuple result from call_tool
if isinstance(search_result, tuple) and len(search_result) > 0:
@@ -197,7 +203,9 @@ def main():
)
# 1. Search for TODOs
search_result = await mcp_server.mcp.call_tool("search", arguments={"pattern": "TODO", "path": str(temp_dir)})
search_result = await mcp_server.mcp.call_tool(
"search", arguments={"pattern": "TODO", "path": str(temp_dir)}
)
# Handle tuple result
if isinstance(search_result, tuple) and len(search_result) > 0:
content_list = search_result[0]
@@ -210,7 +218,9 @@ def main():
assert "TODO" in result_text
# 2. Read the file
content = await mcp_server.mcp.call_tool("read", arguments={"file_path": str(test_file)})
content = await mcp_server.mcp.call_tool(
"read", arguments={"file_path": str(test_file)}
)
# Handle tuple result
if isinstance(content, tuple) and len(content) > 0:
content_list = content[0]
@@ -235,7 +245,9 @@ def main():
# 4. Run the critic tool
critic_result = await mcp_server.mcp.call_tool(
"critic",
arguments={"analysis": f"Review the code in {test_file} for potential issues"},
arguments={
"analysis": f"Review the code in {test_file} for potential issues"
},
)
# Handle tuple result
@@ -249,7 +261,10 @@ def main():
critic_text = str(critic_result)
# The critic tool currently just returns a confirmation message
# Check that it returns the expected template response
assert "Critical analysis complete" in critic_text or "analysis" in critic_text.lower()
assert (
"Critical analysis complete" in critic_text
or "analysis" in critic_text.lower()
)
async def test_notebook_operations(self, tool_helper, mcp_server, temp_dir):
"""Test notebook read/write operations."""
@@ -319,7 +334,9 @@ def main():
assert "cells" in notebook_data
assert len(notebook_data["cells"]) >= 1 # At least the original code cell
# Check that we have at least one code cell
code_cells = [cell for cell in notebook_data["cells"] if cell.get("cell_type") == "code"]
code_cells = [
cell for cell in notebook_data["cells"] if cell.get("cell_type") == "code"
]
assert len(code_cells) >= 1
@@ -345,7 +362,9 @@ class TestHanzoMCPStdioServer:
return
# Use the MCP client session to test the server
async with stdio_client(["python", "-m", "hanzo_mcp"], env={**os.environ, **server_env}) as (read, write):
async with stdio_client(
["python", "-m", "hanzo_mcp"], env={**os.environ, **server_env}
) as (read, write):
# Create a client session
async with ClientSession(read, write) as session:
# Initialize the session
@@ -381,16 +400,22 @@ class TestHanzoMCPStdioServer:
async def test_hanzo_mcp_cli_tool():
"""Test the hanzo-mcp CLI tool directly."""
# Test help command
result = subprocess.run(["python", "-m", "hanzo_mcp", "--help"], capture_output=True, text=True)
result = subprocess.run(
["python", "-m", "hanzo_mcp", "--help"], capture_output=True, text=True
)
assert result.returncode == 0
assert "usage" in result.stdout.lower() or "mcp server" in result.stdout.lower()
# Test version command
result = subprocess.run(["python", "-m", "hanzo_mcp", "--version"], capture_output=True, text=True)
result = subprocess.run(
["python", "-m", "hanzo_mcp", "--version"], capture_output=True, text=True
)
assert result.returncode == 0
assert "0.6" in result.stdout or "0.7" in result.stdout # Should show version 0.6.x or 0.7.x
assert (
"0.6" in result.stdout or "0.7" in result.stdout
) # Should show version 0.6.x or 0.7.x
if __name__ == "__main__":
+24 -8
View File
@@ -24,7 +24,9 @@ def test_basic_import():
def test_server_creation():
"""Test creating an MCP server."""
with tempfile.TemporaryDirectory() as tmpdir:
server = create_server(name="test-server", allowed_paths=[tmpdir], enable_all_tools=True)
server = create_server(
name="test-server", allowed_paths=[tmpdir], enable_all_tools=True
)
print(f"✓ Created server: {server.__class__.__name__}")
@@ -61,7 +63,9 @@ import pytest
async def test_file_operations():
"""Test basic file operations."""
with tempfile.TemporaryDirectory() as tmpdir:
server = create_server(name="test-server", allowed_paths=[tmpdir], enable_all_tools=True)
server = create_server(
name="test-server", allowed_paths=[tmpdir], enable_all_tools=True
)
test_file = Path(tmpdir) / "test.txt"
test_content = "Hello from hanzo-mcp test!"
@@ -76,7 +80,9 @@ async def test_file_operations():
assert test_file.read_text() == test_content
# Test read operation
read_result = await server.mcp.call_tool("read", arguments={"file_path": str(test_file)})
read_result = await server.mcp.call_tool(
"read", arguments={"file_path": str(test_file)}
)
# Handle tuple result from call_tool
if isinstance(read_result, tuple) and len(read_result) > 0:
@@ -107,7 +113,9 @@ async def test_file_operations():
async def test_search_functionality():
"""Test search functionality."""
with tempfile.TemporaryDirectory() as tmpdir:
server = create_server(name="test-server", allowed_paths=[tmpdir], enable_all_tools=True)
server = create_server(
name="test-server", allowed_paths=[tmpdir], enable_all_tools=True
)
# Create test files
for i in range(3):
@@ -122,7 +130,9 @@ def function_{i}():
)
# Search for TODOs using the search tool
search_result = await server.mcp.call_tool("search", arguments={"pattern": "TODO", "path": str(tmpdir)})
search_result = await server.mcp.call_tool(
"search", arguments={"pattern": "TODO", "path": str(tmpdir)}
)
# Handle tuple result from call_tool
if isinstance(search_result, tuple) and len(search_result) > 0:
@@ -148,7 +158,9 @@ def test_cli_invocation():
print("\n Testing CLI:")
# Test help
result = subprocess.run([sys.executable, "-m", "hanzo_mcp", "--help"], capture_output=True, text=True)
result = subprocess.run(
[sys.executable, "-m", "hanzo_mcp", "--help"], capture_output=True, text=True
)
if result.returncode == 0:
print(" ✓ CLI help works")
@@ -156,7 +168,9 @@ def test_cli_invocation():
print(f" ✗ CLI help failed: {result.stderr}")
# Test version
result = subprocess.run([sys.executable, "-m", "hanzo_mcp", "--version"], capture_output=True, text=True)
result = subprocess.run(
[sys.executable, "-m", "hanzo_mcp", "--version"], capture_output=True, text=True
)
if result.returncode == 0 and "0.7" in result.stdout:
print(f" ✓ CLI version works: {result.stdout.strip()}")
@@ -168,7 +182,9 @@ def test_cli_invocation():
async def test_notebook_operations():
"""Test notebook operations."""
with tempfile.TemporaryDirectory() as tmpdir:
server = create_server(name="test-server", allowed_paths=[tmpdir], enable_all_tools=True)
server = create_server(
name="test-server", allowed_paths=[tmpdir], enable_all_tools=True
)
notebook_path = Path(tmpdir) / "test.ipynb"
+12 -4
View File
@@ -14,7 +14,9 @@ sys.path.insert(0, str(Path(__file__).parent.parent))
def test_cli_help():
"""Test that CLI help works."""
result = subprocess.run([sys.executable, "-m", "hanzo_mcp", "--help"], capture_output=True, text=True)
result = subprocess.run(
[sys.executable, "-m", "hanzo_mcp", "--help"], capture_output=True, text=True
)
print(f"Return code: {result.returncode}")
print(f"STDOUT: {result.stdout[:200]}")
@@ -29,7 +31,9 @@ def test_cli_help():
def test_cli_version():
"""Test version command."""
result = subprocess.run([sys.executable, "-m", "hanzo_mcp", "--version"], capture_output=True, text=True)
result = subprocess.run(
[sys.executable, "-m", "hanzo_mcp", "--version"], capture_output=True, text=True
)
print(f"Version output: {result.stdout}")
# Version command might not be supported, so just check if command doesn't crash
@@ -77,7 +81,9 @@ async def test_stdio_server():
# Read response with timeout
try:
response_line = await asyncio.wait_for(proc.stdout.readline(), timeout=5.0)
response_line = await asyncio.wait_for(
proc.stdout.readline(), timeout=5.0
)
response = json.loads(response_line.decode())
print(f"Initialize response: {response}")
@@ -96,7 +102,9 @@ async def test_stdio_server():
proc.stdin.write((json.dumps(list_request) + "\n").encode())
await proc.stdin.drain()
response_line = await asyncio.wait_for(proc.stdout.readline(), timeout=5.0)
response_line = await asyncio.wait_for(
proc.stdout.readline(), timeout=5.0
)
response = json.loads(response_line.decode())
print(f"Found {len(response['result']['tools'])} tools")
@@ -105,9 +105,13 @@ class TestHanzoNetworkIntegration:
async def test_agent_communication(self, tool_helper, basic_network):
"""Test agent-to-agent communication."""
# Create communicating agents
agent1 = Agent(name="agent1", role="Coordinator", capabilities=["coordination", "planning"])
agent1 = Agent(
name="agent1", role="Coordinator", capabilities=["coordination", "planning"]
)
agent2 = Agent(name="agent2", role="Worker", capabilities=["execution", "reporting"])
agent2 = Agent(
name="agent2", role="Worker", capabilities=["execution", "reporting"]
)
# Add message handler to agent2
messages_received = []
@@ -347,13 +351,17 @@ class TestHanzoNetworkIntegration:
is_local=True,
)
api_agent = Agent(name="api_expert", role="Expert Consultant", model="gpt-4", is_local=False)
api_agent = Agent(
name="api_expert", role="Expert Consultant", model="gpt-4", is_local=False
)
await basic_network.add_agent(local_agent)
await basic_network.add_agent(api_agent)
# Simple task (should go to local)
simple_result = await basic_network.delegate_task(task='Format this JSON: {"name":"test"}', complexity="simple")
simple_result = await basic_network.delegate_task(
task='Format this JSON: {"name":"test"}', complexity="simple"
)
assert simple_result["agent"] == "local_helper"
assert simple_result["cost"] < 0.01
@@ -366,7 +374,9 @@ class TestHanzoNetworkIntegration:
assert complex_result["agent"] == "api_expert"
async def test_agent_network_persistence(self, tool_helper, basic_network, temp_dir):
async def test_agent_network_persistence(
self, tool_helper, basic_network, temp_dir
):
"""Test saving and loading agent network state."""
# Add some agents and state
agent1 = Agent(name="persistent_agent", role="Keeper")
@@ -470,7 +480,9 @@ class TestHanzoNetworkMCPIntegration:
# Create specialized agents
analyst = Agent(name="analyst", role="Code Analyst", has_mcp_access=True)
refactorer = Agent(name="refactorer", role="Code Refactorer", has_mcp_access=True)
refactorer = Agent(
name="refactorer", role="Code Refactorer", has_mcp_access=True
)
reviewer = Agent(name="reviewer", role="Code Reviewer", has_mcp_access=True)
@@ -53,7 +53,9 @@ async def test_agent_clarification_flow(test_project, mock_context):
permission_manager = PermissionManager(allowed_paths=[str(test_project)])
# Create an agent tool with test model
agent = AgentTool(permission_manager=permission_manager, model="test-model", max_iterations=5)
agent = AgentTool(
permission_manager=permission_manager, model="test-model", max_iterations=5
)
# Mock the litellm completion to simulate agent requesting clarification
with patch("litellm.completion") as mock_completion:
@@ -69,7 +71,9 @@ async def test_agent_clarification_flow(test_project, mock_context):
Mock(
function=Mock(
name="read",
arguments=json.dumps({"file_path": str(test_project / "main.go")}),
arguments=json.dumps(
{"file_path": str(test_project / "main.go")}
),
),
id="call_1",
)
@@ -93,7 +97,9 @@ async def test_agent_clarification_flow(test_project, mock_context):
"type": "MISSING_CONTEXT",
"question": "What is the correct import path for the common package?",
"context": {
"file_path": str(test_project / "main.go"),
"file_path": str(
test_project / "main.go"
),
"undefined_symbols": ["common"],
},
"options": [
@@ -123,7 +129,9 @@ async def test_agent_clarification_flow(test_project, mock_context):
name="multi_edit",
arguments=json.dumps(
{
"file_path": str(test_project / "main.go"),
"file_path": str(
test_project / "main.go"
),
"edits": [
{
"old_string": 'import (\n "fmt"\n)',
@@ -177,7 +185,9 @@ async def test_agent_critic_flow(test_project, mock_context):
permission_manager = PermissionManager(allowed_paths=[str(test_project)])
# Create an agent tool
agent = AgentTool(permission_manager=permission_manager, model="test-model", max_iterations=5)
agent = AgentTool(
permission_manager=permission_manager, model="test-model", max_iterations=5
)
# Mock the litellm completion to simulate agent requesting critic review
with patch("litellm.completion") as mock_completion:
@@ -192,7 +202,9 @@ async def test_agent_critic_flow(test_project, mock_context):
Mock(
function=Mock(
name="read",
arguments=json.dumps({"file_path": str(test_project / "main.go")}),
arguments=json.dumps(
{"file_path": str(test_project / "main.go")}
),
),
id="call_1",
)
@@ -213,7 +225,9 @@ async def test_agent_critic_flow(test_project, mock_context):
name="multi_edit",
arguments=json.dumps(
{
"file_path": str(test_project / "main.go"),
"file_path": str(
test_project / "main.go"
),
"edits": [
{
"old_string": 'import (\n "fmt"\n)',
@@ -247,7 +261,9 @@ async def test_agent_critic_flow(test_project, mock_context):
"code_snippets": [
'import (\n "fmt"\n "github.com/project/common"\n)'
],
"file_paths": [str(test_project / "main.go")],
"file_paths": [
str(test_project / "main.go")
],
"specific_concerns": "Is the import in the correct format and position?",
}
),
@@ -288,7 +304,9 @@ async def test_clarification_limits(test_project, mock_context):
"""Test that clarification requests are limited."""
permission_manager = PermissionManager(allowed_paths=[str(test_project)])
agent = AgentTool(permission_manager=permission_manager, model="test-model", max_iterations=5)
agent = AgentTool(
permission_manager=permission_manager, model="test-model", max_iterations=5
)
# Test that only one clarification is allowed
with patch("litellm.completion") as mock_completion:
@@ -367,7 +385,9 @@ async def test_critic_limits(test_project, mock_context):
"""Test that critic reviews are limited."""
permission_manager = PermissionManager(allowed_paths=[str(test_project)])
agent = AgentTool(permission_manager=permission_manager, model="test-model", max_iterations=6)
agent = AgentTool(
permission_manager=permission_manager, model="test-model", max_iterations=6
)
# Test that only two critic reviews are allowed
with patch("litellm.completion") as mock_completion:
@@ -471,7 +491,11 @@ async def test_clarification_protocol_types():
request_type=clarification_type,
question=f"Test question for {clarification_type.value}",
context=context,
options=(["option1", "option2"] if clarification_type == ClarificationType.MULTIPLE_OPTIONS else None),
options=(
["option1", "option2"]
if clarification_type == ClarificationType.MULTIPLE_OPTIONS
else None
),
)
assert request_id.startswith("clarify_")
@@ -61,7 +61,14 @@ async def test_agent_uses_all_tools(test_project, mock_context):
Mock(
function=Mock(
name="read",
arguments=json.dumps({"file_path": str(test_project / "complex_algorithm.py")}),
arguments=json.dumps(
{
"file_path": str(
test_project
/ "complex_algorithm.py"
)
}
),
),
id="call_1",
)
@@ -138,7 +145,10 @@ async def test_agent_uses_all_tools(test_project, mock_context):
name="multi_edit",
arguments=json.dumps(
{
"file_path": str(test_project / "complex_algorithm.py"),
"file_path": str(
test_project
/ "complex_algorithm.py"
),
"edits": [
{
"old_string": """def find_optimal_path(graph, start, end):
@@ -181,7 +191,12 @@ async def test_agent_uses_all_tools(test_project, mock_context):
"code_snippets": [
'def find_optimal_path(graph, start, end):\n """Find optimal path"""'
],
"file_paths": [str(test_project / "complex_algorithm.py")],
"file_paths": [
str(
test_project
/ "complex_algorithm.py"
)
],
"context": "This needs to handle various graph types efficiently",
}
),
@@ -207,8 +222,15 @@ async def test_agent_uses_all_tools(test_project, mock_context):
{
"review_type": "PERFORMANCE",
"work_description": "Pathfinding algorithm implementation",
"code_snippets": ["def find_optimal_path(graph, start, end):"],
"file_paths": [str(test_project / "complex_algorithm.py")],
"code_snippets": [
"def find_optimal_path(graph, start, end):"
],
"file_paths": [
str(
test_project
/ "complex_algorithm.py"
)
],
"specific_concerns": "Will this scale to large graphs?",
}
),
@@ -287,11 +309,17 @@ async def test_iching_tool_directly():
# Verify it selected relevant principles
if "scale" in challenge.lower():
assert any(word in result for word in ["Scalable", "Exponentiality", "Growth"])
assert any(
word in result for word in ["Scalable", "Exponentiality", "Growth"]
)
elif "refactor" in challenge.lower():
assert any(word in result for word in ["Simplicity", "Clarity", "Composable"])
assert any(
word in result for word in ["Simplicity", "Clarity", "Composable"]
)
elif "team" in challenge.lower():
assert any(word in result for word in ["Autonomy", "Balance", "Collaboration"])
assert any(
word in result for word in ["Autonomy", "Balance", "Collaboration"]
)
@pytest.mark.asyncio
@@ -304,7 +332,9 @@ async def test_review_vs_critic_difference():
reviewer = ReviewProtocol()
work_description = "Implemented user authentication with JWT tokens"
code_snippet = ["func authenticate(token string) (User, error) { return User{}, nil }"]
code_snippet = [
"func authenticate(token string) (User, error) { return User{}, nil }"
]
# Get critic feedback (harsh)
critic_feedback = critic.request_review(
@@ -345,7 +375,9 @@ async def test_tool_limits():
# First should work
try:
await clarifier.request_clarification(ClarificationType.MISSING_CONTEXT, "Question 1", {})
await clarifier.request_clarification(
ClarificationType.MISSING_CONTEXT, "Question 1", {}
)
except AttributeError:
# Handler doesn't have async request_clarification, that's ok
pass
@@ -69,7 +69,9 @@ class TestUnifiedJupyterTool:
ctx = MCPContext()
# Read entire notebook
result = await jupyter_tool.call(ctx, action="read", notebook_path=str(sample_notebook))
result = await jupyter_tool.call(
ctx, action="read", notebook_path=str(sample_notebook)
)
assert "Notebook with 2 cells" in result
assert "Hello, World!" in result
@@ -77,7 +79,9 @@ class TestUnifiedJupyterTool:
assert "[stdout]: Hello, World!" in result
# Read specific cell by index
result = await jupyter_tool.call(ctx, action="read", notebook_path=str(sample_notebook), cell_index=0)
result = await jupyter_tool.call(
ctx, action="read", notebook_path=str(sample_notebook), cell_index=0
)
assert "Cell 0 (code)" in result
assert "print('Hello, World!')" in result
@@ -88,7 +92,9 @@ class TestUnifiedJupyterTool:
ctx = MCPContext()
new_notebook = tmp_path / "new.ipynb"
result = await jupyter_tool.call(ctx, action="create", notebook_path=str(new_notebook))
result = await jupyter_tool.call(
ctx, action="create", notebook_path=str(new_notebook)
)
assert "Successfully created notebook" in result
assert new_notebook.exists()
@@ -182,7 +188,9 @@ class TestUnifiedJupyterTool:
"""Test deleting entire notebook."""
ctx = MCPContext()
result = await jupyter_tool.call(ctx, action="delete", notebook_path=str(sample_notebook))
result = await jupyter_tool.call(
ctx, action="delete", notebook_path=str(sample_notebook)
)
assert "Successfully deleted notebook" in result
assert not sample_notebook.exists()
@@ -193,11 +201,15 @@ class TestUnifiedJupyterTool:
ctx = MCPContext()
# Non-existent file
result = await jupyter_tool.call(ctx, action="read", notebook_path=str(tmp_path / "nonexistent.ipynb"))
result = await jupyter_tool.call(
ctx, action="read", notebook_path=str(tmp_path / "nonexistent.ipynb")
)
assert "Error:" in result and "does not exist" in result
# Invalid action
result = await jupyter_tool.call(ctx, action="invalid", notebook_path=str(tmp_path / "test.ipynb"))
result = await jupyter_tool.call(
ctx, action="invalid", notebook_path=str(tmp_path / "test.ipynb")
)
assert "Error: Unknown action" in result
# Missing required params for edit (need to test insert mode)
+3 -1
View File
@@ -20,7 +20,9 @@ def test_no_pydantic_warnings():
)
# Check that the command succeeded
assert result.returncode == 0, f"Command failed with return code {result.returncode}"
assert result.returncode == 0, (
f"Command failed with return code {result.returncode}"
)
# Check that help text is shown
assert "MCP server implementing Hanzo AI capabilities" in result.stdout
+3 -1
View File
@@ -51,7 +51,9 @@ async def test_basic_functionality():
print(f"✅ Shell detected: {interpreter} (tool: {tool_name})")
# Test simple command
result = await bash_tool.execute_sync("echo 'Hello from Hanzo AI!'", timeout=5)
result = await bash_tool.execute_sync(
"echo 'Hello from Hanzo AI!'", timeout=5
)
print(f"✅ Command result: {result.strip()}")
except Exception as e:
print(f"❌ Shell test failed: {e}")
+9 -3
View File
@@ -17,7 +17,9 @@ except ImportError:
Memory = None # type: ignore
# Skip entire module if hanzo_memory is not available
pytestmark = pytest.mark.skipif(not HANZO_MEMORY_AVAILABLE, reason="hanzo_memory package not installed")
pytestmark = pytest.mark.skipif(
not HANZO_MEMORY_AVAILABLE, reason="hanzo_memory package not installed"
)
# Only import these if hanzo_memory is available
if HANZO_MEMORY_AVAILABLE:
@@ -60,7 +62,9 @@ class MemoryTestBase:
@pytest.fixture
def mock_memory_service(self):
"""Create a mock memory service with standard responses."""
with patch("hanzo_memory.services.memory.get_memory_service") as mock_get_service:
with patch(
"hanzo_memory.services.memory.get_memory_service"
) as mock_get_service:
mock_service = Mock()
# Set up standard responses
@@ -73,7 +77,9 @@ class MemoryTestBase:
importance=1.0,
)
mock_service.update_memory.return_value = Mock(memory_id="mem_123", content="Updated memory")
mock_service.update_memory.return_value = Mock(
memory_id="mem_123", content="Updated memory"
)
mock_service.delete_memory.return_value = None
+6 -2
View File
@@ -8,7 +8,9 @@ from unittest.mock import AsyncMock, Mock, patch
def test_basic_memory():
"""Basic test without pytest complexity."""
with patch("hanzo_memory.services.memory.get_memory_service") as mock_get_service:
with patch("hanzo_tools.memory.memory_tools.create_tool_context") as mock_create_tool_context:
with patch(
"hanzo_tools.memory.memory_tools.create_tool_context"
) as mock_create_tool_context:
# Mock the tool context
mock_tool_ctx = Mock()
mock_tool_ctx.set_tool_info = AsyncMock()
@@ -54,7 +56,9 @@ def test_basic_memory():
)
print(f"Result: {result}")
print(f"create_memory called: {mock_service.create_memory.call_count} times")
print(
f"create_memory called: {mock_service.create_memory.call_count} times"
)
# Check result
assert "Successfully created 2 new memories" in str(result)
@@ -1,6 +1,5 @@
"""Consolidated memory tests using parametrization to reduce redundancy."""
import pytest
# Import guard for optional hanzo_memory dependency
@@ -12,7 +11,9 @@ except ImportError:
HANZO_MEMORY_AVAILABLE = False
# Skip entire module if hanzo_memory is not available
pytestmark = pytest.mark.skipif(not HANZO_MEMORY_AVAILABLE, reason="hanzo_memory package not installed")
pytestmark = pytest.mark.skipif(
not HANZO_MEMORY_AVAILABLE, reason="hanzo_memory package not installed"
)
# Only import these if hanzo_memory is available
if HANZO_MEMORY_AVAILABLE:
+3 -1
View File
@@ -19,7 +19,9 @@ def test_memory_registration():
permission_manager = PermissionManager()
permission_manager.add_allowed_path("/tmp")
tools = register_memory_tools(mcp_server, permission_manager, user_id="test_user", project_id="test_project")
tools = register_memory_tools(
mcp_server, permission_manager, user_id="test_user", project_id="test_project"
)
assert len(tools) == 9
print(f"Successfully registered {len(tools)} memory tools")
+60 -20
View File
@@ -53,7 +53,9 @@ class TestGraphTools:
"""Test graph database tools."""
@pytest.mark.asyncio
async def test_graph_add_node(self, tool_helper, mock_ctx, permission_manager, db_manager):
async def test_graph_add_node(
self, tool_helper, mock_ctx, permission_manager, db_manager
):
"""Test adding a node to the graph."""
tool = GraphAddTool(permission_manager, db_manager)
@@ -68,7 +70,9 @@ class TestGraphTools:
tool_helper.assert_in_result("Successfully added node 'test_node'", result)
@pytest.mark.asyncio
async def test_graph_add_edge(self, tool_helper, mock_ctx, permission_manager, db_manager):
async def test_graph_add_edge(
self, tool_helper, mock_ctx, permission_manager, db_manager
):
"""Test adding an edge to the graph."""
tool = GraphAddTool(permission_manager, db_manager)
@@ -77,14 +81,18 @@ class TestGraphTools:
await tool.call(mock_ctx, node_id="node2", node_type="file")
# Add edge
result = await tool.call(mock_ctx, source="node1", target="node2", relationship="imports")
result = await tool.call(
mock_ctx, source="node1", target="node2", relationship="imports"
)
if isinstance(result, dict) and "output" in result:
result = result["output"]
tool_helper.assert_in_result("Successfully added edge", result)
@pytest.mark.asyncio
async def test_graph_remove_node(self, tool_helper, mock_ctx, permission_manager, db_manager):
async def test_graph_remove_node(
self, tool_helper, mock_ctx, permission_manager, db_manager
):
"""Test removing a node from the graph."""
add_tool = GraphAddTool(permission_manager, db_manager)
remove_tool = GraphRemoveTool(permission_manager, db_manager)
@@ -98,7 +106,9 @@ class TestGraphTools:
tool_helper.assert_in_result("Successfully removed node 'test_node'", result)
@pytest.mark.asyncio
async def test_graph_query_neighbors(self, tool_helper, mock_ctx, permission_manager, db_manager):
async def test_graph_query_neighbors(
self, tool_helper, mock_ctx, permission_manager, db_manager
):
"""Test querying neighbors in the graph."""
add_tool = GraphAddTool(permission_manager, db_manager)
query_tool = GraphQueryTool(permission_manager, db_manager)
@@ -118,7 +128,9 @@ class TestGraphTools:
tool_helper.assert_in_result("C", result)
@pytest.mark.asyncio
async def test_graph_search(self, tool_helper, mock_ctx, permission_manager, db_manager):
async def test_graph_search(
self, tool_helper, mock_ctx, permission_manager, db_manager
):
"""Test searching in the graph."""
add_tool = GraphAddTool(permission_manager, db_manager)
search_tool = GraphSearchTool(permission_manager, db_manager)
@@ -137,9 +149,13 @@ class TestGraphTools:
tool_helper.assert_in_result("Found", result)
tool_helper.assert_in_result("main.py", result)
@pytest.mark.skip(reason="Graph stats test needs database isolation - stale data causes false failures")
@pytest.mark.skip(
reason="Graph stats test needs database isolation - stale data causes false failures"
)
@pytest.mark.asyncio
async def test_graph_stats(self, tool_helper, mock_ctx, permission_manager, db_manager):
async def test_graph_stats(
self, tool_helper, mock_ctx, permission_manager, db_manager
):
"""Test graph statistics."""
add_tool = GraphAddTool(permission_manager, db_manager)
stats_tool = GraphStatsTool(permission_manager, db_manager)
@@ -184,7 +200,9 @@ class TestFindFilesTool:
assert "data.txt" not in result
@pytest.mark.asyncio
async def test_find_files_recursive(self, tool_helper, mock_ctx, permission_manager):
async def test_find_files_recursive(
self, tool_helper, mock_ctx, permission_manager
):
"""Test recursive file finding."""
tool = FindFilesTool(permission_manager)
@@ -198,7 +216,9 @@ class TestFindFilesTool:
permission_manager.add_allowed_path(tmpdir)
# Find all txt files
result = await tool.call(mock_ctx, pattern="*.txt", path=tmpdir, recursive=True)
result = await tool.call(
mock_ctx, pattern="*.txt", path=tmpdir, recursive=True
)
if isinstance(result, dict) and "output" in result:
result = result["output"]
@@ -216,7 +236,9 @@ class TestPackageRunnerTools:
tool = UvxTool(permission_manager)
with patch("subprocess.run") as mock_run:
mock_run.return_value = MagicMock(stdout="Package output", stderr="", returncode=0)
mock_run.return_value = MagicMock(
stdout="Package output", stderr="", returncode=0
)
with patch("shutil.which", return_value="/usr/bin/uvx"):
result = await tool.call(mock_ctx, package="ruff", args="check .")
@@ -232,7 +254,9 @@ class TestPackageRunnerTools:
tool = NpxTool(permission_manager)
with patch("subprocess.run") as mock_run:
mock_run.return_value = MagicMock(stdout="Package output", stderr="", returncode=0)
mock_run.return_value = MagicMock(
stdout="Package output", stderr="", returncode=0
)
with patch("shutil.which", return_value="/usr/bin/npx"):
result = await tool.call(mock_ctx, package="eslint", args="--version")
@@ -254,7 +278,9 @@ class TestPackageRunnerTools:
mock_popen.return_value = mock_process
with patch("shutil.which", return_value="/usr/bin/uvx"):
result = await tool.call(mock_ctx, package="streamlit", args="run app.py", name="test-app")
result = await tool.call(
mock_ctx, package="streamlit", args="run app.py", name="test-app"
)
if isinstance(result, dict) and "output" in result:
result = result["output"]
@@ -266,17 +292,23 @@ class TestPackageRunnerTools:
class TestMcpManagementTools:
"""Test MCP management tools."""
@pytest.mark.skip(reason="McpAddTool test modifies user's MCP config - run manually")
@pytest.mark.skip(
reason="McpAddTool test modifies user's MCP config - run manually"
)
@pytest.mark.asyncio
async def test_mcp_add(self, tool_helper, mock_ctx):
"""Test adding an MCP server."""
tool = McpAddTool()
result = await tool.call(mock_ctx, command="uvx mcp-server-git", name="git-server")
result = await tool.call(
mock_ctx, command="uvx mcp-server-git", name="git-server"
)
if isinstance(result, dict) and "output" in result:
result = result["output"]
tool_helper.assert_in_result("Successfully added MCP server 'git-server'", result)
tool_helper.assert_in_result(
"Successfully added MCP server 'git-server'", result
)
# Check it was saved
servers = McpAddTool.get_servers()
@@ -293,7 +325,9 @@ class TestMcpManagementTools:
remove_tool = McpRemoveTool()
result = await remove_tool.call(mock_ctx, name="test-server")
tool_helper.assert_in_result("Successfully removed MCP server 'test-server'", result)
tool_helper.assert_in_result(
"Successfully removed MCP server 'test-server'", result
)
# Check it was removed
servers = McpAddTool.get_servers()
@@ -340,7 +374,9 @@ class TestSystemTools:
with patch("psutil.cpu_percent", return_value=50.0):
with patch("psutil.cpu_count", return_value=4):
with patch("psutil.virtual_memory") as mock_mem:
mock_mem.return_value = MagicMock(total=8 * 1024**3, used=4 * 1024**3, percent=50.0)
mock_mem.return_value = MagicMock(
total=8 * 1024**3, used=4 * 1024**3, percent=50.0
)
with patch("psutil.disk_usage") as mock_disk:
mock_disk.return_value = MagicMock(
@@ -367,14 +403,18 @@ class TestSystemTools:
# Disable a tool
result = await disable_tool.call(mock_ctx, tool="vector_search")
tool_helper.assert_in_result("Successfully disabled tool 'vector_search'", result)
tool_helper.assert_in_result(
"Successfully disabled tool 'vector_search'", result
)
# Check it's disabled
assert not ToolEnableTool.is_tool_enabled("vector_search")
# Re-enable it
result = await enable_tool.call(mock_ctx, tool="vector_search")
tool_helper.assert_in_result("Successfully enabled tool 'vector_search'", result)
tool_helper.assert_in_result(
"Successfully enabled tool 'vector_search'", result
)
# Check it's enabled
assert ToolEnableTool.is_tool_enabled("vector_search")
+65 -18
View File
@@ -43,7 +43,9 @@ class StubDetector(ast.NodeVisitor):
# Check for empty functions with just pass (but allow if has docstring)
if len(node.body) == 1 and isinstance(node.body[0], ast.Pass):
self.issues.append((node.lineno, f"Function '{node.name}' contains only 'pass' statement"))
self.issues.append(
(node.lineno, f"Function '{node.name}' contains only 'pass' statement")
)
elif len(node.body) == 2:
# Check for docstring + pass (also a stub)
if (
@@ -59,7 +61,10 @@ class StubDetector(ast.NodeVisitor):
# But allow if it's an abstract method (has docstring explaining it)
if len(node.body) == 1 and isinstance(node.body[0], ast.Raise):
if isinstance(node.body[0].exc, ast.Call):
if hasattr(node.body[0].exc.func, "id") and node.body[0].exc.func.id == "NotImplementedError":
if (
hasattr(node.body[0].exc.func, "id")
and node.body[0].exc.func.id == "NotImplementedError"
):
# This is a legitimate abstract method pattern, skip it
pass
elif len(node.body) == 2:
@@ -77,7 +82,9 @@ class StubDetector(ast.NodeVisitor):
if len(node.body) == 1 and isinstance(node.body[0], ast.Expr):
if isinstance(node.body[0].value, ast.Constant):
if node.body[0].value.value is Ellipsis:
self.issues.append((node.lineno, f"Function '{node.name}' contains only ellipsis"))
self.issues.append(
(node.lineno, f"Function '{node.name}' contains only ellipsis")
)
self.generic_visit(node)
@@ -104,8 +111,16 @@ def find_stub_patterns(filepath: Path) -> List[Tuple[int, str, str]]:
# Format: (pattern, message, case_insensitive)
patterns = [
# Match TODO/FIXME comments (case insensitive for comments)
(r"#\s*(TODO|FIXME|STUB|FAKE|UNFINISHED|HACK|XXX)\s*:", "contains {0} comment", True),
(r'assert\s+False,?\s*["\']Not implemented', 'has "Not implemented" assertion', True),
(
r"#\s*(TODO|FIXME|STUB|FAKE|UNFINISHED|HACK|XXX)\s*:",
"contains {0} comment",
True,
),
(
r'assert\s+False,?\s*["\']Not implemented',
'has "Not implemented" assertion',
True,
),
]
# Additional patterns for non-test files
@@ -116,7 +131,11 @@ def find_stub_patterns(filepath: Path) -> List[Tuple[int, str, str]]:
# Only match uppercase "TODO"/"STUB" strings (case sensitive to avoid "todo" tool name)
(r'return\s+["\']TODO["\']', "returns TODO string", False),
(r'return\s+["\']STUB["\']', "returns STUB string", False),
(r"return\s+None\s*#\s*(STUB|FAKE)", "returns None with stub comment", True),
(
r"return\s+None\s*#\s*(STUB|FAKE)",
"returns None with stub comment",
True,
),
]
)
@@ -185,11 +204,15 @@ class TestNoStubs:
for filepath in python_files:
issues = find_stub_patterns(filepath)
for line_num, message, _filename in issues:
all_issues.append(f"{filepath.relative_to(package_root.parent)}:{line_num} - {message}")
all_issues.append(
f"{filepath.relative_to(package_root.parent)}:{line_num} - {message}"
)
if all_issues:
report = "\n".join(all_issues)
pytest.fail(f"Found {len(all_issues)} stub/incomplete implementations:\n{report}")
pytest.fail(
f"Found {len(all_issues)} stub/incomplete implementations:\n{report}"
)
def test_critical_functions_implemented(self):
"""Ensure critical functions are actually implemented."""
@@ -227,12 +250,23 @@ class TestNoStubs:
if len(node.body) == 1:
body = node.body[0]
if isinstance(body, ast.Pass):
pytest.fail(f"Function {function_name} in {module_path} contains only 'pass'")
if isinstance(body, ast.Expr) and isinstance(body.value, ast.Constant):
pytest.fail(
f"Function {function_name} in {module_path} contains only 'pass'"
)
if isinstance(body, ast.Expr) and isinstance(
body.value, ast.Constant
):
if body.value.value is Ellipsis:
pytest.fail(f"Function {function_name} in {module_path} contains only '...'")
if isinstance(body, ast.Raise) and isinstance(body.exc, ast.Call):
if hasattr(body.exc.func, "id") and body.exc.func.id == "NotImplementedError":
pytest.fail(
f"Function {function_name} in {module_path} contains only '...'"
)
if isinstance(body, ast.Raise) and isinstance(
body.exc, ast.Call
):
if (
hasattr(body.exc.func, "id")
and body.exc.func.id == "NotImplementedError"
):
pytest.fail(
f"Function {function_name} in {module_path} raises NotImplementedError"
)
@@ -279,7 +313,9 @@ class TestNoStubs:
for pattern in mock_patterns:
if re.search(pattern, content, re.IGNORECASE):
pytest.fail(f"Found mock/fake pattern '{pattern}' in production file: {filepath}")
pytest.fail(
f"Found mock/fake pattern '{pattern}' in production file: {filepath}"
)
def test_all_tool_classes_have_run_method(self):
"""Ensure all tool classes have a proper run or call method."""
@@ -327,7 +363,10 @@ class TestNoStubs:
# Also check for method aliases like "call = execute"
if isinstance(item, ast.Assign):
for target in item.targets:
if isinstance(target, ast.Name) and target.id in ("run", "call"):
if isinstance(target, ast.Name) and target.id in (
"run",
"call",
):
has_run_or_call = True
break
@@ -344,15 +383,23 @@ class TestNoStubs:
elif isinstance(base, ast.Attribute):
base_name = base.attr
# If any base ends with Tool, Base, Mixin it's likely OK
if any(base_name.endswith(suffix) for suffix in ("Tool", "Base", "Mixin")):
if any(
base_name.endswith(suffix)
for suffix in ("Tool", "Base", "Mixin")
):
inherits_from_something_meaningful = True
break
else:
# No bases - this is a standalone Tool class that needs run/call
issues.append(f"Tool class {class_name} in {filepath.name} missing run() or call() method")
issues.append(
f"Tool class {class_name} in {filepath.name} missing run() or call() method"
)
if issues:
pytest.fail(f"Found {len(issues)} tool classes without run()/call() method:\n" + "\n".join(issues[:10]))
pytest.fail(
f"Found {len(issues)} tool classes without run()/call() method:\n"
+ "\n".join(issues[:10])
)
def test_no_debug_prints_in_production(self):
"""Ensure no debug print statements in production code."""
+28 -8
View File
@@ -75,7 +75,9 @@ class TestMemoryPerformance:
return Mock(memory_id=f"mem_{len(operation_times)}")
mock_service.create_memory = Mock(side_effect=mock_operation)
mock_service.search_memories = Mock(side_effect=lambda *a, **k: asyncio.create_task(mock_operation(*a, **k)))
mock_service.search_memories = Mock(
side_effect=lambda *a, **k: asyncio.create_task(mock_operation(*a, **k))
)
mock_get_service.return_value = mock_service
async def run_concurrent_operations():
@@ -102,7 +104,9 @@ class TestMemoryPerformance:
sequential_time = sum(op_times)
assert total_time < sequential_time * 0.5 # At least 2x speedup
print(f"Concurrent: {total_time:.2f}s, Sequential would be: {sequential_time:.2f}s")
print(
f"Concurrent: {total_time:.2f}s, Sequential would be: {sequential_time:.2f}s"
)
class TestSearchPerformance:
@@ -136,7 +140,11 @@ class TestSearchPerformance:
# Search for pattern
start_time = time.time()
result = asyncio.run(tool.call(mock_ctx, pattern="SPECIAL_PATTERN", path=tmpdir, max_results=50))
result = asyncio.run(
tool.call(
mock_ctx, pattern="SPECIAL_PATTERN", path=tmpdir, max_results=50
)
)
elapsed = time.time() - start_time
# Should find matches quickly
@@ -177,7 +185,11 @@ class TestBatchToolPerformance:
# Process batch
start_time = time.time()
result = asyncio.run(batch_tool.call(mock_ctx, description="Large batch test", invocations=invocations))
result = asyncio.run(
batch_tool.call(
mock_ctx, description="Large batch test", invocations=invocations
)
)
elapsed = time.time() - start_time
# Should process efficiently
@@ -193,7 +205,9 @@ class TestBatchToolPerformance:
class TestSwarmPerformance:
"""Performance tests for swarm tool."""
@pytest.mark.skip(reason="Test uses deprecated dispatch_to_model API - swarm now uses hanzo-agents SDK")
@pytest.mark.skip(
reason="Test uses deprecated dispatch_to_model API - swarm now uses hanzo-agents SDK"
)
def test_parallel_agent_execution(self):
"""Test parallel execution of swarm agents."""
execution_times = []
@@ -226,7 +240,9 @@ class TestSwarmPerformance:
# Execute swarm
start_time = time.time()
asyncio.run(tool.call(mock_ctx, query="Parallel processing test", agents=agents))
asyncio.run(
tool.call(mock_ctx, query="Parallel processing test", agents=agents)
)
elapsed = time.time() - start_time
# Should execute workers in parallel
@@ -329,7 +345,9 @@ class TestMemoryStressTest:
assert len(operations) > 30 # At least the creates
assert elapsed < 5.0 # Should complete reasonably fast
print(f"Completed {len(operations)} operations under load in {elapsed:.2f} seconds")
print(
f"Completed {len(operations)} operations under load in {elapsed:.2f} seconds"
)
class TestConcurrentFileOperations:
@@ -388,7 +406,9 @@ class TestConcurrentFileOperations:
assert len(errors) == 0
assert elapsed < 2.0 # Should be fast even with 20 operations
print(f"Completed {len(results)} concurrent file operations in {elapsed:.2f} seconds")
print(
f"Completed {len(results)} concurrent file operations in {elapsed:.2f} seconds"
)
if __name__ == "__main__":

Some files were not shown because too many files have changed in this diff Show More