hanzo-mcp 0.5.1__py3-none-any.whl → 0.6.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.

Potentially problematic release.


This version of hanzo-mcp might be problematic. Click here for more details.

Files changed (118) hide show
  1. hanzo_mcp/__init__.py +1 -1
  2. hanzo_mcp/cli.py +32 -0
  3. hanzo_mcp/dev_server.py +246 -0
  4. hanzo_mcp/prompts/__init__.py +1 -1
  5. hanzo_mcp/prompts/project_system.py +43 -7
  6. hanzo_mcp/server.py +5 -1
  7. hanzo_mcp/tools/__init__.py +168 -6
  8. hanzo_mcp/tools/agent/__init__.py +1 -1
  9. hanzo_mcp/tools/agent/agent.py +401 -0
  10. hanzo_mcp/tools/agent/agent_tool.py +3 -4
  11. hanzo_mcp/tools/common/__init__.py +1 -1
  12. hanzo_mcp/tools/common/base.py +9 -4
  13. hanzo_mcp/tools/common/batch_tool.py +3 -5
  14. hanzo_mcp/tools/common/config_tool.py +1 -1
  15. hanzo_mcp/tools/common/context.py +1 -1
  16. hanzo_mcp/tools/common/palette.py +344 -0
  17. hanzo_mcp/tools/common/palette_loader.py +108 -0
  18. hanzo_mcp/tools/common/stats.py +261 -0
  19. hanzo_mcp/tools/common/thinking_tool.py +3 -5
  20. hanzo_mcp/tools/common/tool_disable.py +144 -0
  21. hanzo_mcp/tools/common/tool_enable.py +182 -0
  22. hanzo_mcp/tools/common/tool_list.py +260 -0
  23. hanzo_mcp/tools/config/__init__.py +10 -0
  24. hanzo_mcp/tools/config/config_tool.py +212 -0
  25. hanzo_mcp/tools/config/index_config.py +176 -0
  26. hanzo_mcp/tools/config/palette_tool.py +166 -0
  27. hanzo_mcp/tools/database/__init__.py +71 -0
  28. hanzo_mcp/tools/database/database_manager.py +246 -0
  29. hanzo_mcp/tools/database/graph.py +482 -0
  30. hanzo_mcp/tools/database/graph_add.py +257 -0
  31. hanzo_mcp/tools/database/graph_query.py +536 -0
  32. hanzo_mcp/tools/database/graph_remove.py +267 -0
  33. hanzo_mcp/tools/database/graph_search.py +348 -0
  34. hanzo_mcp/tools/database/graph_stats.py +345 -0
  35. hanzo_mcp/tools/database/sql.py +411 -0
  36. hanzo_mcp/tools/database/sql_query.py +229 -0
  37. hanzo_mcp/tools/database/sql_search.py +296 -0
  38. hanzo_mcp/tools/database/sql_stats.py +254 -0
  39. hanzo_mcp/tools/editor/__init__.py +11 -0
  40. hanzo_mcp/tools/editor/neovim_command.py +272 -0
  41. hanzo_mcp/tools/editor/neovim_edit.py +290 -0
  42. hanzo_mcp/tools/editor/neovim_session.py +356 -0
  43. hanzo_mcp/tools/filesystem/__init__.py +52 -13
  44. hanzo_mcp/tools/filesystem/base.py +1 -1
  45. hanzo_mcp/tools/filesystem/batch_search.py +812 -0
  46. hanzo_mcp/tools/filesystem/content_replace.py +3 -5
  47. hanzo_mcp/tools/filesystem/diff.py +193 -0
  48. hanzo_mcp/tools/filesystem/directory_tree.py +3 -5
  49. hanzo_mcp/tools/filesystem/edit.py +3 -5
  50. hanzo_mcp/tools/filesystem/find.py +443 -0
  51. hanzo_mcp/tools/filesystem/find_files.py +348 -0
  52. hanzo_mcp/tools/filesystem/git_search.py +505 -0
  53. hanzo_mcp/tools/filesystem/grep.py +2 -2
  54. hanzo_mcp/tools/filesystem/multi_edit.py +3 -5
  55. hanzo_mcp/tools/filesystem/read.py +17 -5
  56. hanzo_mcp/tools/filesystem/{grep_ast_tool.py → symbols.py} +17 -27
  57. hanzo_mcp/tools/filesystem/symbols_unified.py +376 -0
  58. hanzo_mcp/tools/filesystem/tree.py +268 -0
  59. hanzo_mcp/tools/filesystem/unified_search.py +465 -443
  60. hanzo_mcp/tools/filesystem/unix_aliases.py +99 -0
  61. hanzo_mcp/tools/filesystem/watch.py +174 -0
  62. hanzo_mcp/tools/filesystem/write.py +3 -5
  63. hanzo_mcp/tools/jupyter/__init__.py +9 -12
  64. hanzo_mcp/tools/jupyter/base.py +1 -1
  65. hanzo_mcp/tools/jupyter/jupyter.py +326 -0
  66. hanzo_mcp/tools/jupyter/notebook_edit.py +3 -4
  67. hanzo_mcp/tools/jupyter/notebook_read.py +3 -5
  68. hanzo_mcp/tools/llm/__init__.py +31 -0
  69. hanzo_mcp/tools/llm/consensus_tool.py +351 -0
  70. hanzo_mcp/tools/llm/llm_manage.py +413 -0
  71. hanzo_mcp/tools/llm/llm_tool.py +346 -0
  72. hanzo_mcp/tools/llm/llm_unified.py +851 -0
  73. hanzo_mcp/tools/llm/provider_tools.py +412 -0
  74. hanzo_mcp/tools/mcp/__init__.py +15 -0
  75. hanzo_mcp/tools/mcp/mcp_add.py +263 -0
  76. hanzo_mcp/tools/mcp/mcp_remove.py +127 -0
  77. hanzo_mcp/tools/mcp/mcp_stats.py +165 -0
  78. hanzo_mcp/tools/mcp/mcp_unified.py +503 -0
  79. hanzo_mcp/tools/shell/__init__.py +21 -23
  80. hanzo_mcp/tools/shell/base.py +1 -1
  81. hanzo_mcp/tools/shell/base_process.py +303 -0
  82. hanzo_mcp/tools/shell/bash_unified.py +134 -0
  83. hanzo_mcp/tools/shell/logs.py +265 -0
  84. hanzo_mcp/tools/shell/npx.py +194 -0
  85. hanzo_mcp/tools/shell/npx_background.py +254 -0
  86. hanzo_mcp/tools/shell/npx_unified.py +101 -0
  87. hanzo_mcp/tools/shell/open.py +107 -0
  88. hanzo_mcp/tools/shell/pkill.py +262 -0
  89. hanzo_mcp/tools/shell/process_unified.py +131 -0
  90. hanzo_mcp/tools/shell/processes.py +279 -0
  91. hanzo_mcp/tools/shell/run_background.py +326 -0
  92. hanzo_mcp/tools/shell/run_command.py +3 -4
  93. hanzo_mcp/tools/shell/run_command_windows.py +3 -4
  94. hanzo_mcp/tools/shell/uvx.py +187 -0
  95. hanzo_mcp/tools/shell/uvx_background.py +249 -0
  96. hanzo_mcp/tools/shell/uvx_unified.py +101 -0
  97. hanzo_mcp/tools/todo/__init__.py +1 -1
  98. hanzo_mcp/tools/todo/base.py +1 -1
  99. hanzo_mcp/tools/todo/todo.py +265 -0
  100. hanzo_mcp/tools/todo/todo_read.py +3 -5
  101. hanzo_mcp/tools/todo/todo_write.py +3 -5
  102. hanzo_mcp/tools/vector/__init__.py +6 -1
  103. hanzo_mcp/tools/vector/git_ingester.py +3 -0
  104. hanzo_mcp/tools/vector/index_tool.py +358 -0
  105. hanzo_mcp/tools/vector/infinity_store.py +98 -0
  106. hanzo_mcp/tools/vector/project_manager.py +27 -5
  107. hanzo_mcp/tools/vector/vector.py +311 -0
  108. hanzo_mcp/tools/vector/vector_index.py +1 -1
  109. hanzo_mcp/tools/vector/vector_search.py +12 -7
  110. hanzo_mcp-0.6.1.dist-info/METADATA +336 -0
  111. hanzo_mcp-0.6.1.dist-info/RECORD +134 -0
  112. hanzo_mcp-0.6.1.dist-info/entry_points.txt +3 -0
  113. hanzo_mcp-0.5.1.dist-info/METADATA +0 -276
  114. hanzo_mcp-0.5.1.dist-info/RECORD +0 -68
  115. hanzo_mcp-0.5.1.dist-info/entry_points.txt +0 -2
  116. {hanzo_mcp-0.5.1.dist-info → hanzo_mcp-0.6.1.dist-info}/WHEEL +0 -0
  117. {hanzo_mcp-0.5.1.dist-info → hanzo_mcp-0.6.1.dist-info}/licenses/LICENSE +0 -0
  118. {hanzo_mcp-0.5.1.dist-info → hanzo_mcp-0.6.1.dist-info}/top_level.txt +0 -0
@@ -0,0 +1,303 @@
1
+ """Base classes for process execution tools."""
2
+
3
+ import asyncio
4
+ import subprocess
5
+ import tempfile
6
+ import uuid
7
+ from abc import abstractmethod
8
+ from pathlib import Path
9
+ from typing import Any, Dict, List, Optional, override
10
+
11
+ from mcp.server.fastmcp import Context as MCPContext
12
+
13
+ from hanzo_mcp.tools.common.base import BaseTool
14
+ from hanzo_mcp.tools.common.permissions import PermissionManager
15
+
16
+
17
+ class ProcessManager:
18
+ """Singleton manager for background processes."""
19
+
20
+ _instance = None
21
+ _processes: Dict[str, subprocess.Popen] = {}
22
+ _logs: Dict[str, List[str]] = {}
23
+ _log_dir = Path(tempfile.gettempdir()) / "hanzo_mcp_logs"
24
+
25
+ def __new__(cls):
26
+ if cls._instance is None:
27
+ cls._instance = super().__new__(cls)
28
+ cls._instance._log_dir.mkdir(exist_ok=True)
29
+ return cls._instance
30
+
31
+ def add_process(self, process_id: str, process: subprocess.Popen, log_file: Path) -> None:
32
+ """Add a process to track."""
33
+ self._processes[process_id] = process
34
+ self._logs[process_id] = str(log_file)
35
+
36
+ def get_process(self, process_id: str) -> Optional[subprocess.Popen]:
37
+ """Get a tracked process."""
38
+ return self._processes.get(process_id)
39
+
40
+ def remove_process(self, process_id: str) -> None:
41
+ """Remove a process from tracking."""
42
+ self._processes.pop(process_id, None)
43
+ self._logs.pop(process_id, None)
44
+
45
+ def list_processes(self) -> Dict[str, Dict[str, Any]]:
46
+ """List all tracked processes."""
47
+ result = {}
48
+ for pid, proc in list(self._processes.items()):
49
+ if proc.poll() is None:
50
+ result[pid] = {
51
+ "pid": proc.pid,
52
+ "running": True,
53
+ "log_file": self._logs.get(pid)
54
+ }
55
+ else:
56
+ result[pid] = {
57
+ "pid": proc.pid,
58
+ "running": False,
59
+ "return_code": proc.returncode,
60
+ "log_file": self._logs.get(pid)
61
+ }
62
+ # Clean up finished processes
63
+ self.remove_process(pid)
64
+ return result
65
+
66
+ def get_log_file(self, process_id: str) -> Optional[Path]:
67
+ """Get log file path for a process."""
68
+ log_path = self._logs.get(process_id)
69
+ return Path(log_path) if log_path else None
70
+
71
+ @property
72
+ def log_dir(self) -> Path:
73
+ """Get the log directory."""
74
+ return self._log_dir
75
+
76
+
77
+ class BaseProcessTool(BaseTool):
78
+ """Base class for all process execution tools."""
79
+
80
+ def __init__(self, permission_manager: Optional[PermissionManager] = None):
81
+ """Initialize the process tool.
82
+
83
+ Args:
84
+ permission_manager: Optional permission manager for access control
85
+ """
86
+ super().__init__()
87
+ self.permission_manager = permission_manager
88
+ self.process_manager = ProcessManager()
89
+
90
+ @abstractmethod
91
+ def get_command_args(self, command: str, **kwargs) -> List[str]:
92
+ """Get the command arguments for subprocess.
93
+
94
+ Args:
95
+ command: The command or script to run
96
+ **kwargs: Additional arguments specific to the tool
97
+
98
+ Returns:
99
+ List of command arguments for subprocess
100
+ """
101
+ pass
102
+
103
+ @abstractmethod
104
+ def get_tool_name(self) -> str:
105
+ """Get the name of the tool being used (e.g., 'bash', 'uvx', 'npx')."""
106
+ pass
107
+
108
+ async def execute_sync(
109
+ self,
110
+ command: str,
111
+ cwd: Optional[Path] = None,
112
+ env: Optional[Dict[str, str]] = None,
113
+ timeout: Optional[int] = None,
114
+ **kwargs
115
+ ) -> str:
116
+ """Execute a command synchronously and return output.
117
+
118
+ Args:
119
+ command: Command to execute
120
+ cwd: Working directory
121
+ env: Environment variables
122
+ timeout: Timeout in seconds
123
+ **kwargs: Additional tool-specific arguments
124
+
125
+ Returns:
126
+ Command output
127
+
128
+ Raises:
129
+ RuntimeError: If command fails
130
+ """
131
+ # Check permissions if manager is available
132
+ if self.permission_manager and cwd:
133
+ self.permission_manager.check_permission(cwd)
134
+
135
+ # Get command arguments
136
+ cmd_args = self.get_command_args(command, **kwargs)
137
+
138
+ # Prepare environment
139
+ process_env = os.environ.copy()
140
+ if env:
141
+ process_env.update(env)
142
+
143
+ try:
144
+ result = subprocess.run(
145
+ cmd_args,
146
+ cwd=cwd,
147
+ env=process_env,
148
+ capture_output=True,
149
+ text=True,
150
+ timeout=timeout,
151
+ check=True
152
+ )
153
+ return result.stdout
154
+ except subprocess.TimeoutExpired:
155
+ raise RuntimeError(f"{self.get_tool_name()} command timed out after {timeout} seconds")
156
+ except subprocess.CalledProcessError as e:
157
+ error_msg = f"{self.get_tool_name()} command failed with exit code {e.returncode}"
158
+ if e.stderr:
159
+ error_msg += f"\nError: {e.stderr}"
160
+ raise RuntimeError(error_msg)
161
+
162
+ async def execute_background(
163
+ self,
164
+ command: str,
165
+ cwd: Optional[Path] = None,
166
+ env: Optional[Dict[str, str]] = None,
167
+ **kwargs
168
+ ) -> Dict[str, Any]:
169
+ """Execute a command in the background.
170
+
171
+ Args:
172
+ command: Command to execute
173
+ cwd: Working directory
174
+ env: Environment variables
175
+ **kwargs: Additional tool-specific arguments
176
+
177
+ Returns:
178
+ Dict with process_id and log_file
179
+ """
180
+ # Check permissions if manager is available
181
+ if self.permission_manager and cwd:
182
+ self.permission_manager.check_permission(cwd)
183
+
184
+ # Generate process ID and log file
185
+ process_id = f"{self.get_tool_name()}_{uuid.uuid4().hex[:8]}"
186
+ log_file = self.process_manager.log_dir / f"{process_id}.log"
187
+
188
+ # Get command arguments
189
+ cmd_args = self.get_command_args(command, **kwargs)
190
+
191
+ # Prepare environment
192
+ process_env = os.environ.copy()
193
+ if env:
194
+ process_env.update(env)
195
+
196
+ # Start process with output to log file
197
+ with open(log_file, "w") as f:
198
+ process = subprocess.Popen(
199
+ cmd_args,
200
+ cwd=cwd,
201
+ env=process_env,
202
+ stdout=f,
203
+ stderr=subprocess.STDOUT,
204
+ text=True
205
+ )
206
+
207
+ # Track the process
208
+ self.process_manager.add_process(process_id, process, log_file)
209
+
210
+ return {
211
+ "process_id": process_id,
212
+ "pid": process.pid,
213
+ "log_file": str(log_file),
214
+ "status": "started"
215
+ }
216
+
217
+
218
+ class BaseBinaryTool(BaseProcessTool):
219
+ """Base class for binary execution tools (like npx, uvx)."""
220
+
221
+ @abstractmethod
222
+ def get_binary_name(self) -> str:
223
+ """Get the name of the binary to execute."""
224
+ pass
225
+
226
+ @override
227
+ def get_command_args(self, command: str, **kwargs) -> List[str]:
228
+ """Get command arguments for binary execution.
229
+
230
+ Args:
231
+ command: The package or command to run
232
+ **kwargs: Additional arguments (args, flags, etc.)
233
+
234
+ Returns:
235
+ List of command arguments
236
+ """
237
+ cmd_args = [self.get_binary_name()]
238
+
239
+ # Add any binary-specific flags
240
+ if "flags" in kwargs:
241
+ cmd_args.extend(kwargs["flags"])
242
+
243
+ # Add the command/package
244
+ cmd_args.append(command)
245
+
246
+ # Add any additional arguments
247
+ if "args" in kwargs:
248
+ if isinstance(kwargs["args"], str):
249
+ cmd_args.extend(kwargs["args"].split())
250
+ else:
251
+ cmd_args.extend(kwargs["args"])
252
+
253
+ return cmd_args
254
+
255
+ @override
256
+ def get_tool_name(self) -> str:
257
+ """Get the tool name (same as binary name by default)."""
258
+ return self.get_binary_name()
259
+
260
+
261
+ class BaseScriptTool(BaseProcessTool):
262
+ """Base class for script execution tools (like bash, python)."""
263
+
264
+ @abstractmethod
265
+ def get_interpreter(self) -> str:
266
+ """Get the interpreter to use."""
267
+ pass
268
+
269
+ @abstractmethod
270
+ def get_script_flags(self) -> List[str]:
271
+ """Get default flags for the interpreter."""
272
+ pass
273
+
274
+ @override
275
+ def get_command_args(self, command: str, **kwargs) -> List[str]:
276
+ """Get command arguments for script execution.
277
+
278
+ Args:
279
+ command: The script content to execute
280
+ **kwargs: Additional arguments
281
+
282
+ Returns:
283
+ List of command arguments
284
+ """
285
+ cmd_args = [self.get_interpreter()]
286
+ cmd_args.extend(self.get_script_flags())
287
+
288
+ # For inline scripts, use -c flag
289
+ if not kwargs.get("is_file", False):
290
+ cmd_args.extend(["-c", command])
291
+ else:
292
+ cmd_args.append(command)
293
+
294
+ return cmd_args
295
+
296
+ @override
297
+ def get_tool_name(self) -> str:
298
+ """Get the tool name (interpreter name by default)."""
299
+ return self.get_interpreter()
300
+
301
+
302
+ # Import os at the top of the file
303
+ import os
@@ -0,0 +1,134 @@
1
+ """Bash/Shell tool for command execution."""
2
+
3
+ import os
4
+ import platform
5
+ from pathlib import Path
6
+ from typing import Optional, override
7
+
8
+ from mcp.server.fastmcp import Context as MCPContext
9
+
10
+ from hanzo_mcp.tools.shell.base_process import BaseScriptTool
11
+ from mcp.server import FastMCP
12
+
13
+
14
+ class BashTool(BaseScriptTool):
15
+ """Tool for running shell commands."""
16
+
17
+ name = "bash"
18
+
19
+ def register(self, server: FastMCP) -> None:
20
+ """Register the tool with the MCP server."""
21
+ server.tool(name=self.name, description=self.description)(self.call)
22
+
23
+ async def call(self, **kwargs) -> str:
24
+ """Call the tool with arguments."""
25
+ return await self.run(None, **kwargs)
26
+
27
+ @property
28
+ @override
29
+ def description(self) -> str:
30
+ """Get the tool description."""
31
+ return """Run shell commands. Actions: run (default), background.
32
+
33
+ Usage:
34
+ bash "ls -la"
35
+ bash --action background "python server.py"
36
+ bash "git status && git diff"
37
+ bash --action background "npm run dev" --cwd ./frontend"""
38
+
39
+ @override
40
+ def get_interpreter(self) -> str:
41
+ """Get the shell interpreter."""
42
+ if platform.system() == "Windows":
43
+ return "cmd.exe"
44
+
45
+ # Check for user's preferred shell from environment
46
+ shell = os.environ.get("SHELL", "/bin/bash")
47
+
48
+ # Extract just the shell name from the path
49
+ shell_name = os.path.basename(shell)
50
+
51
+ # Check if it's a supported shell and the config file exists
52
+ if shell_name == "zsh":
53
+ # Check for .zshrc
54
+ zshrc_path = Path.home() / ".zshrc"
55
+ if zshrc_path.exists():
56
+ return shell # Use full path to zsh
57
+ elif shell_name == "fish":
58
+ # Check for fish config
59
+ fish_config = Path.home() / ".config" / "fish" / "config.fish"
60
+ if fish_config.exists():
61
+ return shell # Use full path to fish
62
+
63
+ # Default to bash if no special shell config found
64
+ return "bash"
65
+
66
+ @override
67
+ def get_script_flags(self) -> list[str]:
68
+ """Get interpreter flags."""
69
+ if platform.system() == "Windows":
70
+ return ["/c"]
71
+ return ["-c"]
72
+
73
+ @override
74
+ def get_tool_name(self) -> str:
75
+ """Get the tool name."""
76
+ if platform.system() == "Windows":
77
+ return "shell"
78
+
79
+ # Return the actual shell being used
80
+ interpreter = self.get_interpreter()
81
+ return os.path.basename(interpreter)
82
+
83
+ @override
84
+ async def run(
85
+ self,
86
+ ctx: MCPContext,
87
+ command: str,
88
+ action: str = "run",
89
+ cwd: Optional[str] = None,
90
+ env: Optional[dict[str, str]] = None,
91
+ timeout: Optional[int] = None,
92
+ ) -> str:
93
+ """Run a shell command.
94
+
95
+ Args:
96
+ ctx: MCP context
97
+ command: Shell command to execute
98
+ action: Action to perform (run, background)
99
+ cwd: Working directory
100
+ env: Environment variables
101
+ timeout: Command timeout in seconds
102
+
103
+ Returns:
104
+ Command output or process info
105
+ """
106
+ # Prepare working directory
107
+ work_dir = Path(cwd).resolve() if cwd else Path.cwd()
108
+
109
+ if action == "background":
110
+ result = await self.execute_background(
111
+ command,
112
+ cwd=work_dir,
113
+ env=env
114
+ )
115
+ return (
116
+ f"Started command in background\n"
117
+ f"Process ID: {result['process_id']}\n"
118
+ f"PID: {result['pid']}\n"
119
+ f"Log file: {result['log_file']}\n"
120
+ f"Command: {command}"
121
+ )
122
+ else:
123
+ # Default to sync execution
124
+ output = await self.execute_sync(
125
+ command,
126
+ cwd=work_dir,
127
+ env=env,
128
+ timeout=timeout or 120 # Default 2 minute timeout
129
+ )
130
+ return output if output else "Command completed successfully (no output)"
131
+
132
+
133
+ # Create tool instance
134
+ bash_tool = BashTool()
@@ -0,0 +1,265 @@
1
+ """Tool for viewing process logs."""
2
+
3
+ import os
4
+ from pathlib import Path
5
+ from typing import Annotated, Optional, TypedDict, Unpack, final, override
6
+
7
+ from mcp.server.fastmcp import Context as MCPContext
8
+ from pydantic import Field
9
+
10
+ from hanzo_mcp.tools.common.base import BaseTool
11
+ from hanzo_mcp.tools.common.context import create_tool_context
12
+ from hanzo_mcp.tools.common.permissions import PermissionManager
13
+ from hanzo_mcp.tools.shell.run_background import RunBackgroundTool
14
+
15
+
16
+ ProcessId = Annotated[
17
+ Optional[str],
18
+ Field(
19
+ description="Process ID from run_background",
20
+ default=None,
21
+ ),
22
+ ]
23
+
24
+ LogFile = Annotated[
25
+ Optional[str],
26
+ Field(
27
+ description="Path to specific log file",
28
+ default=None,
29
+ ),
30
+ ]
31
+
32
+ Lines = Annotated[
33
+ int,
34
+ Field(
35
+ description="Number of lines to show (default: 50, -1 for all)",
36
+ default=50,
37
+ ),
38
+ ]
39
+
40
+ Follow = Annotated[
41
+ bool,
42
+ Field(
43
+ description="Follow log output (tail -f)",
44
+ default=False,
45
+ ),
46
+ ]
47
+
48
+ ListLogs = Annotated[
49
+ bool,
50
+ Field(
51
+ description="List all available log files",
52
+ default=False,
53
+ ),
54
+ ]
55
+
56
+
57
+ class LogsParams(TypedDict, total=False):
58
+ """Parameters for viewing logs."""
59
+
60
+ id: Optional[str]
61
+ file: Optional[str]
62
+ lines: int
63
+ follow: bool
64
+ list: bool
65
+
66
+
67
+ @final
68
+ class LogsTool(BaseTool):
69
+ """Tool for viewing process logs."""
70
+
71
+ def __init__(self, permission_manager: PermissionManager):
72
+ """Initialize the logs tool.
73
+
74
+ Args:
75
+ permission_manager: Permission manager for access control
76
+ """
77
+ self.permission_manager = permission_manager
78
+ self.log_dir = Path.home() / ".hanzo" / "logs"
79
+
80
+ @property
81
+ @override
82
+ def name(self) -> str:
83
+ """Get the tool name."""
84
+ return "logs"
85
+
86
+ @property
87
+ @override
88
+ def description(self) -> str:
89
+ """Get the tool description."""
90
+ return """View logs from background processes.
91
+
92
+ Options:
93
+ - id: Process ID to view logs for
94
+ - file: Specific log file path
95
+ - lines: Number of lines to show (default: 50, -1 for all)
96
+ - list: List all available log files
97
+
98
+ Examples:
99
+ - logs --id abc123 # View logs for specific process
100
+ - logs --id abc123 --lines 100 # View last 100 lines
101
+ - logs --id abc123 --lines -1 # View entire log
102
+ - logs --list # List all log files
103
+ - logs --file /path/to/log # View specific log file
104
+
105
+ Note: Follow mode (--follow) is not supported in MCP context.
106
+ Use run_command with 'tail -f' for continuous monitoring.
107
+ """
108
+
109
+ @override
110
+ async def call(
111
+ self,
112
+ ctx: MCPContext,
113
+ **params: Unpack[LogsParams],
114
+ ) -> str:
115
+ """View process logs.
116
+
117
+ Args:
118
+ ctx: MCP context
119
+ **params: Tool parameters
120
+
121
+ Returns:
122
+ Log content or list of logs
123
+ """
124
+ tool_ctx = create_tool_context(ctx)
125
+ await tool_ctx.set_tool_info(self.name)
126
+
127
+ # Extract parameters
128
+ process_id = params.get("id")
129
+ log_file = params.get("file")
130
+ lines = params.get("lines", 50)
131
+ follow = params.get("follow", False)
132
+ list_logs = params.get("list", False)
133
+
134
+ try:
135
+ # List available logs
136
+ if list_logs:
137
+ return await self._list_logs(tool_ctx)
138
+
139
+ # Determine log file to read
140
+ if process_id:
141
+ # Find log file for process ID
142
+ process = RunBackgroundTool.get_process(process_id)
143
+ if not process:
144
+ return f"Process with ID '{process_id}' not found."
145
+
146
+ if not process.log_file:
147
+ return f"Process '{process_id}' does not have logging enabled."
148
+
149
+ log_path = process.log_file
150
+
151
+ elif log_file:
152
+ # Use specified log file
153
+ log_path = Path(log_file)
154
+
155
+ # Check if it's in the logs directory
156
+ if not log_path.is_absolute():
157
+ log_path = self.log_dir / log_path
158
+
159
+ else:
160
+ return "Error: Must specify --id or --file"
161
+
162
+ # Check permissions
163
+ if not self.permission_manager.has_permission(str(log_path)):
164
+ return f"Permission denied: {log_path}"
165
+
166
+ # Check if file exists
167
+ if not log_path.exists():
168
+ return f"Log file not found: {log_path}"
169
+
170
+ # Note about follow mode
171
+ if follow:
172
+ await tool_ctx.warning("Follow mode not supported in MCP. Showing latest lines instead.")
173
+
174
+ # Read log file
175
+ await tool_ctx.info(f"Reading log file: {log_path}")
176
+
177
+ try:
178
+ with open(log_path, 'r') as f:
179
+ if lines == -1:
180
+ # Read entire file
181
+ content = f.read()
182
+ else:
183
+ # Read last N lines
184
+ all_lines = f.readlines()
185
+ if len(all_lines) <= lines:
186
+ content = ''.join(all_lines)
187
+ else:
188
+ content = ''.join(all_lines[-lines:])
189
+
190
+ if not content:
191
+ return f"Log file is empty: {log_path}"
192
+
193
+ # Add header
194
+ header = f"=== Log: {log_path.name} ===\n"
195
+ if process_id:
196
+ process = RunBackgroundTool.get_process(process_id)
197
+ if process:
198
+ header += f"Process: {process.name} (ID: {process_id})\n"
199
+ header += f"Command: {process.command}\n"
200
+ status = "running" if process.is_running else f"finished (code: {process.return_code})"
201
+ header += f"Status: {status}\n"
202
+ header += f"{'=' * 50}\n"
203
+
204
+ return header + content
205
+
206
+ except Exception as e:
207
+ return f"Error reading log file: {str(e)}"
208
+
209
+ except Exception as e:
210
+ await tool_ctx.error(f"Failed to view logs: {str(e)}")
211
+ return f"Error viewing logs: {str(e)}"
212
+
213
+ async def _list_logs(self, tool_ctx) -> str:
214
+ """List all available log files."""
215
+ await tool_ctx.info("Listing available log files")
216
+
217
+ if not self.log_dir.exists():
218
+ return "No logs directory found."
219
+
220
+ # Get all log files
221
+ log_files = list(self.log_dir.glob("*.log"))
222
+
223
+ if not log_files:
224
+ return "No log files found."
225
+
226
+ # Sort by modification time (newest first)
227
+ log_files.sort(key=lambda x: x.stat().st_mtime, reverse=True)
228
+
229
+ # Check which logs belong to active processes
230
+ active_processes = RunBackgroundTool.get_processes()
231
+ active_log_files = {str(p.log_file): (pid, p) for pid, p in active_processes.items() if p.log_file}
232
+
233
+ # Build output
234
+ output = []
235
+ output.append("=== Available Log Files ===\n")
236
+
237
+ for log_file in log_files[:50]: # Limit to 50 most recent
238
+ size = log_file.stat().st_size
239
+ size_str = self._format_size(size)
240
+
241
+ # Check if this belongs to an active process
242
+ if str(log_file) in active_log_files:
243
+ pid, process = active_log_files[str(log_file)]
244
+ status = "active" if process.is_running else "finished"
245
+ output.append(f"{log_file.name:<50} {size_str:>10} [{status}] (ID: {pid})")
246
+ else:
247
+ output.append(f"{log_file.name:<50} {size_str:>10}")
248
+
249
+ output.append(f"\nTotal: {len(log_files)} log file(s)")
250
+ output.append("\nUse 'logs --file <filename>' to view a specific log")
251
+ output.append("Use 'logs --id <process-id>' to view logs for a running process")
252
+
253
+ return "\n".join(output)
254
+
255
+ def _format_size(self, size: int) -> str:
256
+ """Format file size in human-readable format."""
257
+ for unit in ['B', 'KB', 'MB', 'GB']:
258
+ if size < 1024.0:
259
+ return f"{size:.1f} {unit}"
260
+ size /= 1024.0
261
+ return f"{size:.1f} TB"
262
+
263
+ def register(self, mcp_server) -> None:
264
+ """Register this tool with the MCP server."""
265
+ pass