fix(hanzo-mcp): convert blocking subprocess.run to async

- git_search.py: All 6 subprocess.run calls converted to asyncio.create_subprocess_exec
- search_tool.py: All 6 subprocess.run + 3 blocking file reads converted to async
- npx.py: Converted to asyncio.create_subprocess_exec with proper timeout
- uvx.py: Both subprocess calls converted to async with timeout handling
- open.py: Platform openers converted to async fire-and-forget pattern

All tools now use non-blocking async I/O for better concurrency.
Tests: 31/31 passing.
This commit is contained in:
2025-12-26 05:47:19 -08:00
parent 30d59c9793
commit dd0f3a9b5c
6 changed files with 336 additions and 142 deletions
@@ -2,7 +2,7 @@
import os
import re
import subprocess
import asyncio
from typing import Unpack, Annotated, TypedDict, final, override
from pydantic import Field
@@ -295,19 +295,27 @@ Examples:
if file_pattern:
cmd.extend(["--", file_pattern])
result = subprocess.run(cmd, cwd=repo_path, capture_output=True, text=True)
process = await asyncio.create_subprocess_exec(
*cmd,
cwd=repo_path,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
)
stdout, stderr = await process.communicate()
stdout_text = stdout.decode() if stdout else ""
stderr_text = stderr.decode() if stderr else ""
if result.returncode == 0:
lines = result.stdout.strip().split("\n")
if process.returncode == 0:
lines = stdout_text.strip().split("\n")
if lines and lines[0]:
await tool_ctx.info(f"Found {len(lines)} matches")
return self._format_grep_results(lines, pattern)
else:
return f"No matches found for pattern: {pattern}"
elif result.returncode == 1:
elif process.returncode == 1:
return f"No matches found for pattern: {pattern}"
else:
raise subprocess.CalledProcessError(result.returncode, cmd, result.stdout, result.stderr)
raise RuntimeError(f"Git command failed with code {process.returncode}: {stderr_text}")
async def _search_commits(
self,
@@ -345,17 +353,25 @@ Examples:
if file_pattern:
cmd.extend(["--", file_pattern])
result = subprocess.run(cmd, cwd=repo_path, capture_output=True, text=True)
process = await asyncio.create_subprocess_exec(
*cmd,
cwd=repo_path,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
)
stdout, stderr = await process.communicate()
stdout_text = stdout.decode() if stdout else ""
stderr_text = stderr.decode() if stderr else ""
if result.returncode == 0:
lines = result.stdout.strip().split("\n")
if process.returncode == 0:
lines = stdout_text.strip().split("\n")
if lines and lines[0]:
await tool_ctx.info(f"Found {len(lines)} commits")
return f"Found {len(lines)} commits matching '{pattern}':\n\n" + result.stdout
return f"Found {len(lines)} commits matching '{pattern}':\n\n" + stdout_text
else:
return f"No commits found matching: {pattern}"
else:
raise subprocess.CalledProcessError(result.returncode, cmd, result.stdout, result.stderr)
raise RuntimeError(f"Git command failed with code {process.returncode}: {stderr_text}")
async def _search_diff(
self,
@@ -403,11 +419,18 @@ Examples:
if file_pattern:
cmd.extend(["--", file_pattern])
result = subprocess.run(cmd, cwd=repo_path, capture_output=True, text=True)
process = await asyncio.create_subprocess_exec(
*cmd,
cwd=repo_path,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
)
stdout, _stderr = await process.communicate()
stdout_text = stdout.decode() if stdout else ""
if result.returncode == 0 and result.stdout.strip():
if process.returncode == 0 and stdout_text.strip():
# Parse and highlight matching lines
output = self._highlight_diff_matches(result.stdout, pattern, case_sensitive)
output = self._highlight_diff_matches(stdout_text, pattern, case_sensitive)
matches = output.count("commit ")
await tool_ctx.info(f"Found {matches} commits with changes")
return f"Found {matches} commits with changes matching '{pattern}':\n\n{output}"
@@ -450,12 +473,19 @@ Examples:
if file_pattern:
cmd.extend(["--", file_pattern])
result = subprocess.run(cmd, cwd=repo_path, capture_output=True, text=True)
process = await asyncio.create_subprocess_exec(
*cmd,
cwd=repo_path,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
)
stdout, _stderr = await process.communicate()
stdout_text = stdout.decode() if stdout else ""
if result.returncode == 0 and result.stdout.strip():
lines = result.stdout.strip().split("\n")
if process.returncode == 0 and stdout_text.strip():
lines = stdout_text.strip().split("\n")
await tool_ctx.info(f"Found {len(lines)} commits")
return f"Found {len(lines)} commits:\n\n" + result.stdout
return f"Found {len(lines)} commits:\n\n" + stdout_text
else:
return "No commits found matching criteria"
@@ -473,23 +503,37 @@ Examples:
# First, find files matching the pattern
cmd = ["git", "ls-files", file_pattern]
result = subprocess.run(cmd, cwd=repo_path, capture_output=True, text=True)
process = await asyncio.create_subprocess_exec(
*cmd,
cwd=repo_path,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
)
stdout, _stderr = await process.communicate()
stdout_text = stdout.decode() if stdout else ""
if result.returncode != 0 or not result.stdout.strip():
if process.returncode != 0 or not stdout_text.strip():
return f"No files found matching: {file_pattern}"
files = result.stdout.strip().split("\n")
files = stdout_text.strip().split("\n")
all_matches = []
for file_path in files[:10]: # Limit to 10 files
# Get blame for the file
cmd = ["git", "blame", "-l", file_path]
result = subprocess.run(cmd, cwd=repo_path, capture_output=True, text=True)
blame_cmd = ["git", "blame", "-l", file_path]
blame_process = await asyncio.create_subprocess_exec(
*blame_cmd,
cwd=repo_path,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
)
blame_stdout, _blame_stderr = await blame_process.communicate()
blame_text = blame_stdout.decode() if blame_stdout else ""
if result.returncode == 0:
if blame_process.returncode == 0:
# Search for pattern in blame output
flags = 0 if case_sensitive else re.IGNORECASE
for line in result.stdout.split("\n"):
for line in blame_text.split("\n"):
if re.search(pattern, line, flags):
all_matches.append(f"{file_path}: {line}")
@@ -6,7 +6,8 @@ No configuration needed. Just search.
import json
import time
import subprocess
import asyncio
import aiofiles
from typing import Any, Dict, List, Optional
from pathlib import Path
from dataclasses import dataclass
@@ -96,25 +97,50 @@ class SearchTool(BaseTool):
def __init__(self):
super().__init__()
self.ripgrep_available = self._check_cmd("rg")
self.git_available = self._check_cmd("git")
self._ripgrep_available: Optional[bool] = None
self._git_available: Optional[bool] = None
def _check_cmd(self, cmd: str) -> bool:
async def _check_cmd_async(self, cmd: str) -> bool:
"""Check if a command is available (async)."""
try:
subprocess.run([cmd, "--version"], capture_output=True, check=True)
return True
proc = await asyncio.create_subprocess_exec(
cmd, "--version",
stdout=asyncio.subprocess.DEVNULL,
stderr=asyncio.subprocess.DEVNULL,
)
await proc.wait()
return proc.returncode == 0
except Exception:
return False
def _is_git_repo(self, path: str) -> bool:
async def _ensure_ripgrep_checked(self) -> bool:
"""Lazily check ripgrep availability."""
if self._ripgrep_available is None:
self._ripgrep_available = await self._check_cmd_async("rg")
return self._ripgrep_available
async def _ensure_git_checked(self) -> bool:
"""Lazily check git availability."""
if self._git_available is None:
self._git_available = await self._check_cmd_async("git")
return self._git_available
async def _is_git_repo(self, path: str) -> bool:
"""Check if path is in a git repo (async)."""
try:
result = subprocess.run(
["git", "rev-parse", "--git-dir"],
capture_output=True,
proc = await asyncio.create_subprocess_exec(
"git", "rev-parse", "--git-dir",
cwd=path or ".",
timeout=2,
stdout=asyncio.subprocess.DEVNULL,
stderr=asyncio.subprocess.DEVNULL,
)
return result.returncode == 0
try:
await asyncio.wait_for(proc.wait(), timeout=2)
except asyncio.TimeoutError:
proc.kill()
await proc.wait()
return False
return proc.returncode == 0
except Exception:
return False
@@ -131,10 +157,14 @@ class SearchTool(BaseTool):
**kwargs,
) -> MCPResourceDocument:
"""Search everything in parallel. No configuration needed."""
import asyncio
max_per_engine = max(5, max_results // 5)
is_git_repo = self._is_git_repo(path)
# Check prerequisites in parallel
ripgrep_available, git_available, is_git_repo = await asyncio.gather(
self._ensure_ripgrep_checked(),
self._ensure_git_checked(),
self._is_git_repo(path),
)
# Stats tracking
stats = {"query": pattern, "path": path, "engines": [], "time_ms": {}}
@@ -154,17 +184,17 @@ class SearchTool(BaseTool):
# Always run ALL available engines in parallel
tasks = [
timed("grep", self._grep_search(pattern, path, include, exclude, max_per_engine, context_lines)),
timed("grep", self._grep_search(pattern, path, include, exclude, max_per_engine, context_lines, ripgrep_available)),
timed("file", self._file_search(pattern, path, include, exclude, max_per_engine)),
]
if TREESITTER_AVAILABLE:
tasks.append(timed("ast", self._ast_search(pattern, path, include, exclude, max_per_engine, context_lines)))
tasks.append(timed("ast", self._ast_search(pattern, path, include, exclude, max_per_engine, context_lines, ripgrep_available)))
if LSP_AVAILABLE:
tasks.append(timed("lsp", self._lsp_search(pattern, path, include, max_per_engine)))
tasks.append(timed("lsp", self._lsp_search(pattern, path, include, max_per_engine, ripgrep_available)))
if is_git_repo and self.git_available:
if is_git_repo and git_available:
tasks.append(timed("git", self._git_search(pattern, path, max_per_engine)))
# Execute all in parallel
@@ -244,9 +274,10 @@ class SearchTool(BaseTool):
exclude: Optional[str],
max_results: int,
context_lines: int,
ripgrep_available: bool,
) -> List[SearchResult]:
"""Text search using ripgrep."""
if not self.ripgrep_available:
if not ripgrep_available:
return await self._python_grep(pattern, path, include, exclude, max_results, context_lines)
cmd = ["rg", "--json", "--max-count", str(max_results)]
@@ -260,8 +291,19 @@ class SearchTool(BaseTool):
results = []
try:
proc = subprocess.run(cmd, capture_output=True, text=True, timeout=30)
for line in proc.stdout.splitlines():
proc = await asyncio.create_subprocess_exec(
*cmd,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
)
try:
stdout, _ = await asyncio.wait_for(proc.communicate(), timeout=30)
except asyncio.TimeoutError:
proc.kill()
await proc.wait()
return results
for line in stdout.decode().splitlines():
try:
data = json.loads(line)
if data.get("type") == "match":
@@ -301,6 +343,7 @@ class SearchTool(BaseTool):
exclude: Optional[str],
max_results: int,
context_lines: int,
ripgrep_available: bool,
) -> List[SearchResult]:
"""AST search using tree-sitter via grep-ast."""
try:
@@ -313,7 +356,7 @@ class SearchTool(BaseTool):
# Use ripgrep to find candidate files first (fast)
files = []
if self.ripgrep_available:
if ripgrep_available:
cmd = ["rg", "--files-with-matches", "-l", "--max-count", "1"]
if include:
cmd.extend(["--glob", include])
@@ -325,16 +368,25 @@ class SearchTool(BaseTool):
cmd.extend([pattern, str(search_path)])
try:
result = subprocess.run(cmd, capture_output=True, text=True, timeout=5)
if result.returncode == 0:
files = [Path(f) for f in result.stdout.strip().split("\n") if f]
proc = await asyncio.create_subprocess_exec(
*cmd,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
)
try:
stdout, _ = await asyncio.wait_for(proc.communicate(), timeout=5)
if proc.returncode == 0:
files = [Path(f) for f in stdout.decode().strip().split("\n") if f]
except asyncio.TimeoutError:
proc.kill()
await proc.wait()
except Exception:
pass
for file_path in files[:max_results]:
try:
with open(file_path, "r", encoding="utf-8") as f:
code = f.read()
async with aiofiles.open(file_path, "r", encoding="utf-8") as f:
code = await f.read()
tc = TreeContext(str(file_path), code, color=False, verbose=False, line_number=True)
matches = tc.grep(pattern, ignore_case=False)
lines = code.split("\n")
@@ -405,6 +457,7 @@ class SearchTool(BaseTool):
path: str,
include: Optional[str],
max_results: int,
ripgrep_available: bool,
) -> List[SearchResult]:
"""LSP reference search."""
if not LSP_AVAILABLE:
@@ -415,22 +468,36 @@ class SearchTool(BaseTool):
root_path = Path(path).resolve()
# Find files containing pattern
cmd = ["rg", "--files-with-matches", "-l", pattern]
if include:
cmd.extend(["--glob", include])
cmd.append(str(root_path))
matching_files = []
if ripgrep_available:
cmd = ["rg", "--files-with-matches", "-l", pattern]
if include:
cmd.extend(["--glob", include])
cmd.append(str(root_path))
try:
result = subprocess.run(cmd, capture_output=True, text=True, timeout=10)
matching_files = [f.strip() for f in result.stdout.strip().split("\n") if f.strip()]
except Exception:
return []
try:
proc = await asyncio.create_subprocess_exec(
*cmd,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
)
try:
stdout, _ = await asyncio.wait_for(proc.communicate(), timeout=10)
matching_files = [f.strip() for f in stdout.decode().strip().split("\n") if f.strip()]
except asyncio.TimeoutError:
proc.kill()
await proc.wait()
return []
except Exception:
return []
lsp = LSPTool()
for file_path in matching_files[:3]:
try:
with open(file_path, "r", encoding="utf-8", errors="ignore") as f:
for line_num, line in enumerate(f, 1):
async with aiofiles.open(file_path, "r", encoding="utf-8", errors="ignore") as f:
line_num = 0
async for line in f:
line_num += 1
col = line.find(pattern)
if col >= 0:
lsp_result = await lsp.run(
@@ -466,10 +533,21 @@ class SearchTool(BaseTool):
try:
# git log -S (pickaxe)
cmd = ["git", "log", f"-S{pattern}", "--oneline", f"-n{max_results}", "--pretty=format:%h|%s|%an|%ar"]
result = subprocess.run(cmd, capture_output=True, text=True, cwd=path or ".", timeout=10)
proc = await asyncio.create_subprocess_exec(
*cmd,
cwd=path or ".",
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
)
try:
stdout, _ = await asyncio.wait_for(proc.communicate(), timeout=10)
except asyncio.TimeoutError:
proc.kill()
await proc.wait()
return results
if result.returncode == 0:
for line in result.stdout.strip().split("\n"):
if proc.returncode == 0:
for line in stdout.decode().strip().split("\n"):
parts = line.split("|", 3)
if len(parts) >= 4:
commit, subject, author, date = parts
@@ -490,10 +568,21 @@ class SearchTool(BaseTool):
# git grep
if len(results) < max_results:
grep_cmd = ["git", "grep", "-n", "-I", f"--max-count={max_results - len(results)}", pattern]
grep_result = subprocess.run(grep_cmd, capture_output=True, text=True, cwd=path or ".", timeout=10)
grep_proc = await asyncio.create_subprocess_exec(
*grep_cmd,
cwd=path or ".",
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
)
try:
grep_stdout, _ = await asyncio.wait_for(grep_proc.communicate(), timeout=10)
except asyncio.TimeoutError:
grep_proc.kill()
await grep_proc.wait()
return results
if grep_result.returncode == 0:
for line in grep_result.stdout.strip().split("\n"):
if grep_proc.returncode == 0:
for line in grep_stdout.decode().strip().split("\n"):
parts = line.split(":", 2)
if len(parts) >= 3:
fp, ln, content = parts
@@ -537,8 +626,8 @@ class SearchTool(BaseTool):
break
if file_path.is_file():
try:
with open(file_path, "r", encoding="utf-8") as f:
lines = f.readlines()
async with aiofiles.open(file_path, "r", encoding="utf-8") as f:
lines = await f.readlines()
for i, line in enumerate(lines):
if count >= max_results:
break
+29 -18
View File
@@ -1,7 +1,7 @@
"""Run Node.js packages with npx."""
import shutil
import subprocess
import asyncio
from typing import Unpack, Optional, Annotated, TypedDict, final, override
from pydantic import Field
@@ -161,26 +161,37 @@ Or download from: https://nodejs.org/"""
await tool_ctx.info(f"Running: {' '.join(cmd)}")
try:
# Execute command
result = subprocess.run(cmd, capture_output=True, text=True, timeout=timeout, check=True)
# Execute command asynchronously
process = await asyncio.create_subprocess_exec(
*cmd,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
)
try:
stdout, stderr = await asyncio.wait_for(process.communicate(), timeout=timeout)
except asyncio.TimeoutError:
process.kill()
await process.wait()
return f"Error: Command timed out after {timeout} seconds. Use npx_background for long-running processes."
output = []
if result.stdout:
output.append(result.stdout)
if result.stderr:
output.append(f"\nSTDERR:\n{result.stderr}")
stdout_text = stdout.decode() if stdout else ""
stderr_text = stderr.decode() if stderr else ""
return "\n".join(output) if output else "Command completed successfully with no output."
if process.returncode == 0:
output = []
if stdout_text:
output.append(stdout_text)
if stderr_text:
output.append(f"\nSTDERR:\n{stderr_text}")
return "\n".join(output) if output else "Command completed successfully with no output."
else:
error_msg = [f"Error: Command failed with exit code {process.returncode}"]
if stdout_text:
error_msg.append(f"\nSTDOUT:\n{stdout_text}")
if stderr_text:
error_msg.append(f"\nSTDERR:\n{stderr_text}")
return "\n".join(error_msg)
except subprocess.TimeoutExpired:
return f"Error: Command timed out after {timeout} seconds. Use npx_background for long-running processes."
except subprocess.CalledProcessError as e:
error_msg = [f"Error: Command failed with exit code {e.returncode}"]
if e.stdout:
error_msg.append(f"\nSTDOUT:\n{e.stdout}")
if e.stderr:
error_msg.append(f"\nSTDERR:\n{e.stderr}")
return "\n".join(error_msg)
except Exception as e:
await tool_ctx.error(f"Unexpected error: {str(e)}")
return f"Error running npx: {str(e)}"
+39 -17
View File
@@ -1,7 +1,8 @@
"""Open files or URLs in the default application."""
import asyncio
import platform
import subprocess
import shutil
import webbrowser
from typing import override
from pathlib import Path
@@ -43,6 +44,27 @@ open https://example.com
open ./document.pdf
open /path/to/image.png"""
async def _run_opener(self, cmd: list[str], file_path: str) -> bool:
"""Run opener command asynchronously.
Returns True if successful, False otherwise.
"""
try:
proc = await asyncio.create_subprocess_exec(
*cmd,
stdout=asyncio.subprocess.DEVNULL,
stderr=asyncio.subprocess.DEVNULL,
)
# Don't wait long - these are fire-and-forget
try:
await asyncio.wait_for(proc.wait(), timeout=5)
return proc.returncode == 0
except asyncio.TimeoutError:
# Process is running (which is fine for opener commands)
return True
except Exception:
return False
@override
async def run(self, ctx: MCPContext, path: str) -> str:
"""Open a file or URL in the default application.
@@ -79,33 +101,33 @@ open /path/to/image.png"""
try:
if system == "darwin": # macOS
subprocess.run(["open", str(file_path)], check=True)
success = await self._run_opener(["open", str(file_path)], str(file_path))
if not success:
raise RuntimeError("Failed to open file with 'open' command")
elif system == "linux":
# Try xdg-open first (most common)
try:
subprocess.run(["xdg-open", str(file_path)], check=True)
except (subprocess.CalledProcessError, FileNotFoundError):
# Fallback to other common openers
for opener in ["gnome-open", "kde-open", "exo-open"]:
try:
subprocess.run([opener, str(file_path)], check=True)
success = False
openers = ["xdg-open", "gnome-open", "kde-open", "exo-open"]
for opener in openers:
if shutil.which(opener):
success = await self._run_opener([opener, str(file_path)], str(file_path))
if success:
break
except (subprocess.CalledProcessError, FileNotFoundError):
continue
else:
raise RuntimeError("No suitable file opener found on Linux")
if not success:
raise RuntimeError("No suitable file opener found on Linux")
elif system == "windows":
# Use os.startfile on Windows
# Use os.startfile on Windows (this is sync but fast)
import os
os.startfile(str(file_path))
else:
raise RuntimeError(f"Unsupported platform: {system}")
return f"Opened file: {file_path}"
except subprocess.CalledProcessError as e:
raise RuntimeError(f"Failed to open file: {e}")
except RuntimeError:
raise
except Exception as e:
raise RuntimeError(f"Error opening file: {e}")
+51 -29
View File
@@ -1,7 +1,7 @@
"""Run Python packages with uvx."""
import shutil
import subprocess
import asyncio
from typing import Unpack, Optional, Annotated, TypedDict, final, override
from pydantic import Field
@@ -78,7 +78,7 @@ class UvxTool(BaseTool):
"""Get the tool description."""
return """Run Python packages using uvx (Python package runner).
uvx allows running Python applications in isolated environments without
uvx allows running Python applications in isolated environments without
installing them globally. It automatically manages dependencies and Python versions.
Common packages:
@@ -136,10 +136,23 @@ For long-running servers, use uvx_background instead.
install_cmd = "curl -LsSf https://astral.sh/uv/install.sh | sh"
try:
# Run installation
install_result = subprocess.run(install_cmd, shell=True, capture_output=True, text=True, timeout=60)
# Run installation asynchronously
install_process = await asyncio.create_subprocess_shell(
install_cmd,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
)
try:
install_stdout, install_stderr = await asyncio.wait_for(
install_process.communicate(), timeout=60
)
except asyncio.TimeoutError:
install_process.kill()
await install_process.wait()
return """Error: Installation timed out. Install uvx manually with:
curl -LsSf https://astral.sh/uv/install.sh | sh"""
if install_result.returncode == 0:
if install_process.returncode == 0:
await tool_ctx.info("uvx installed successfully!")
# Add to PATH for current session
@@ -150,29 +163,27 @@ For long-running servers, use uvx_background instead.
# Check again
if not shutil.which("uvx"):
return """Error: uvx installed but not found in PATH.
return """Error: uvx installed but not found in PATH.
Please add ~/.cargo/bin to your PATH and restart your shell.
Add to ~/.zshrc or ~/.bashrc:
export PATH="$HOME/.cargo/bin:$PATH"
"""
else:
stderr_text = install_stderr.decode() if install_stderr else ""
return f"""Error: Failed to install uvx automatically.
Install manually with:
curl -LsSf https://astral.sh/uv/install.sh | sh
Or on macOS:
brew install uv
Error details: {install_result.stderr}"""
Error details: {stderr_text}"""
except subprocess.TimeoutExpired:
return """Error: Installation timed out. Install uvx manually with:
curl -LsSf https://astral.sh/uv/install.sh | sh"""
except Exception as e:
return f"""Error: Failed to auto-install uvx: {str(e)}
Install manually with:
curl -LsSf https://astral.sh/uv/install.sh | sh"""
@@ -194,26 +205,37 @@ curl -LsSf https://astral.sh/uv/install.sh | sh"""
await tool_ctx.info(f"Running: {' '.join(cmd)}")
try:
# Execute command
result = subprocess.run(cmd, capture_output=True, text=True, timeout=timeout, check=True)
# Execute command asynchronously
process = await asyncio.create_subprocess_exec(
*cmd,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
)
try:
stdout, stderr = await asyncio.wait_for(process.communicate(), timeout=timeout)
except asyncio.TimeoutError:
process.kill()
await process.wait()
return f"Error: Command timed out after {timeout} seconds. Use uvx_background for long-running processes."
output = []
if result.stdout:
output.append(result.stdout)
if result.stderr:
output.append(f"\nSTDERR:\n{result.stderr}")
stdout_text = stdout.decode() if stdout else ""
stderr_text = stderr.decode() if stderr else ""
return "\n".join(output) if output else "Command completed successfully with no output."
if process.returncode == 0:
output = []
if stdout_text:
output.append(stdout_text)
if stderr_text:
output.append(f"\nSTDERR:\n{stderr_text}")
return "\n".join(output) if output else "Command completed successfully with no output."
else:
error_msg = [f"Error: Command failed with exit code {process.returncode}"]
if stdout_text:
error_msg.append(f"\nSTDOUT:\n{stdout_text}")
if stderr_text:
error_msg.append(f"\nSTDERR:\n{stderr_text}")
return "\n".join(error_msg)
except subprocess.TimeoutExpired:
return f"Error: Command timed out after {timeout} seconds. Use uvx_background for long-running processes."
except subprocess.CalledProcessError as e:
error_msg = [f"Error: Command failed with exit code {e.returncode}"]
if e.stdout:
error_msg.append(f"\nSTDOUT:\n{e.stdout}")
if e.stderr:
error_msg.append(f"\nSTDERR:\n{e.stderr}")
return "\n".join(error_msg)
except Exception as e:
await tool_ctx.error(f"Unexpected error: {str(e)}")
return f"Error running uvx: {str(e)}"
+12 -6
View File
@@ -336,9 +336,15 @@ class TestMemoryIntegration:
try:
from hanzo_mcp.tools.memory import memory_tools
# If we get here, hanzo_memory is installed
# Check that module has expected lazy-loading infrastructure
assert hasattr(memory_tools, "MEMORY_AVAILABLE")
assert memory_tools.MEMORY_AVAILABLE == True
assert hasattr(memory_tools, "_check_memory_available")
# Call the check function to initialize the lazy state
is_available = memory_tools._check_memory_available()
# After calling, MEMORY_AVAILABLE should be set (True or False, not None)
assert memory_tools.MEMORY_AVAILABLE is not None
assert memory_tools.MEMORY_AVAILABLE == is_available
# Test that we can access the base class
assert hasattr(memory_tools, "MemoryToolBase")
@@ -478,7 +484,7 @@ class TestAutoBackgrounding:
# Test that executor is created properly
assert executor is not None
assert executor.timeout == 0.1
assert executor.default_timeout == 0.1
assert executor.process_manager == process_manager
# Test has the expected method
@@ -496,15 +502,15 @@ class TestAutoBackgrounding:
# Test with zero timeout
executor_zero = AutoBackgroundExecutor(process_manager, timeout=0)
assert executor_zero.timeout == 0
assert executor_zero.default_timeout == 0
# Test with very large timeout
executor_large = AutoBackgroundExecutor(process_manager, timeout=float("inf"))
assert executor_large.timeout == float("inf")
assert executor_large.default_timeout == float("inf")
# Test with negative timeout (should handle gracefully)
executor_negative = AutoBackgroundExecutor(process_manager, timeout=-1)
assert executor_negative.timeout == -1 # Should accept but handle internally
assert executor_negative.default_timeout == -1 # Should accept but handle internally
def test_process_manager_singleton(self):
"""Test that ProcessManager is a proper singleton.