hanzo-mcp 0.5.0__py3-none-any.whl → 0.5.2__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.
- hanzo_mcp/__init__.py +1 -1
- hanzo_mcp/config/settings.py +61 -0
- hanzo_mcp/tools/__init__.py +158 -12
- hanzo_mcp/tools/common/base.py +7 -2
- hanzo_mcp/tools/common/config_tool.py +396 -0
- hanzo_mcp/tools/common/stats.py +261 -0
- hanzo_mcp/tools/common/tool_disable.py +144 -0
- hanzo_mcp/tools/common/tool_enable.py +182 -0
- hanzo_mcp/tools/common/tool_list.py +263 -0
- hanzo_mcp/tools/database/__init__.py +71 -0
- hanzo_mcp/tools/database/database_manager.py +246 -0
- hanzo_mcp/tools/database/graph_add.py +257 -0
- hanzo_mcp/tools/database/graph_query.py +536 -0
- hanzo_mcp/tools/database/graph_remove.py +267 -0
- hanzo_mcp/tools/database/graph_search.py +348 -0
- hanzo_mcp/tools/database/graph_stats.py +345 -0
- hanzo_mcp/tools/database/sql_query.py +229 -0
- hanzo_mcp/tools/database/sql_search.py +296 -0
- hanzo_mcp/tools/database/sql_stats.py +254 -0
- hanzo_mcp/tools/editor/__init__.py +11 -0
- hanzo_mcp/tools/editor/neovim_command.py +272 -0
- hanzo_mcp/tools/editor/neovim_edit.py +290 -0
- hanzo_mcp/tools/editor/neovim_session.py +356 -0
- hanzo_mcp/tools/filesystem/__init__.py +20 -1
- hanzo_mcp/tools/filesystem/batch_search.py +812 -0
- hanzo_mcp/tools/filesystem/find_files.py +348 -0
- hanzo_mcp/tools/filesystem/git_search.py +505 -0
- hanzo_mcp/tools/llm/__init__.py +27 -0
- hanzo_mcp/tools/llm/consensus_tool.py +351 -0
- hanzo_mcp/tools/llm/llm_manage.py +413 -0
- hanzo_mcp/tools/llm/llm_tool.py +346 -0
- hanzo_mcp/tools/llm/provider_tools.py +412 -0
- hanzo_mcp/tools/mcp/__init__.py +11 -0
- hanzo_mcp/tools/mcp/mcp_add.py +263 -0
- hanzo_mcp/tools/mcp/mcp_remove.py +127 -0
- hanzo_mcp/tools/mcp/mcp_stats.py +165 -0
- hanzo_mcp/tools/shell/__init__.py +27 -7
- hanzo_mcp/tools/shell/logs.py +265 -0
- hanzo_mcp/tools/shell/npx.py +194 -0
- hanzo_mcp/tools/shell/npx_background.py +254 -0
- hanzo_mcp/tools/shell/pkill.py +262 -0
- hanzo_mcp/tools/shell/processes.py +279 -0
- hanzo_mcp/tools/shell/run_background.py +326 -0
- hanzo_mcp/tools/shell/uvx.py +187 -0
- hanzo_mcp/tools/shell/uvx_background.py +249 -0
- hanzo_mcp/tools/vector/__init__.py +21 -12
- hanzo_mcp/tools/vector/ast_analyzer.py +459 -0
- hanzo_mcp/tools/vector/git_ingester.py +485 -0
- hanzo_mcp/tools/vector/index_tool.py +358 -0
- hanzo_mcp/tools/vector/infinity_store.py +465 -1
- hanzo_mcp/tools/vector/mock_infinity.py +162 -0
- hanzo_mcp/tools/vector/vector_index.py +7 -6
- hanzo_mcp/tools/vector/vector_search.py +22 -7
- {hanzo_mcp-0.5.0.dist-info → hanzo_mcp-0.5.2.dist-info}/METADATA +68 -20
- hanzo_mcp-0.5.2.dist-info/RECORD +106 -0
- hanzo_mcp-0.5.0.dist-info/RECORD +0 -63
- {hanzo_mcp-0.5.0.dist-info → hanzo_mcp-0.5.2.dist-info}/WHEEL +0 -0
- {hanzo_mcp-0.5.0.dist-info → hanzo_mcp-0.5.2.dist-info}/entry_points.txt +0 -0
- {hanzo_mcp-0.5.0.dist-info → hanzo_mcp-0.5.2.dist-info}/licenses/LICENSE +0 -0
- {hanzo_mcp-0.5.0.dist-info → hanzo_mcp-0.5.2.dist-info}/top_level.txt +0 -0
|
@@ -0,0 +1,279 @@
|
|
|
1
|
+
"""Tool for listing running background processes."""
|
|
2
|
+
|
|
3
|
+
import psutil
|
|
4
|
+
from datetime import datetime
|
|
5
|
+
from typing import Annotated, Optional, TypedDict, Unpack, final, override
|
|
6
|
+
|
|
7
|
+
from 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
|
+
ShowAll = Annotated[
|
|
17
|
+
bool,
|
|
18
|
+
Field(
|
|
19
|
+
description="Show all system processes (not just background processes)",
|
|
20
|
+
default=False,
|
|
21
|
+
),
|
|
22
|
+
]
|
|
23
|
+
|
|
24
|
+
FilterName = Annotated[
|
|
25
|
+
Optional[str],
|
|
26
|
+
Field(
|
|
27
|
+
description="Filter processes by name",
|
|
28
|
+
default=None,
|
|
29
|
+
),
|
|
30
|
+
]
|
|
31
|
+
|
|
32
|
+
ShowDetails = Annotated[
|
|
33
|
+
bool,
|
|
34
|
+
Field(
|
|
35
|
+
description="Show detailed process information",
|
|
36
|
+
default=False,
|
|
37
|
+
),
|
|
38
|
+
]
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class ProcessesParams(TypedDict, total=False):
|
|
42
|
+
"""Parameters for listing processes."""
|
|
43
|
+
|
|
44
|
+
show_all: bool
|
|
45
|
+
filter_name: Optional[str]
|
|
46
|
+
show_details: bool
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
@final
|
|
50
|
+
class ProcessesTool(BaseTool):
|
|
51
|
+
"""Tool for listing running processes."""
|
|
52
|
+
|
|
53
|
+
def __init__(self, permission_manager: PermissionManager):
|
|
54
|
+
"""Initialize the processes tool.
|
|
55
|
+
|
|
56
|
+
Args:
|
|
57
|
+
permission_manager: Permission manager for access control
|
|
58
|
+
"""
|
|
59
|
+
self.permission_manager = permission_manager
|
|
60
|
+
|
|
61
|
+
@property
|
|
62
|
+
@override
|
|
63
|
+
def name(self) -> str:
|
|
64
|
+
"""Get the tool name."""
|
|
65
|
+
return "processes"
|
|
66
|
+
|
|
67
|
+
@property
|
|
68
|
+
@override
|
|
69
|
+
def description(self) -> str:
|
|
70
|
+
"""Get the tool description."""
|
|
71
|
+
return """List running background processes started with run_background.
|
|
72
|
+
|
|
73
|
+
Shows:
|
|
74
|
+
- Process ID (for use with pkill)
|
|
75
|
+
- Process name
|
|
76
|
+
- Command
|
|
77
|
+
- PID (system process ID)
|
|
78
|
+
- Status (running/finished)
|
|
79
|
+
- Start time
|
|
80
|
+
- Log file location
|
|
81
|
+
|
|
82
|
+
Options:
|
|
83
|
+
- show_all: Show all system processes (requires permissions)
|
|
84
|
+
- filter_name: Filter by process name
|
|
85
|
+
- show_details: Show CPU, memory usage
|
|
86
|
+
|
|
87
|
+
Examples:
|
|
88
|
+
- processes # List background processes
|
|
89
|
+
- processes --show-details # Include resource usage
|
|
90
|
+
- processes --filter-name npm # Show only npm processes
|
|
91
|
+
"""
|
|
92
|
+
|
|
93
|
+
@override
|
|
94
|
+
async def call(
|
|
95
|
+
self,
|
|
96
|
+
ctx: MCPContext,
|
|
97
|
+
**params: Unpack[ProcessesParams],
|
|
98
|
+
) -> str:
|
|
99
|
+
"""List running processes.
|
|
100
|
+
|
|
101
|
+
Args:
|
|
102
|
+
ctx: MCP context
|
|
103
|
+
**params: Tool parameters
|
|
104
|
+
|
|
105
|
+
Returns:
|
|
106
|
+
Process listing
|
|
107
|
+
"""
|
|
108
|
+
tool_ctx = create_tool_context(ctx)
|
|
109
|
+
await tool_ctx.set_tool_info(self.name)
|
|
110
|
+
|
|
111
|
+
# Extract parameters
|
|
112
|
+
show_all = params.get("show_all", False)
|
|
113
|
+
filter_name = params.get("filter_name")
|
|
114
|
+
show_details = params.get("show_details", False)
|
|
115
|
+
|
|
116
|
+
try:
|
|
117
|
+
if show_all:
|
|
118
|
+
# Show all system processes
|
|
119
|
+
await tool_ctx.info("Listing all system processes")
|
|
120
|
+
return self._list_system_processes(filter_name, show_details)
|
|
121
|
+
else:
|
|
122
|
+
# Show only background processes
|
|
123
|
+
await tool_ctx.info("Listing background processes")
|
|
124
|
+
return self._list_background_processes(filter_name, show_details)
|
|
125
|
+
|
|
126
|
+
except Exception as e:
|
|
127
|
+
await tool_ctx.error(f"Failed to list processes: {str(e)}")
|
|
128
|
+
return f"Error listing processes: {str(e)}"
|
|
129
|
+
|
|
130
|
+
def _list_background_processes(
|
|
131
|
+
self, filter_name: Optional[str], show_details: bool
|
|
132
|
+
) -> str:
|
|
133
|
+
"""List background processes started with run_background."""
|
|
134
|
+
processes = RunBackgroundTool.get_processes()
|
|
135
|
+
|
|
136
|
+
if not processes:
|
|
137
|
+
return "No background processes are currently running."
|
|
138
|
+
|
|
139
|
+
# Filter if requested
|
|
140
|
+
filtered_processes = []
|
|
141
|
+
for proc_id, process in processes.items():
|
|
142
|
+
if filter_name:
|
|
143
|
+
if filter_name.lower() not in process.name.lower():
|
|
144
|
+
continue
|
|
145
|
+
filtered_processes.append((proc_id, process))
|
|
146
|
+
|
|
147
|
+
if not filtered_processes:
|
|
148
|
+
return f"No background processes found matching '{filter_name}'."
|
|
149
|
+
|
|
150
|
+
# Build output
|
|
151
|
+
output = []
|
|
152
|
+
output.append("=== Background Processes ===\n")
|
|
153
|
+
|
|
154
|
+
# Sort by start time (newest first)
|
|
155
|
+
filtered_processes.sort(key=lambda x: x[1].start_time, reverse=True)
|
|
156
|
+
|
|
157
|
+
for proc_id, process in filtered_processes:
|
|
158
|
+
status = "running" if process.is_running else f"finished (code: {process.return_code})"
|
|
159
|
+
runtime = datetime.now() - process.start_time
|
|
160
|
+
runtime_str = str(runtime).split('.')[0] # Remove microseconds
|
|
161
|
+
|
|
162
|
+
output.append(f"ID: {proc_id}")
|
|
163
|
+
output.append(f"Name: {process.name}")
|
|
164
|
+
output.append(f"Status: {status}")
|
|
165
|
+
output.append(f"PID: {process.pid}")
|
|
166
|
+
output.append(f"Runtime: {runtime_str}")
|
|
167
|
+
output.append(f"Command: {process.command}")
|
|
168
|
+
output.append(f"Working Dir: {process.working_dir}")
|
|
169
|
+
|
|
170
|
+
if process.log_file:
|
|
171
|
+
output.append(f"Log File: {process.log_file}")
|
|
172
|
+
|
|
173
|
+
if show_details and process.is_running:
|
|
174
|
+
try:
|
|
175
|
+
# Get process details using psutil
|
|
176
|
+
p = psutil.Process(process.pid)
|
|
177
|
+
output.append(f"CPU: {p.cpu_percent(interval=0.1):.1f}%")
|
|
178
|
+
output.append(f"Memory: {p.memory_info().rss / 1024 / 1024:.1f} MB")
|
|
179
|
+
output.append(f"Threads: {p.num_threads()}")
|
|
180
|
+
except (psutil.NoSuchProcess, psutil.AccessDenied):
|
|
181
|
+
output.append("Process details unavailable")
|
|
182
|
+
|
|
183
|
+
output.append("-" * 40)
|
|
184
|
+
|
|
185
|
+
output.append(f"\nTotal: {len(filtered_processes)} process(es)")
|
|
186
|
+
output.append("\nUse 'pkill --id <ID>' to stop a process")
|
|
187
|
+
output.append("Use 'logs --id <ID>' to view process logs")
|
|
188
|
+
|
|
189
|
+
return "\n".join(output)
|
|
190
|
+
|
|
191
|
+
def _list_system_processes(
|
|
192
|
+
self, filter_name: Optional[str], show_details: bool
|
|
193
|
+
) -> str:
|
|
194
|
+
"""List all system processes."""
|
|
195
|
+
try:
|
|
196
|
+
processes = []
|
|
197
|
+
|
|
198
|
+
# Get all running processes
|
|
199
|
+
for proc in psutil.process_iter(['pid', 'name', 'cmdline', 'create_time']):
|
|
200
|
+
try:
|
|
201
|
+
info = proc.info
|
|
202
|
+
name = info['name']
|
|
203
|
+
|
|
204
|
+
# Filter if requested
|
|
205
|
+
if filter_name:
|
|
206
|
+
if filter_name.lower() not in name.lower():
|
|
207
|
+
continue
|
|
208
|
+
|
|
209
|
+
# Get command line
|
|
210
|
+
cmdline = info.get('cmdline')
|
|
211
|
+
if cmdline:
|
|
212
|
+
cmd = ' '.join(cmdline)
|
|
213
|
+
else:
|
|
214
|
+
cmd = name
|
|
215
|
+
|
|
216
|
+
# Truncate long commands
|
|
217
|
+
if len(cmd) > 80:
|
|
218
|
+
cmd = cmd[:77] + "..."
|
|
219
|
+
|
|
220
|
+
process_info = {
|
|
221
|
+
'pid': info['pid'],
|
|
222
|
+
'name': name,
|
|
223
|
+
'cmd': cmd,
|
|
224
|
+
'create_time': info['create_time'],
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
if show_details:
|
|
228
|
+
process_info['cpu'] = proc.cpu_percent(interval=0.1)
|
|
229
|
+
process_info['memory'] = proc.memory_info().rss / 1024 / 1024 # MB
|
|
230
|
+
|
|
231
|
+
processes.append(process_info)
|
|
232
|
+
|
|
233
|
+
except (psutil.NoSuchProcess, psutil.AccessDenied):
|
|
234
|
+
continue
|
|
235
|
+
|
|
236
|
+
if not processes:
|
|
237
|
+
return f"No processes found matching '{filter_name}'."
|
|
238
|
+
|
|
239
|
+
# Sort by PID
|
|
240
|
+
processes.sort(key=lambda x: x['pid'])
|
|
241
|
+
|
|
242
|
+
# Build output
|
|
243
|
+
output = []
|
|
244
|
+
output.append("=== System Processes ===\n")
|
|
245
|
+
|
|
246
|
+
# Header
|
|
247
|
+
if show_details:
|
|
248
|
+
output.append(f"{'PID':>7} {'CPU%':>5} {'MEM(MB)':>8} {'NAME':<20} COMMAND")
|
|
249
|
+
output.append("-" * 80)
|
|
250
|
+
|
|
251
|
+
for proc in processes:
|
|
252
|
+
output.append(
|
|
253
|
+
f"{proc['pid']:>7} "
|
|
254
|
+
f"{proc['cpu']:>5.1f} "
|
|
255
|
+
f"{proc['memory']:>8.1f} "
|
|
256
|
+
f"{proc['name']:<20} "
|
|
257
|
+
f"{proc['cmd']}"
|
|
258
|
+
)
|
|
259
|
+
else:
|
|
260
|
+
output.append(f"{'PID':>7} {'NAME':<20} COMMAND")
|
|
261
|
+
output.append("-" * 80)
|
|
262
|
+
|
|
263
|
+
for proc in processes:
|
|
264
|
+
output.append(
|
|
265
|
+
f"{proc['pid']:>7} "
|
|
266
|
+
f"{proc['name']:<20} "
|
|
267
|
+
f"{proc['cmd']}"
|
|
268
|
+
)
|
|
269
|
+
|
|
270
|
+
output.append(f"\nTotal: {len(processes)} process(es)")
|
|
271
|
+
|
|
272
|
+
return "\n".join(output)
|
|
273
|
+
|
|
274
|
+
except Exception as e:
|
|
275
|
+
return f"Error listing system processes: {str(e)}\nYou may need elevated permissions."
|
|
276
|
+
|
|
277
|
+
def register(self, mcp_server) -> None:
|
|
278
|
+
"""Register this tool with the MCP server."""
|
|
279
|
+
pass
|
|
@@ -0,0 +1,326 @@
|
|
|
1
|
+
"""Background process execution tool."""
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import os
|
|
5
|
+
import subprocess
|
|
6
|
+
import time
|
|
7
|
+
import uuid
|
|
8
|
+
from datetime import datetime
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from typing import Annotated, Optional, TypedDict, Unpack, final, override
|
|
11
|
+
|
|
12
|
+
from fastmcp import Context as MCPContext
|
|
13
|
+
from pydantic import Field
|
|
14
|
+
|
|
15
|
+
from hanzo_mcp.tools.common.base import BaseTool
|
|
16
|
+
from hanzo_mcp.tools.common.context import create_tool_context
|
|
17
|
+
from hanzo_mcp.tools.common.permissions import PermissionManager
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
Command = Annotated[
|
|
21
|
+
str,
|
|
22
|
+
Field(
|
|
23
|
+
description="The command to execute in the background",
|
|
24
|
+
min_length=1,
|
|
25
|
+
),
|
|
26
|
+
]
|
|
27
|
+
|
|
28
|
+
WorkingDir = Annotated[
|
|
29
|
+
Optional[str],
|
|
30
|
+
Field(
|
|
31
|
+
description="Working directory for the command",
|
|
32
|
+
default=None,
|
|
33
|
+
),
|
|
34
|
+
]
|
|
35
|
+
|
|
36
|
+
Name = Annotated[
|
|
37
|
+
Optional[str],
|
|
38
|
+
Field(
|
|
39
|
+
description="Name for the background process (for identification)",
|
|
40
|
+
default=None,
|
|
41
|
+
),
|
|
42
|
+
]
|
|
43
|
+
|
|
44
|
+
LogToFile = Annotated[
|
|
45
|
+
bool,
|
|
46
|
+
Field(
|
|
47
|
+
description="Whether to log output to file",
|
|
48
|
+
default=True,
|
|
49
|
+
),
|
|
50
|
+
]
|
|
51
|
+
|
|
52
|
+
Env = Annotated[
|
|
53
|
+
Optional[dict[str, str]],
|
|
54
|
+
Field(
|
|
55
|
+
description="Environment variables to set",
|
|
56
|
+
default=None,
|
|
57
|
+
),
|
|
58
|
+
]
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
class RunBackgroundParams(TypedDict, total=False):
|
|
62
|
+
"""Parameters for running background commands."""
|
|
63
|
+
|
|
64
|
+
command: str
|
|
65
|
+
working_dir: Optional[str]
|
|
66
|
+
name: Optional[str]
|
|
67
|
+
log_to_file: bool
|
|
68
|
+
env: Optional[dict[str, str]]
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
class BackgroundProcess:
|
|
72
|
+
"""Represents a running background process."""
|
|
73
|
+
|
|
74
|
+
def __init__(
|
|
75
|
+
self,
|
|
76
|
+
process_id: str,
|
|
77
|
+
command: str,
|
|
78
|
+
name: str,
|
|
79
|
+
working_dir: str,
|
|
80
|
+
log_file: Optional[Path],
|
|
81
|
+
process: subprocess.Popen,
|
|
82
|
+
):
|
|
83
|
+
self.process_id = process_id
|
|
84
|
+
self.command = command
|
|
85
|
+
self.name = name
|
|
86
|
+
self.working_dir = working_dir
|
|
87
|
+
self.log_file = log_file
|
|
88
|
+
self.process = process
|
|
89
|
+
self.start_time = datetime.now()
|
|
90
|
+
self.end_time: Optional[datetime] = None
|
|
91
|
+
|
|
92
|
+
@property
|
|
93
|
+
def is_running(self) -> bool:
|
|
94
|
+
"""Check if process is still running."""
|
|
95
|
+
return self.process.poll() is None
|
|
96
|
+
|
|
97
|
+
@property
|
|
98
|
+
def pid(self) -> int:
|
|
99
|
+
"""Get process ID."""
|
|
100
|
+
return self.process.pid
|
|
101
|
+
|
|
102
|
+
@property
|
|
103
|
+
def return_code(self) -> Optional[int]:
|
|
104
|
+
"""Get return code if process has finished."""
|
|
105
|
+
return self.process.poll()
|
|
106
|
+
|
|
107
|
+
def terminate(self) -> None:
|
|
108
|
+
"""Terminate the process."""
|
|
109
|
+
if self.is_running:
|
|
110
|
+
self.process.terminate()
|
|
111
|
+
self.end_time = datetime.now()
|
|
112
|
+
|
|
113
|
+
def kill(self) -> None:
|
|
114
|
+
"""Kill the process forcefully."""
|
|
115
|
+
if self.is_running:
|
|
116
|
+
self.process.kill()
|
|
117
|
+
self.end_time = datetime.now()
|
|
118
|
+
|
|
119
|
+
def to_dict(self) -> dict:
|
|
120
|
+
"""Convert to dictionary for display."""
|
|
121
|
+
return {
|
|
122
|
+
"id": self.process_id,
|
|
123
|
+
"name": self.name,
|
|
124
|
+
"command": self.command,
|
|
125
|
+
"pid": self.pid,
|
|
126
|
+
"working_dir": self.working_dir,
|
|
127
|
+
"log_file": str(self.log_file) if self.log_file else None,
|
|
128
|
+
"start_time": self.start_time.isoformat(),
|
|
129
|
+
"end_time": self.end_time.isoformat() if self.end_time else None,
|
|
130
|
+
"is_running": self.is_running,
|
|
131
|
+
"return_code": self.return_code,
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
@final
|
|
136
|
+
class RunBackgroundTool(BaseTool):
|
|
137
|
+
"""Tool for running commands in the background."""
|
|
138
|
+
|
|
139
|
+
# Class variable to store running processes
|
|
140
|
+
_processes: dict[str, BackgroundProcess] = {}
|
|
141
|
+
|
|
142
|
+
def __init__(self, permission_manager: PermissionManager):
|
|
143
|
+
"""Initialize the background runner tool.
|
|
144
|
+
|
|
145
|
+
Args:
|
|
146
|
+
permission_manager: Permission manager for access control
|
|
147
|
+
"""
|
|
148
|
+
self.permission_manager = permission_manager
|
|
149
|
+
self.log_dir = Path.home() / ".hanzo" / "logs"
|
|
150
|
+
self.log_dir.mkdir(parents=True, exist_ok=True)
|
|
151
|
+
|
|
152
|
+
@property
|
|
153
|
+
@override
|
|
154
|
+
def name(self) -> str:
|
|
155
|
+
"""Get the tool name."""
|
|
156
|
+
return "run_background"
|
|
157
|
+
|
|
158
|
+
@property
|
|
159
|
+
@override
|
|
160
|
+
def description(self) -> str:
|
|
161
|
+
"""Get the tool description."""
|
|
162
|
+
return """Run a command in the background, allowing it to continue running.
|
|
163
|
+
|
|
164
|
+
Perfect for:
|
|
165
|
+
- Starting development servers (npm run dev, python -m http.server)
|
|
166
|
+
- Running long-running processes (file watchers, build processes)
|
|
167
|
+
- Starting services that need to keep running
|
|
168
|
+
- Running multiple processes concurrently
|
|
169
|
+
|
|
170
|
+
Features:
|
|
171
|
+
- Runs process in background, returns immediately
|
|
172
|
+
- Optional logging to ~/.hanzo/logs
|
|
173
|
+
- Process tracking with unique IDs
|
|
174
|
+
- Working directory support
|
|
175
|
+
- Environment variable support
|
|
176
|
+
|
|
177
|
+
Use 'processes' tool to list running processes
|
|
178
|
+
Use 'pkill' tool to terminate processes
|
|
179
|
+
Use 'logs' tool to view process output
|
|
180
|
+
|
|
181
|
+
Examples:
|
|
182
|
+
- run_background --command "npm run dev" --name "frontend-server"
|
|
183
|
+
- run_background --command "python app.py" --working-dir "/path/to/app"
|
|
184
|
+
"""
|
|
185
|
+
|
|
186
|
+
@override
|
|
187
|
+
async def call(
|
|
188
|
+
self,
|
|
189
|
+
ctx: MCPContext,
|
|
190
|
+
**params: Unpack[RunBackgroundParams],
|
|
191
|
+
) -> str:
|
|
192
|
+
"""Execute a command in the background.
|
|
193
|
+
|
|
194
|
+
Args:
|
|
195
|
+
ctx: MCP context
|
|
196
|
+
**params: Tool parameters
|
|
197
|
+
|
|
198
|
+
Returns:
|
|
199
|
+
Information about the started process
|
|
200
|
+
"""
|
|
201
|
+
tool_ctx = create_tool_context(ctx)
|
|
202
|
+
await tool_ctx.set_tool_info(self.name)
|
|
203
|
+
|
|
204
|
+
# Extract parameters
|
|
205
|
+
command = params.get("command")
|
|
206
|
+
if not command:
|
|
207
|
+
return "Error: command is required"
|
|
208
|
+
|
|
209
|
+
working_dir = params.get("working_dir", os.getcwd())
|
|
210
|
+
name = params.get("name", command.split()[0])
|
|
211
|
+
log_to_file = params.get("log_to_file", True)
|
|
212
|
+
env = params.get("env")
|
|
213
|
+
|
|
214
|
+
# Resolve absolute path for working directory
|
|
215
|
+
abs_working_dir = os.path.abspath(working_dir)
|
|
216
|
+
|
|
217
|
+
# Check permissions
|
|
218
|
+
if not self.permission_manager.has_permission(abs_working_dir):
|
|
219
|
+
return f"Permission denied: {abs_working_dir}"
|
|
220
|
+
|
|
221
|
+
# Check if working directory exists
|
|
222
|
+
if not os.path.exists(abs_working_dir):
|
|
223
|
+
return f"Working directory does not exist: {abs_working_dir}"
|
|
224
|
+
|
|
225
|
+
# Generate process ID
|
|
226
|
+
process_id = str(uuid.uuid4())[:8]
|
|
227
|
+
|
|
228
|
+
# Setup logging
|
|
229
|
+
log_file = None
|
|
230
|
+
if log_to_file:
|
|
231
|
+
log_filename = f"{process_id}_{name.replace(' ', '_')}_{datetime.now().strftime('%Y%m%d_%H%M%S')}.log"
|
|
232
|
+
log_file = self.log_dir / log_filename
|
|
233
|
+
|
|
234
|
+
await tool_ctx.info(f"Starting background process: {name}")
|
|
235
|
+
|
|
236
|
+
try:
|
|
237
|
+
# Prepare environment
|
|
238
|
+
process_env = os.environ.copy()
|
|
239
|
+
if env:
|
|
240
|
+
process_env.update(env)
|
|
241
|
+
|
|
242
|
+
# Open log file for writing
|
|
243
|
+
if log_file:
|
|
244
|
+
log_handle = open(log_file, 'w')
|
|
245
|
+
stdout = log_handle
|
|
246
|
+
stderr = subprocess.STDOUT
|
|
247
|
+
else:
|
|
248
|
+
stdout = subprocess.DEVNULL
|
|
249
|
+
stderr = subprocess.DEVNULL
|
|
250
|
+
|
|
251
|
+
# Start the process
|
|
252
|
+
process = subprocess.Popen(
|
|
253
|
+
command,
|
|
254
|
+
shell=True,
|
|
255
|
+
cwd=abs_working_dir,
|
|
256
|
+
env=process_env,
|
|
257
|
+
stdout=stdout,
|
|
258
|
+
stderr=stderr,
|
|
259
|
+
# Don't wait for process to complete
|
|
260
|
+
start_new_session=True, # Detach from parent process group
|
|
261
|
+
)
|
|
262
|
+
|
|
263
|
+
# Create process object
|
|
264
|
+
bg_process = BackgroundProcess(
|
|
265
|
+
process_id=process_id,
|
|
266
|
+
command=command,
|
|
267
|
+
name=name,
|
|
268
|
+
working_dir=abs_working_dir,
|
|
269
|
+
log_file=log_file,
|
|
270
|
+
process=process,
|
|
271
|
+
)
|
|
272
|
+
|
|
273
|
+
# Store in class variable
|
|
274
|
+
RunBackgroundTool._processes[process_id] = bg_process
|
|
275
|
+
|
|
276
|
+
# Clean up finished processes
|
|
277
|
+
self._cleanup_finished_processes()
|
|
278
|
+
|
|
279
|
+
await tool_ctx.info(f"Process started with ID: {process_id}, PID: {process.pid}")
|
|
280
|
+
|
|
281
|
+
# Return process information
|
|
282
|
+
return f"""Background process started successfully!
|
|
283
|
+
|
|
284
|
+
Process ID: {process_id}
|
|
285
|
+
Name: {name}
|
|
286
|
+
PID: {process.pid}
|
|
287
|
+
Command: {command}
|
|
288
|
+
Working Directory: {abs_working_dir}
|
|
289
|
+
Log File: {log_file if log_file else 'Not logging'}
|
|
290
|
+
|
|
291
|
+
Use 'processes' to list all running processes
|
|
292
|
+
Use 'pkill --id {process_id}' to stop this process
|
|
293
|
+
Use 'logs --id {process_id}' to view output (if logging enabled)
|
|
294
|
+
"""
|
|
295
|
+
|
|
296
|
+
except Exception as e:
|
|
297
|
+
await tool_ctx.error(f"Failed to start background process: {str(e)}")
|
|
298
|
+
return f"Error starting background process: {str(e)}"
|
|
299
|
+
|
|
300
|
+
@classmethod
|
|
301
|
+
def get_processes(cls) -> dict[str, BackgroundProcess]:
|
|
302
|
+
"""Get all tracked processes."""
|
|
303
|
+
return cls._processes
|
|
304
|
+
|
|
305
|
+
@classmethod
|
|
306
|
+
def get_process(cls, process_id: str) -> Optional[BackgroundProcess]:
|
|
307
|
+
"""Get a specific process by ID."""
|
|
308
|
+
return cls._processes.get(process_id)
|
|
309
|
+
|
|
310
|
+
def _cleanup_finished_processes(self) -> None:
|
|
311
|
+
"""Remove finished processes that have been terminated for a while."""
|
|
312
|
+
now = datetime.now()
|
|
313
|
+
to_remove = []
|
|
314
|
+
|
|
315
|
+
for process_id, process in RunBackgroundTool._processes.items():
|
|
316
|
+
if not process.is_running and process.end_time:
|
|
317
|
+
# Keep finished processes for 5 minutes for log access
|
|
318
|
+
if (now - process.end_time).total_seconds() > 300:
|
|
319
|
+
to_remove.append(process_id)
|
|
320
|
+
|
|
321
|
+
for process_id in to_remove:
|
|
322
|
+
del RunBackgroundTool._processes[process_id]
|
|
323
|
+
|
|
324
|
+
def register(self, mcp_server) -> None:
|
|
325
|
+
"""Register this tool with the MCP server."""
|
|
326
|
+
pass
|