hanzo-mcp 0.1.21__py3-none-any.whl → 0.1.30__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/cli.py +81 -10
- hanzo_mcp/server.py +42 -11
- hanzo_mcp/tools/__init__.py +51 -32
- hanzo_mcp/tools/agent/__init__.py +59 -0
- hanzo_mcp/tools/agent/agent_tool.py +474 -0
- hanzo_mcp/tools/agent/prompt.py +137 -0
- hanzo_mcp/tools/agent/tool_adapter.py +75 -0
- hanzo_mcp/tools/common/__init__.py +18 -1
- hanzo_mcp/tools/common/base.py +216 -0
- hanzo_mcp/tools/common/context.py +9 -5
- hanzo_mcp/tools/common/permissions.py +7 -3
- hanzo_mcp/tools/common/session.py +91 -0
- hanzo_mcp/tools/common/thinking_tool.py +123 -0
- hanzo_mcp/tools/common/validation.py +1 -1
- hanzo_mcp/tools/filesystem/__init__.py +85 -5
- hanzo_mcp/tools/filesystem/base.py +113 -0
- hanzo_mcp/tools/filesystem/content_replace.py +287 -0
- hanzo_mcp/tools/filesystem/directory_tree.py +286 -0
- hanzo_mcp/tools/filesystem/edit_file.py +287 -0
- hanzo_mcp/tools/filesystem/get_file_info.py +170 -0
- hanzo_mcp/tools/filesystem/read_files.py +198 -0
- hanzo_mcp/tools/filesystem/search_content.py +275 -0
- hanzo_mcp/tools/filesystem/write_file.py +162 -0
- hanzo_mcp/tools/jupyter/__init__.py +67 -4
- hanzo_mcp/tools/jupyter/base.py +284 -0
- hanzo_mcp/tools/jupyter/edit_notebook.py +295 -0
- hanzo_mcp/tools/jupyter/notebook_operations.py +73 -113
- hanzo_mcp/tools/jupyter/read_notebook.py +165 -0
- hanzo_mcp/tools/project/__init__.py +64 -1
- hanzo_mcp/tools/project/analysis.py +8 -5
- hanzo_mcp/tools/project/base.py +66 -0
- hanzo_mcp/tools/project/project_analyze.py +173 -0
- hanzo_mcp/tools/shell/__init__.py +58 -1
- hanzo_mcp/tools/shell/base.py +148 -0
- hanzo_mcp/tools/shell/command_executor.py +198 -317
- hanzo_mcp/tools/shell/run_command.py +204 -0
- hanzo_mcp/tools/shell/run_script.py +215 -0
- hanzo_mcp/tools/shell/script_tool.py +244 -0
- {hanzo_mcp-0.1.21.dist-info → hanzo_mcp-0.1.30.dist-info}/METADATA +72 -77
- hanzo_mcp-0.1.30.dist-info/RECORD +45 -0
- {hanzo_mcp-0.1.21.dist-info → hanzo_mcp-0.1.30.dist-info}/WHEEL +1 -1
- {hanzo_mcp-0.1.21.dist-info → hanzo_mcp-0.1.30.dist-info}/licenses/LICENSE +2 -2
- hanzo_mcp/tools/common/thinking.py +0 -65
- hanzo_mcp/tools/filesystem/file_operations.py +0 -1050
- hanzo_mcp-0.1.21.dist-info/RECORD +0 -23
- {hanzo_mcp-0.1.21.dist-info → hanzo_mcp-0.1.30.dist-info}/entry_points.txt +0 -0
- {hanzo_mcp-0.1.21.dist-info → hanzo_mcp-0.1.30.dist-info}/top_level.txt +0 -0
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
"""Run command tool implementation.
|
|
2
|
+
|
|
3
|
+
This module provides the RunCommandTool for running shell commands.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
import os
|
|
7
|
+
from typing import Any, final, override
|
|
8
|
+
|
|
9
|
+
from mcp.server.fastmcp import Context as MCPContext
|
|
10
|
+
from mcp.server.fastmcp import FastMCP
|
|
11
|
+
|
|
12
|
+
from hanzo_mcp.tools.common.context import create_tool_context
|
|
13
|
+
from hanzo_mcp.tools.shell.base import ShellBaseTool
|
|
14
|
+
from hanzo_mcp.tools.shell.command_executor import CommandExecutor
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@final
|
|
18
|
+
class RunCommandTool(ShellBaseTool):
|
|
19
|
+
"""Tool for executing shell commands."""
|
|
20
|
+
|
|
21
|
+
def __init__(self, permission_manager: Any, command_executor: CommandExecutor) -> None:
|
|
22
|
+
"""Initialize the run command tool.
|
|
23
|
+
|
|
24
|
+
Args:
|
|
25
|
+
permission_manager: Permission manager for access control
|
|
26
|
+
command_executor: Command executor for running commands
|
|
27
|
+
"""
|
|
28
|
+
super().__init__(permission_manager)
|
|
29
|
+
self.command_executor: CommandExecutor = command_executor
|
|
30
|
+
|
|
31
|
+
@property
|
|
32
|
+
@override
|
|
33
|
+
def name(self) -> str:
|
|
34
|
+
"""Get the tool name.
|
|
35
|
+
|
|
36
|
+
Returns:
|
|
37
|
+
Tool name
|
|
38
|
+
"""
|
|
39
|
+
return "run_command"
|
|
40
|
+
|
|
41
|
+
@property
|
|
42
|
+
@override
|
|
43
|
+
def description(self) -> str:
|
|
44
|
+
"""Get the tool description.
|
|
45
|
+
|
|
46
|
+
Returns:
|
|
47
|
+
Tool description
|
|
48
|
+
"""
|
|
49
|
+
return """Execute a shell command.
|
|
50
|
+
|
|
51
|
+
Args:
|
|
52
|
+
command: The shell command to execute
|
|
53
|
+
cwd: Working directory for the command. MUST be a subdirectory of one of the allowed paths, not a parent directory. Specify the most specific path possible.
|
|
54
|
+
|
|
55
|
+
use_login_shell: Whether to use login shell (loads ~/.zshrc, ~/.bashrc, etc.)
|
|
56
|
+
|
|
57
|
+
Returns:
|
|
58
|
+
The output of the command
|
|
59
|
+
"""
|
|
60
|
+
|
|
61
|
+
@property
|
|
62
|
+
@override
|
|
63
|
+
def parameters(self) -> dict[str, Any]:
|
|
64
|
+
"""Get the parameter specifications for the tool.
|
|
65
|
+
|
|
66
|
+
Returns:
|
|
67
|
+
Parameter specifications
|
|
68
|
+
"""
|
|
69
|
+
return {
|
|
70
|
+
"properties": {
|
|
71
|
+
"command": {
|
|
72
|
+
"title": "Command",
|
|
73
|
+
"type": "string"
|
|
74
|
+
},
|
|
75
|
+
"cwd": {
|
|
76
|
+
"title": "Cwd",
|
|
77
|
+
"type": "string"
|
|
78
|
+
},
|
|
79
|
+
"use_login_shell": {
|
|
80
|
+
"default": True,
|
|
81
|
+
"title": "Use Login Shell",
|
|
82
|
+
"type": "boolean"
|
|
83
|
+
}
|
|
84
|
+
},
|
|
85
|
+
"required": ["command", "cwd"],
|
|
86
|
+
"title": "run_commandArguments",
|
|
87
|
+
"type": "object"
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
@property
|
|
91
|
+
@override
|
|
92
|
+
def required(self) -> list[str]:
|
|
93
|
+
"""Get the list of required parameter names.
|
|
94
|
+
|
|
95
|
+
Returns:
|
|
96
|
+
List of required parameter names
|
|
97
|
+
"""
|
|
98
|
+
return ["command", "cwd"]
|
|
99
|
+
|
|
100
|
+
@override
|
|
101
|
+
async def prepare_tool_context(self, ctx: MCPContext) -> Any:
|
|
102
|
+
"""Create and prepare the tool context.
|
|
103
|
+
|
|
104
|
+
Args:
|
|
105
|
+
ctx: MCP context
|
|
106
|
+
|
|
107
|
+
Returns:
|
|
108
|
+
Prepared tool context
|
|
109
|
+
"""
|
|
110
|
+
tool_ctx = create_tool_context(ctx)
|
|
111
|
+
tool_ctx.set_tool_info(self.name)
|
|
112
|
+
return tool_ctx
|
|
113
|
+
|
|
114
|
+
@override
|
|
115
|
+
async def call(self, ctx: MCPContext, **params: Any) -> str:
|
|
116
|
+
"""Execute the tool with the given parameters.
|
|
117
|
+
|
|
118
|
+
Args:
|
|
119
|
+
ctx: MCP context
|
|
120
|
+
**params: Tool parameters
|
|
121
|
+
|
|
122
|
+
Returns:
|
|
123
|
+
Tool result
|
|
124
|
+
"""
|
|
125
|
+
tool_ctx = await self.prepare_tool_context(ctx)
|
|
126
|
+
|
|
127
|
+
# Extract parameters
|
|
128
|
+
command = params.get("command")
|
|
129
|
+
cwd = params.get("cwd")
|
|
130
|
+
use_login_shell = params.get("use_login_shell", True)
|
|
131
|
+
|
|
132
|
+
# Validate required parameters
|
|
133
|
+
if not command:
|
|
134
|
+
await tool_ctx.error("Parameter 'command' is required but was None")
|
|
135
|
+
return "Error: Parameter 'command' is required but was None"
|
|
136
|
+
|
|
137
|
+
if command.strip() == "":
|
|
138
|
+
await tool_ctx.error("Parameter 'command' cannot be empty")
|
|
139
|
+
return "Error: Parameter 'command' cannot be empty"
|
|
140
|
+
|
|
141
|
+
if not cwd:
|
|
142
|
+
await tool_ctx.error("Parameter 'cwd' is required but was None")
|
|
143
|
+
return "Error: Parameter 'cwd' is required but was None"
|
|
144
|
+
|
|
145
|
+
if cwd.strip() == "":
|
|
146
|
+
await tool_ctx.error("Parameter 'cwd' cannot be empty")
|
|
147
|
+
return "Error: Parameter 'cwd' cannot be empty"
|
|
148
|
+
|
|
149
|
+
await tool_ctx.info(f"Executing command: {command}")
|
|
150
|
+
|
|
151
|
+
# Check if command is allowed
|
|
152
|
+
if not self.command_executor.is_command_allowed(command):
|
|
153
|
+
await tool_ctx.error(f"Command not allowed: {command}")
|
|
154
|
+
return f"Error: Command not allowed: {command}"
|
|
155
|
+
|
|
156
|
+
# Check if working directory is allowed
|
|
157
|
+
if not self.is_path_allowed(cwd):
|
|
158
|
+
await tool_ctx.error(f"Working directory not allowed: {cwd}")
|
|
159
|
+
return f"Error: Working directory not allowed: {cwd}"
|
|
160
|
+
|
|
161
|
+
# Check if working directory exists
|
|
162
|
+
if not os.path.isdir(cwd):
|
|
163
|
+
await tool_ctx.error(f"Working directory does not exist: {cwd}")
|
|
164
|
+
return f"Error: Working directory does not exist: {cwd}"
|
|
165
|
+
|
|
166
|
+
# Execute the command
|
|
167
|
+
result = await self.command_executor.execute_command(
|
|
168
|
+
command,
|
|
169
|
+
cwd=cwd,
|
|
170
|
+
timeout=30.0,
|
|
171
|
+
use_login_shell=use_login_shell
|
|
172
|
+
)
|
|
173
|
+
|
|
174
|
+
# Report result
|
|
175
|
+
if result.is_success:
|
|
176
|
+
await tool_ctx.info("Command executed successfully")
|
|
177
|
+
else:
|
|
178
|
+
await tool_ctx.error(f"Command failed with exit code {result.return_code}")
|
|
179
|
+
|
|
180
|
+
# Format the result
|
|
181
|
+
if result.is_success:
|
|
182
|
+
# For successful commands, just return stdout unless stderr has content
|
|
183
|
+
if result.stderr:
|
|
184
|
+
return f"Command executed successfully.\n\nSTDOUT:\n{result.stdout}\n\nSTDERR:\n{result.stderr}"
|
|
185
|
+
return result.stdout
|
|
186
|
+
else:
|
|
187
|
+
# For failed commands, include all available information
|
|
188
|
+
return result.format_output()
|
|
189
|
+
|
|
190
|
+
@override
|
|
191
|
+
def register(self, mcp_server: FastMCP) -> None:
|
|
192
|
+
"""Register this run command tool with the MCP server.
|
|
193
|
+
|
|
194
|
+
Creates a wrapper function with explicitly defined parameters that match
|
|
195
|
+
the tool's parameter schema and registers it with the MCP server.
|
|
196
|
+
|
|
197
|
+
Args:
|
|
198
|
+
mcp_server: The FastMCP server instance
|
|
199
|
+
"""
|
|
200
|
+
tool_self = self # Create a reference to self for use in the closure
|
|
201
|
+
|
|
202
|
+
@mcp_server.tool(name=self.name, description=self.mcp_description)
|
|
203
|
+
async def run_command(ctx: MCPContext, command: str, cwd: str, use_login_shell: bool = True) -> str:
|
|
204
|
+
return await tool_self.call(ctx, command=command, cwd=cwd, use_login_shell=use_login_shell)
|
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
"""Run script tool implementation.
|
|
2
|
+
|
|
3
|
+
This module provides the RunScriptTool for executing scripts with interpreters.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
import os
|
|
7
|
+
from typing import Any, final, override
|
|
8
|
+
|
|
9
|
+
from mcp.server.fastmcp import Context as MCPContext
|
|
10
|
+
from mcp.server.fastmcp import FastMCP
|
|
11
|
+
|
|
12
|
+
from hanzo_mcp.tools.common.context import create_tool_context
|
|
13
|
+
from hanzo_mcp.tools.shell.base import ShellBaseTool
|
|
14
|
+
from hanzo_mcp.tools.shell.command_executor import CommandExecutor
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@final
|
|
18
|
+
class RunScriptTool(ShellBaseTool):
|
|
19
|
+
"""Tool for executing scripts with interpreters."""
|
|
20
|
+
|
|
21
|
+
def __init__(self, permission_manager: Any, command_executor: CommandExecutor) -> None:
|
|
22
|
+
"""Initialize the run script tool.
|
|
23
|
+
|
|
24
|
+
Args:
|
|
25
|
+
permission_manager: Permission manager for access control
|
|
26
|
+
command_executor: Command executor for running scripts
|
|
27
|
+
"""
|
|
28
|
+
super().__init__(permission_manager)
|
|
29
|
+
self.command_executor: CommandExecutor = command_executor
|
|
30
|
+
|
|
31
|
+
@property
|
|
32
|
+
@override
|
|
33
|
+
def name(self) -> str:
|
|
34
|
+
"""Get the tool name.
|
|
35
|
+
|
|
36
|
+
Returns:
|
|
37
|
+
Tool name
|
|
38
|
+
"""
|
|
39
|
+
return "run_script"
|
|
40
|
+
|
|
41
|
+
@property
|
|
42
|
+
@override
|
|
43
|
+
def description(self) -> str:
|
|
44
|
+
"""Get the tool description.
|
|
45
|
+
|
|
46
|
+
Returns:
|
|
47
|
+
Tool description
|
|
48
|
+
"""
|
|
49
|
+
return """Execute a script with the specified interpreter.
|
|
50
|
+
|
|
51
|
+
Args:
|
|
52
|
+
script: The script content to execute
|
|
53
|
+
cwd: Working directory for script execution. MUST be a subdirectory of one of the allowed paths, not a parent directory. Specify the most specific path possible.
|
|
54
|
+
|
|
55
|
+
interpreter: The interpreter to use (bash, python, etc.)
|
|
56
|
+
use_login_shell: Whether to use login shell (loads ~/.zshrc, ~/.bashrc, etc.)
|
|
57
|
+
|
|
58
|
+
Returns:
|
|
59
|
+
The output of the script
|
|
60
|
+
"""
|
|
61
|
+
|
|
62
|
+
@property
|
|
63
|
+
@override
|
|
64
|
+
def parameters(self) -> dict[str, Any]:
|
|
65
|
+
"""Get the parameter specifications for the tool.
|
|
66
|
+
|
|
67
|
+
Returns:
|
|
68
|
+
Parameter specifications
|
|
69
|
+
"""
|
|
70
|
+
return {
|
|
71
|
+
"properties": {
|
|
72
|
+
"script": {
|
|
73
|
+
"title": "Script",
|
|
74
|
+
"type": "string"
|
|
75
|
+
},
|
|
76
|
+
"cwd": {
|
|
77
|
+
"title": "Cwd",
|
|
78
|
+
"type": "string"
|
|
79
|
+
},
|
|
80
|
+
"interpreter": {
|
|
81
|
+
"default": "bash",
|
|
82
|
+
"title": "Interpreter",
|
|
83
|
+
"type": "string"
|
|
84
|
+
},
|
|
85
|
+
"use_login_shell": {
|
|
86
|
+
"default": True,
|
|
87
|
+
"title": "Use Login Shell",
|
|
88
|
+
"type": "boolean"
|
|
89
|
+
}
|
|
90
|
+
},
|
|
91
|
+
"required": ["script", "cwd"],
|
|
92
|
+
"title": "run_scriptArguments",
|
|
93
|
+
"type": "object"
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
@property
|
|
97
|
+
@override
|
|
98
|
+
def required(self) -> list[str]:
|
|
99
|
+
"""Get the list of required parameter names.
|
|
100
|
+
|
|
101
|
+
Returns:
|
|
102
|
+
List of required parameter names
|
|
103
|
+
"""
|
|
104
|
+
return ["script", "cwd"]
|
|
105
|
+
|
|
106
|
+
@override
|
|
107
|
+
async def prepare_tool_context(self, ctx: MCPContext) -> Any:
|
|
108
|
+
"""Create and prepare the tool context.
|
|
109
|
+
|
|
110
|
+
Args:
|
|
111
|
+
ctx: MCP context
|
|
112
|
+
|
|
113
|
+
Returns:
|
|
114
|
+
Prepared tool context
|
|
115
|
+
"""
|
|
116
|
+
tool_ctx = create_tool_context(ctx)
|
|
117
|
+
tool_ctx.set_tool_info(self.name)
|
|
118
|
+
return tool_ctx
|
|
119
|
+
|
|
120
|
+
@override
|
|
121
|
+
async def call(self, ctx: MCPContext, **params: Any) -> str:
|
|
122
|
+
"""Execute the tool with the given parameters.
|
|
123
|
+
|
|
124
|
+
Args:
|
|
125
|
+
ctx: MCP context
|
|
126
|
+
**params: Tool parameters
|
|
127
|
+
|
|
128
|
+
Returns:
|
|
129
|
+
Tool result
|
|
130
|
+
"""
|
|
131
|
+
tool_ctx = await self.prepare_tool_context(ctx)
|
|
132
|
+
|
|
133
|
+
# Extract parameters
|
|
134
|
+
script = params.get("script")
|
|
135
|
+
cwd = params.get("cwd")
|
|
136
|
+
interpreter = params.get("interpreter", "bash")
|
|
137
|
+
use_login_shell = params.get("use_login_shell", True)
|
|
138
|
+
|
|
139
|
+
# Validate required parameters
|
|
140
|
+
if not script:
|
|
141
|
+
await tool_ctx.error("Parameter 'script' is required but was None")
|
|
142
|
+
return "Error: Parameter 'script' is required but was None"
|
|
143
|
+
|
|
144
|
+
if script.strip() == "":
|
|
145
|
+
await tool_ctx.error("Parameter 'script' cannot be empty")
|
|
146
|
+
return "Error: Parameter 'script' cannot be empty"
|
|
147
|
+
|
|
148
|
+
# Validate interpreter
|
|
149
|
+
if not interpreter:
|
|
150
|
+
interpreter = "bash" # Use default if None
|
|
151
|
+
elif interpreter.strip() == "":
|
|
152
|
+
await tool_ctx.error("Parameter 'interpreter' cannot be empty")
|
|
153
|
+
return "Error: Parameter 'interpreter' cannot be empty"
|
|
154
|
+
|
|
155
|
+
# Validate required cwd parameter
|
|
156
|
+
if not cwd:
|
|
157
|
+
await tool_ctx.error("Parameter 'cwd' is required but was None")
|
|
158
|
+
return "Error: Parameter 'cwd' is required but was None"
|
|
159
|
+
|
|
160
|
+
if cwd.strip() == "":
|
|
161
|
+
await tool_ctx.error("Parameter 'cwd' cannot be empty")
|
|
162
|
+
return "Error: Parameter 'cwd' cannot be empty"
|
|
163
|
+
|
|
164
|
+
await tool_ctx.info(f"Executing script with interpreter: {interpreter}")
|
|
165
|
+
|
|
166
|
+
# Check if working directory is allowed
|
|
167
|
+
if not self.is_path_allowed(cwd):
|
|
168
|
+
await tool_ctx.error(f"Working directory not allowed: {cwd}")
|
|
169
|
+
return f"Error: Working directory not allowed: {cwd}"
|
|
170
|
+
|
|
171
|
+
# Check if working directory exists
|
|
172
|
+
if not os.path.isdir(cwd):
|
|
173
|
+
await tool_ctx.error(f"Working directory does not exist: {cwd}")
|
|
174
|
+
return f"Error: Working directory does not exist: {cwd}"
|
|
175
|
+
|
|
176
|
+
# Execute the script
|
|
177
|
+
result = await self.command_executor.execute_script(
|
|
178
|
+
script=script,
|
|
179
|
+
interpreter=interpreter,
|
|
180
|
+
cwd=cwd,
|
|
181
|
+
timeout=30.0,
|
|
182
|
+
use_login_shell=use_login_shell
|
|
183
|
+
)
|
|
184
|
+
|
|
185
|
+
# Report result
|
|
186
|
+
if result.is_success:
|
|
187
|
+
await tool_ctx.info("Script executed successfully")
|
|
188
|
+
else:
|
|
189
|
+
await tool_ctx.error(f"Script execution failed with exit code {result.return_code}")
|
|
190
|
+
|
|
191
|
+
# Format the result
|
|
192
|
+
if result.is_success:
|
|
193
|
+
# For successful scripts, just return stdout unless stderr has content
|
|
194
|
+
if result.stderr:
|
|
195
|
+
return f"Script executed successfully.\n\nSTDOUT:\n{result.stdout}\n\nSTDERR:\n{result.stderr}"
|
|
196
|
+
return result.stdout
|
|
197
|
+
else:
|
|
198
|
+
# For failed scripts, include all available information
|
|
199
|
+
return result.format_output()
|
|
200
|
+
|
|
201
|
+
@override
|
|
202
|
+
def register(self, mcp_server: FastMCP) -> None:
|
|
203
|
+
"""Register this run script tool with the MCP server.
|
|
204
|
+
|
|
205
|
+
Creates a wrapper function with explicitly defined parameters that match
|
|
206
|
+
the tool's parameter schema and registers it with the MCP server.
|
|
207
|
+
|
|
208
|
+
Args:
|
|
209
|
+
mcp_server: The FastMCP server instance
|
|
210
|
+
"""
|
|
211
|
+
tool_self = self # Create a reference to self for use in the closure
|
|
212
|
+
|
|
213
|
+
@mcp_server.tool(name=self.name, description=self.mcp_description)
|
|
214
|
+
async def run_script(ctx: MCPContext, script: str, cwd: str, interpreter: str = "bash", use_login_shell: bool = True) -> str:
|
|
215
|
+
return await tool_self.call(ctx, script=script, cwd=cwd, interpreter=interpreter, use_login_shell=use_login_shell)
|
|
@@ -0,0 +1,244 @@
|
|
|
1
|
+
"""Script tool implementation.
|
|
2
|
+
|
|
3
|
+
This module provides the ScriptTool for executing scripts in various languages.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
import os
|
|
7
|
+
from typing import Any, final, override
|
|
8
|
+
|
|
9
|
+
from mcp.server.fastmcp import Context as MCPContext
|
|
10
|
+
from mcp.server.fastmcp import FastMCP
|
|
11
|
+
|
|
12
|
+
from hanzo_mcp.tools.common.context import create_tool_context
|
|
13
|
+
from hanzo_mcp.tools.shell.base import ShellBaseTool
|
|
14
|
+
from hanzo_mcp.tools.shell.command_executor import CommandExecutor
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@final
|
|
18
|
+
class ScriptTool(ShellBaseTool):
|
|
19
|
+
"""Tool for executing scripts in various languages."""
|
|
20
|
+
|
|
21
|
+
def __init__(self, permission_manager: Any, command_executor: CommandExecutor) -> None:
|
|
22
|
+
"""Initialize the script tool.
|
|
23
|
+
|
|
24
|
+
Args:
|
|
25
|
+
permission_manager: Permission manager for access control
|
|
26
|
+
command_executor: Command executor for running scripts
|
|
27
|
+
"""
|
|
28
|
+
super().__init__(permission_manager)
|
|
29
|
+
self.command_executor: CommandExecutor = command_executor
|
|
30
|
+
|
|
31
|
+
@property
|
|
32
|
+
@override
|
|
33
|
+
def name(self) -> str:
|
|
34
|
+
"""Get the tool name.
|
|
35
|
+
|
|
36
|
+
Returns:
|
|
37
|
+
Tool name
|
|
38
|
+
"""
|
|
39
|
+
return "script_tool"
|
|
40
|
+
|
|
41
|
+
@property
|
|
42
|
+
@override
|
|
43
|
+
def description(self) -> str:
|
|
44
|
+
"""Get the tool description.
|
|
45
|
+
|
|
46
|
+
Returns:
|
|
47
|
+
Tool description
|
|
48
|
+
"""
|
|
49
|
+
return """Execute a script in the specified language.
|
|
50
|
+
|
|
51
|
+
Args:
|
|
52
|
+
language: The programming language (python, javascript, etc.)
|
|
53
|
+
script: The script code to execute
|
|
54
|
+
cwd: Working directory for script execution. MUST be a subdirectory of one of the allowed paths, not a parent directory. Specify the most specific path possible.
|
|
55
|
+
|
|
56
|
+
args: Optional command-line arguments
|
|
57
|
+
use_login_shell: Whether to use login shell (loads ~/.zshrc, ~/.bashrc, etc.)
|
|
58
|
+
|
|
59
|
+
Returns:
|
|
60
|
+
Script execution results
|
|
61
|
+
"""
|
|
62
|
+
|
|
63
|
+
@property
|
|
64
|
+
@override
|
|
65
|
+
def parameters(self) -> dict[str, Any]:
|
|
66
|
+
"""Get the parameter specifications for the tool.
|
|
67
|
+
|
|
68
|
+
Returns:
|
|
69
|
+
Parameter specifications
|
|
70
|
+
"""
|
|
71
|
+
return {
|
|
72
|
+
"properties": {
|
|
73
|
+
"language": {
|
|
74
|
+
"title": "Language",
|
|
75
|
+
"type": "string"
|
|
76
|
+
},
|
|
77
|
+
"script": {
|
|
78
|
+
"title": "Script",
|
|
79
|
+
"type": "string"
|
|
80
|
+
},
|
|
81
|
+
"cwd": {
|
|
82
|
+
"title": "Cwd",
|
|
83
|
+
"type": "string"
|
|
84
|
+
},
|
|
85
|
+
"args": {
|
|
86
|
+
"anyOf": [
|
|
87
|
+
{"items": {"type": "string"}, "type": "array"},
|
|
88
|
+
{"type": "null"}
|
|
89
|
+
],
|
|
90
|
+
"default": None,
|
|
91
|
+
"title": "Args"
|
|
92
|
+
},
|
|
93
|
+
"use_login_shell": {
|
|
94
|
+
"default": True,
|
|
95
|
+
"title": "Use Login Shell",
|
|
96
|
+
"type": "boolean"
|
|
97
|
+
}
|
|
98
|
+
},
|
|
99
|
+
"required": ["language", "script", "cwd"],
|
|
100
|
+
"title": "script_toolArguments",
|
|
101
|
+
"type": "object"
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
@property
|
|
105
|
+
@override
|
|
106
|
+
def required(self) -> list[str]:
|
|
107
|
+
"""Get the list of required parameter names.
|
|
108
|
+
|
|
109
|
+
Returns:
|
|
110
|
+
List of required parameter names
|
|
111
|
+
"""
|
|
112
|
+
return ["language", "script", "cwd"]
|
|
113
|
+
|
|
114
|
+
@override
|
|
115
|
+
async def prepare_tool_context(self, ctx: MCPContext) -> Any:
|
|
116
|
+
"""Create and prepare the tool context.
|
|
117
|
+
|
|
118
|
+
Args:
|
|
119
|
+
ctx: MCP context
|
|
120
|
+
|
|
121
|
+
Returns:
|
|
122
|
+
Prepared tool context
|
|
123
|
+
"""
|
|
124
|
+
tool_ctx = create_tool_context(ctx)
|
|
125
|
+
tool_ctx.set_tool_info(self.name)
|
|
126
|
+
return tool_ctx
|
|
127
|
+
|
|
128
|
+
@override
|
|
129
|
+
async def call(self, ctx: MCPContext, **params: Any) -> str:
|
|
130
|
+
"""Execute the tool with the given parameters.
|
|
131
|
+
|
|
132
|
+
Args:
|
|
133
|
+
ctx: MCP context
|
|
134
|
+
**params: Tool parameters
|
|
135
|
+
|
|
136
|
+
Returns:
|
|
137
|
+
Tool result
|
|
138
|
+
"""
|
|
139
|
+
tool_ctx = await self.prepare_tool_context(ctx)
|
|
140
|
+
|
|
141
|
+
# Extract parameters
|
|
142
|
+
language = params.get("language")
|
|
143
|
+
script = params.get("script")
|
|
144
|
+
cwd = params.get("cwd")
|
|
145
|
+
args = params.get("args")
|
|
146
|
+
use_login_shell = params.get("use_login_shell", True)
|
|
147
|
+
|
|
148
|
+
# Validate required parameters
|
|
149
|
+
if not language:
|
|
150
|
+
await tool_ctx.error("Parameter 'language' is required but was None")
|
|
151
|
+
return "Error: Parameter 'language' is required but was None"
|
|
152
|
+
|
|
153
|
+
if language.strip() == "":
|
|
154
|
+
await tool_ctx.error("Parameter 'language' cannot be empty")
|
|
155
|
+
return "Error: Parameter 'language' cannot be empty"
|
|
156
|
+
|
|
157
|
+
if not script:
|
|
158
|
+
await tool_ctx.error("Parameter 'script' is required but was None")
|
|
159
|
+
return "Error: Parameter 'script' is required but was None"
|
|
160
|
+
|
|
161
|
+
if script.strip() == "":
|
|
162
|
+
await tool_ctx.error("Parameter 'script' cannot be empty")
|
|
163
|
+
return "Error: Parameter 'script' cannot be empty"
|
|
164
|
+
|
|
165
|
+
# args can be None as it's optional
|
|
166
|
+
# Check for empty list but still allow None
|
|
167
|
+
if args is not None and len(args) == 0:
|
|
168
|
+
await tool_ctx.warning("Parameter 'args' is an empty list")
|
|
169
|
+
# We don't return error for this as empty args is acceptable
|
|
170
|
+
|
|
171
|
+
# Validate required cwd parameter
|
|
172
|
+
if not cwd:
|
|
173
|
+
await tool_ctx.error("Parameter 'cwd' is required but was None")
|
|
174
|
+
return "Error: Parameter 'cwd' is required but was None"
|
|
175
|
+
|
|
176
|
+
if cwd.strip() == "":
|
|
177
|
+
await tool_ctx.error("Parameter 'cwd' cannot be empty")
|
|
178
|
+
return "Error: Parameter 'cwd' cannot be empty"
|
|
179
|
+
|
|
180
|
+
await tool_ctx.info(f"Executing {language} script")
|
|
181
|
+
|
|
182
|
+
# Check if the language is supported
|
|
183
|
+
if language not in self.command_executor.get_available_languages():
|
|
184
|
+
await tool_ctx.error(f"Unsupported language: {language}")
|
|
185
|
+
return f"Error: Unsupported language: {language}. Supported languages: {', '.join(self.command_executor.get_available_languages())}"
|
|
186
|
+
|
|
187
|
+
# Check if working directory is allowed
|
|
188
|
+
if not self.is_path_allowed(cwd):
|
|
189
|
+
await tool_ctx.error(f"Working directory not allowed: {cwd}")
|
|
190
|
+
return f"Error: Working directory not allowed: {cwd}"
|
|
191
|
+
|
|
192
|
+
# Check if working directory exists
|
|
193
|
+
if not os.path.isdir(cwd):
|
|
194
|
+
await tool_ctx.error(f"Working directory does not exist: {cwd}")
|
|
195
|
+
return f"Error: Working directory does not exist: {cwd}"
|
|
196
|
+
|
|
197
|
+
# Execute the script
|
|
198
|
+
result = await self.command_executor.execute_script_from_file(
|
|
199
|
+
script=script,
|
|
200
|
+
language=language,
|
|
201
|
+
cwd=cwd,
|
|
202
|
+
timeout=30.0,
|
|
203
|
+
args=args,
|
|
204
|
+
use_login_shell=use_login_shell
|
|
205
|
+
)
|
|
206
|
+
|
|
207
|
+
# Report result
|
|
208
|
+
if result.is_success:
|
|
209
|
+
await tool_ctx.info(f"{language} script executed successfully")
|
|
210
|
+
else:
|
|
211
|
+
await tool_ctx.error(
|
|
212
|
+
f"{language} script execution failed with exit code {result.return_code}"
|
|
213
|
+
)
|
|
214
|
+
|
|
215
|
+
# Format the result
|
|
216
|
+
if result.is_success:
|
|
217
|
+
# Format the successful result
|
|
218
|
+
output = f"{language} script executed successfully.\n\n"
|
|
219
|
+
if result.stdout:
|
|
220
|
+
output += f"STDOUT:\n{result.stdout}\n\n"
|
|
221
|
+
if result.stderr:
|
|
222
|
+
output += f"STDERR:\n{result.stderr}"
|
|
223
|
+
return output.strip()
|
|
224
|
+
else:
|
|
225
|
+
# For failed scripts, include all available information
|
|
226
|
+
return result.format_output()
|
|
227
|
+
|
|
228
|
+
@override
|
|
229
|
+
def register(self, mcp_server: FastMCP) -> None:
|
|
230
|
+
"""Register this script tool with the MCP server.
|
|
231
|
+
|
|
232
|
+
Creates a wrapper function with explicitly defined parameters that match
|
|
233
|
+
the tool's parameter schema and registers it with the MCP server.
|
|
234
|
+
|
|
235
|
+
Args:
|
|
236
|
+
mcp_server: The FastMCP server instance
|
|
237
|
+
"""
|
|
238
|
+
tool_self = self # Create a reference to self for use in the closure
|
|
239
|
+
|
|
240
|
+
@mcp_server.tool(name=self.name, description=self.mcp_description)
|
|
241
|
+
async def script_tool(ctx: MCPContext, language: str, script: str, cwd: str, args: list[str] | None = None,
|
|
242
|
+
use_login_shell: bool = True) -> str:
|
|
243
|
+
return await tool_self.call(ctx, language=language, script=script, cwd=cwd,
|
|
244
|
+
args=args, use_login_shell=use_login_shell)
|