mirror of
https://github.com/hanzoai/python-sdk.git
synced 2026-10-23 20:23:00 +00:00
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:
@@ -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
|
||||
|
||||
@@ -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)}"
|
||||
|
||||
@@ -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}")
|
||||
|
||||
|
||||
@@ -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)}"
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user