hanzo-mcp 0.1.20__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 +3 -0
- hanzo_mcp/cli.py +213 -0
- hanzo_mcp/server.py +149 -0
- hanzo_mcp/tools/__init__.py +81 -0
- 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 -0
- hanzo_mcp/tools/common/base.py +216 -0
- hanzo_mcp/tools/common/context.py +444 -0
- hanzo_mcp/tools/common/permissions.py +253 -0
- hanzo_mcp/tools/common/thinking_tool.py +123 -0
- hanzo_mcp/tools/common/validation.py +124 -0
- hanzo_mcp/tools/filesystem/__init__.py +89 -0
- 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 +71 -0
- hanzo_mcp/tools/jupyter/base.py +284 -0
- hanzo_mcp/tools/jupyter/edit_notebook.py +295 -0
- hanzo_mcp/tools/jupyter/notebook_operations.py +514 -0
- hanzo_mcp/tools/jupyter/read_notebook.py +165 -0
- hanzo_mcp/tools/project/__init__.py +64 -0
- hanzo_mcp/tools/project/analysis.py +882 -0
- hanzo_mcp/tools/project/base.py +66 -0
- hanzo_mcp/tools/project/project_analyze.py +173 -0
- hanzo_mcp/tools/shell/__init__.py +58 -0
- hanzo_mcp/tools/shell/base.py +148 -0
- hanzo_mcp/tools/shell/command_executor.py +740 -0
- 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.20.dist-info/METADATA +111 -0
- hanzo_mcp-0.1.20.dist-info/RECORD +44 -0
- hanzo_mcp-0.1.20.dist-info/WHEEL +5 -0
- hanzo_mcp-0.1.20.dist-info/entry_points.txt +2 -0
- hanzo_mcp-0.1.20.dist-info/licenses/LICENSE +21 -0
- hanzo_mcp-0.1.20.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,253 @@
|
|
|
1
|
+
"""Permission system for the Hanzo MCP server."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import os
|
|
5
|
+
from collections.abc import Awaitable, Callable
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Any, TypeVar, final
|
|
8
|
+
|
|
9
|
+
# Define type variables for better type annotations
|
|
10
|
+
T = TypeVar("T")
|
|
11
|
+
P = TypeVar("P")
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@final
|
|
15
|
+
class PermissionManager:
|
|
16
|
+
"""Manages permissions for file and command operations."""
|
|
17
|
+
|
|
18
|
+
def __init__(self) -> None:
|
|
19
|
+
"""Initialize the permission manager."""
|
|
20
|
+
# Allowed paths
|
|
21
|
+
self.allowed_paths: set[Path] = set(
|
|
22
|
+
[Path("/tmp").resolve(), Path("/var").resolve()]
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
# Excluded paths
|
|
26
|
+
self.excluded_paths: set[Path] = set()
|
|
27
|
+
self.excluded_patterns: list[str] = []
|
|
28
|
+
|
|
29
|
+
# Default excluded patterns
|
|
30
|
+
self._add_default_exclusions()
|
|
31
|
+
|
|
32
|
+
def _add_default_exclusions(self) -> None:
|
|
33
|
+
"""Add default exclusions for sensitive files and directories."""
|
|
34
|
+
# Sensitive directories
|
|
35
|
+
sensitive_dirs: list[str] = [
|
|
36
|
+
# ".git" is now allowed by default
|
|
37
|
+
".ssh",
|
|
38
|
+
".gnupg",
|
|
39
|
+
".config",
|
|
40
|
+
"node_modules",
|
|
41
|
+
"__pycache__",
|
|
42
|
+
".venv",
|
|
43
|
+
"venv",
|
|
44
|
+
"env",
|
|
45
|
+
".idea",
|
|
46
|
+
".vscode",
|
|
47
|
+
".DS_Store",
|
|
48
|
+
]
|
|
49
|
+
self.excluded_patterns.extend(sensitive_dirs)
|
|
50
|
+
|
|
51
|
+
# Sensitive file patterns
|
|
52
|
+
sensitive_patterns: list[str] = [
|
|
53
|
+
".env",
|
|
54
|
+
"*.key",
|
|
55
|
+
"*.pem",
|
|
56
|
+
"*.crt",
|
|
57
|
+
"*password*",
|
|
58
|
+
"*secret*",
|
|
59
|
+
"*.sqlite",
|
|
60
|
+
"*.db",
|
|
61
|
+
"*.sqlite3",
|
|
62
|
+
"*.log",
|
|
63
|
+
]
|
|
64
|
+
self.excluded_patterns.extend(sensitive_patterns)
|
|
65
|
+
|
|
66
|
+
def add_allowed_path(self, path: str) -> None:
|
|
67
|
+
"""Add a path to the allowed paths.
|
|
68
|
+
|
|
69
|
+
Args:
|
|
70
|
+
path: The path to allow
|
|
71
|
+
"""
|
|
72
|
+
resolved_path: Path = Path(path).resolve()
|
|
73
|
+
self.allowed_paths.add(resolved_path)
|
|
74
|
+
|
|
75
|
+
def remove_allowed_path(self, path: str) -> None:
|
|
76
|
+
"""Remove a path from the allowed paths.
|
|
77
|
+
|
|
78
|
+
Args:
|
|
79
|
+
path: The path to remove
|
|
80
|
+
"""
|
|
81
|
+
resolved_path: Path = Path(path).resolve()
|
|
82
|
+
if resolved_path in self.allowed_paths:
|
|
83
|
+
self.allowed_paths.remove(resolved_path)
|
|
84
|
+
|
|
85
|
+
def exclude_path(self, path: str) -> None:
|
|
86
|
+
"""Exclude a path from allowed operations.
|
|
87
|
+
|
|
88
|
+
Args:
|
|
89
|
+
path: The path to exclude
|
|
90
|
+
"""
|
|
91
|
+
resolved_path: Path = Path(path).resolve()
|
|
92
|
+
self.excluded_paths.add(resolved_path)
|
|
93
|
+
|
|
94
|
+
def add_exclusion_pattern(self, pattern: str) -> None:
|
|
95
|
+
"""Add an exclusion pattern.
|
|
96
|
+
|
|
97
|
+
Args:
|
|
98
|
+
pattern: The pattern to exclude
|
|
99
|
+
"""
|
|
100
|
+
self.excluded_patterns.append(pattern)
|
|
101
|
+
|
|
102
|
+
def is_path_allowed(self, path: str) -> bool:
|
|
103
|
+
"""Check if a path is allowed.
|
|
104
|
+
|
|
105
|
+
Args:
|
|
106
|
+
path: The path to check
|
|
107
|
+
|
|
108
|
+
Returns:
|
|
109
|
+
True if the path is allowed, False otherwise
|
|
110
|
+
"""
|
|
111
|
+
resolved_path: Path = Path(path).resolve()
|
|
112
|
+
|
|
113
|
+
# Check exclusions first
|
|
114
|
+
if self._is_path_excluded(resolved_path):
|
|
115
|
+
return False
|
|
116
|
+
|
|
117
|
+
# Check if the path is within any allowed path
|
|
118
|
+
for allowed_path in self.allowed_paths:
|
|
119
|
+
try:
|
|
120
|
+
resolved_path.relative_to(allowed_path)
|
|
121
|
+
return True
|
|
122
|
+
except ValueError:
|
|
123
|
+
continue
|
|
124
|
+
|
|
125
|
+
return False
|
|
126
|
+
|
|
127
|
+
def _is_path_excluded(self, path: Path) -> bool:
|
|
128
|
+
"""Check if a path is excluded.
|
|
129
|
+
|
|
130
|
+
Args:
|
|
131
|
+
path: The path to check
|
|
132
|
+
|
|
133
|
+
Returns:
|
|
134
|
+
True if the path is excluded, False otherwise
|
|
135
|
+
"""
|
|
136
|
+
|
|
137
|
+
# Check exact excluded paths
|
|
138
|
+
if path in self.excluded_paths:
|
|
139
|
+
return True
|
|
140
|
+
|
|
141
|
+
# Check excluded patterns
|
|
142
|
+
path_str: str = str(path)
|
|
143
|
+
|
|
144
|
+
# Get path parts to check for exact directory/file name matches
|
|
145
|
+
path_parts = path_str.split(os.sep)
|
|
146
|
+
|
|
147
|
+
for pattern in self.excluded_patterns:
|
|
148
|
+
# Handle wildcard patterns (e.g., "*.log")
|
|
149
|
+
if pattern.startswith("*"):
|
|
150
|
+
if path_str.endswith(pattern[1:]):
|
|
151
|
+
return True
|
|
152
|
+
else:
|
|
153
|
+
# For non-wildcard patterns, check if any path component matches exactly
|
|
154
|
+
if pattern in path_parts:
|
|
155
|
+
return True
|
|
156
|
+
|
|
157
|
+
return False
|
|
158
|
+
|
|
159
|
+
def to_json(self) -> str:
|
|
160
|
+
"""Convert the permission manager to a JSON string.
|
|
161
|
+
|
|
162
|
+
Returns:
|
|
163
|
+
A JSON string representation of the permission manager
|
|
164
|
+
"""
|
|
165
|
+
data: dict[str, Any] = {
|
|
166
|
+
"allowed_paths": [str(p) for p in self.allowed_paths],
|
|
167
|
+
"excluded_paths": [str(p) for p in self.excluded_paths],
|
|
168
|
+
"excluded_patterns": self.excluded_patterns,
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
return json.dumps(data)
|
|
172
|
+
|
|
173
|
+
@classmethod
|
|
174
|
+
def from_json(cls, json_str: str) -> "PermissionManager":
|
|
175
|
+
"""Create a permission manager from a JSON string.
|
|
176
|
+
|
|
177
|
+
Args:
|
|
178
|
+
json_str: The JSON string
|
|
179
|
+
|
|
180
|
+
Returns:
|
|
181
|
+
A new PermissionManager instance
|
|
182
|
+
"""
|
|
183
|
+
data: dict[str, Any] = json.loads(json_str)
|
|
184
|
+
|
|
185
|
+
manager = cls()
|
|
186
|
+
|
|
187
|
+
for path in data.get("allowed_paths", []):
|
|
188
|
+
manager.add_allowed_path(path)
|
|
189
|
+
|
|
190
|
+
for path in data.get("excluded_paths", []):
|
|
191
|
+
manager.exclude_path(path)
|
|
192
|
+
|
|
193
|
+
manager.excluded_patterns = data.get("excluded_patterns", [])
|
|
194
|
+
|
|
195
|
+
return manager
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
class PermissibleOperation:
|
|
199
|
+
"""A decorator for operations that require permission."""
|
|
200
|
+
|
|
201
|
+
def __init__(
|
|
202
|
+
self,
|
|
203
|
+
permission_manager: PermissionManager,
|
|
204
|
+
operation: str,
|
|
205
|
+
get_path_fn: Callable[[list[Any], dict[str, Any]], str] | None = None,
|
|
206
|
+
) -> None:
|
|
207
|
+
"""Initialize the permissible operation.
|
|
208
|
+
|
|
209
|
+
Args:
|
|
210
|
+
permission_manager: The permission manager
|
|
211
|
+
operation: The operation type (read, write, execute, etc.)
|
|
212
|
+
get_path_fn: Optional function to extract the path from args and kwargs
|
|
213
|
+
"""
|
|
214
|
+
self.permission_manager: PermissionManager = permission_manager
|
|
215
|
+
self.operation: str = operation
|
|
216
|
+
self.get_path_fn: Callable[[list[Any], dict[str, Any]], str] | None = (
|
|
217
|
+
get_path_fn
|
|
218
|
+
)
|
|
219
|
+
|
|
220
|
+
def __call__(
|
|
221
|
+
self, func: Callable[..., Awaitable[T]]
|
|
222
|
+
) -> Callable[..., Awaitable[T]]:
|
|
223
|
+
"""Decorate the function.
|
|
224
|
+
|
|
225
|
+
Args:
|
|
226
|
+
func: The function to decorate
|
|
227
|
+
|
|
228
|
+
Returns:
|
|
229
|
+
The decorated function
|
|
230
|
+
"""
|
|
231
|
+
|
|
232
|
+
async def wrapper(*args: Any, **kwargs: Any) -> T:
|
|
233
|
+
# Extract the path
|
|
234
|
+
if self.get_path_fn:
|
|
235
|
+
# Pass args as a list and kwargs as a dict to the path function
|
|
236
|
+
path = self.get_path_fn(list(args), kwargs)
|
|
237
|
+
else:
|
|
238
|
+
# Default to first argument
|
|
239
|
+
path = args[0] if args else next(iter(kwargs.values()), None)
|
|
240
|
+
|
|
241
|
+
if not isinstance(path, str):
|
|
242
|
+
raise ValueError(f"Invalid path type: {type(path)}")
|
|
243
|
+
|
|
244
|
+
# Check permission
|
|
245
|
+
if not self.permission_manager.is_path_allowed(path):
|
|
246
|
+
raise PermissionError(
|
|
247
|
+
f"Operation '{self.operation}' not allowed for path: {path}"
|
|
248
|
+
)
|
|
249
|
+
|
|
250
|
+
# Call the function
|
|
251
|
+
return await func(*args, **kwargs)
|
|
252
|
+
|
|
253
|
+
return wrapper
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
"""Thinking tool implementation.
|
|
2
|
+
|
|
3
|
+
This module provides the ThinkingTool for Claude to engage in structured thinking.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from typing import Any, final, override
|
|
7
|
+
|
|
8
|
+
from mcp.server.fastmcp import Context as MCPContext
|
|
9
|
+
from mcp.server.fastmcp import FastMCP
|
|
10
|
+
|
|
11
|
+
from hanzo_mcp.tools.common.base import BaseTool
|
|
12
|
+
from hanzo_mcp.tools.common.context import create_tool_context
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@final
|
|
16
|
+
class ThinkingTool(BaseTool):
|
|
17
|
+
"""Tool for Claude to engage in structured thinking."""
|
|
18
|
+
|
|
19
|
+
@property
|
|
20
|
+
@override
|
|
21
|
+
def name(self) -> str:
|
|
22
|
+
"""Get the tool name.
|
|
23
|
+
|
|
24
|
+
Returns:
|
|
25
|
+
Tool name
|
|
26
|
+
"""
|
|
27
|
+
return "think"
|
|
28
|
+
|
|
29
|
+
@property
|
|
30
|
+
@override
|
|
31
|
+
def description(self) -> str:
|
|
32
|
+
"""Get the tool description.
|
|
33
|
+
|
|
34
|
+
Returns:
|
|
35
|
+
Tool description
|
|
36
|
+
"""
|
|
37
|
+
return """Use the tool to think about something.
|
|
38
|
+
|
|
39
|
+
It will not obtain new information or make any changes to the repository, but just log the thought. Use it when complex reasoning or brainstorming is needed. For example, if you explore the repo and discover the source of a bug, call this tool to brainstorm several unique ways of fixing the bug, and assess which change(s) are likely to be simplest and most effective. Alternatively, if you receive some test results, call this tool to brainstorm ways to fix the failing tests."""
|
|
40
|
+
|
|
41
|
+
@property
|
|
42
|
+
@override
|
|
43
|
+
def parameters(self) -> dict[str, Any]:
|
|
44
|
+
"""Get the parameter specifications for the tool.
|
|
45
|
+
|
|
46
|
+
Returns:
|
|
47
|
+
Parameter specifications
|
|
48
|
+
"""
|
|
49
|
+
return {
|
|
50
|
+
"properties": {
|
|
51
|
+
"thought": {
|
|
52
|
+
"title": "Thought",
|
|
53
|
+
"type": "string"
|
|
54
|
+
}
|
|
55
|
+
},
|
|
56
|
+
"required": ["thought"],
|
|
57
|
+
"title": "thinkArguments",
|
|
58
|
+
"type": "object"
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
@property
|
|
62
|
+
@override
|
|
63
|
+
def required(self) -> list[str]:
|
|
64
|
+
"""Get the list of required parameter names.
|
|
65
|
+
|
|
66
|
+
Returns:
|
|
67
|
+
List of required parameter names
|
|
68
|
+
"""
|
|
69
|
+
return ["thought"]
|
|
70
|
+
|
|
71
|
+
def __init__(self) -> None:
|
|
72
|
+
"""Initialize the thinking tool."""
|
|
73
|
+
pass
|
|
74
|
+
|
|
75
|
+
@override
|
|
76
|
+
async def call(self, ctx: MCPContext, **params: Any) -> str:
|
|
77
|
+
"""Execute the tool with the given parameters.
|
|
78
|
+
|
|
79
|
+
Args:
|
|
80
|
+
ctx: MCP context
|
|
81
|
+
**params: Tool parameters
|
|
82
|
+
|
|
83
|
+
Returns:
|
|
84
|
+
Tool result
|
|
85
|
+
"""
|
|
86
|
+
tool_ctx = create_tool_context(ctx)
|
|
87
|
+
tool_ctx.set_tool_info(self.name)
|
|
88
|
+
|
|
89
|
+
# Extract parameters
|
|
90
|
+
thought = params.get("thought")
|
|
91
|
+
|
|
92
|
+
# Validate required thought parameter
|
|
93
|
+
if not thought:
|
|
94
|
+
await tool_ctx.error(
|
|
95
|
+
"Parameter 'thought' is required but was None or empty"
|
|
96
|
+
)
|
|
97
|
+
return "Error: Parameter 'thought' is required but was None or empty"
|
|
98
|
+
|
|
99
|
+
if thought.strip() == "":
|
|
100
|
+
await tool_ctx.error("Parameter 'thought' cannot be empty")
|
|
101
|
+
return "Error: Parameter 'thought' cannot be empty"
|
|
102
|
+
|
|
103
|
+
# Log the thought but don't take action
|
|
104
|
+
await tool_ctx.info("Thinking process recorded")
|
|
105
|
+
|
|
106
|
+
# Return confirmation
|
|
107
|
+
return "I've recorded your thinking process. You can continue with your next action based on this analysis."
|
|
108
|
+
|
|
109
|
+
@override
|
|
110
|
+
def register(self, mcp_server: FastMCP) -> None:
|
|
111
|
+
"""Register this thinking tool with the MCP server.
|
|
112
|
+
|
|
113
|
+
Creates a wrapper function with explicitly defined parameters that match
|
|
114
|
+
the tool's parameter schema and registers it with the MCP server.
|
|
115
|
+
|
|
116
|
+
Args:
|
|
117
|
+
mcp_server: The FastMCP server instance
|
|
118
|
+
"""
|
|
119
|
+
tool_self = self # Create a reference to self for use in the closure
|
|
120
|
+
|
|
121
|
+
@mcp_server.tool(name=self.name, description=self.mcp_description)
|
|
122
|
+
async def think(thought: str, ctx: MCPContext) -> str:
|
|
123
|
+
return await tool_self.call(ctx, thought=thought)
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
"""Parameter validation utilities for Hanzo MCP tools.
|
|
2
|
+
|
|
3
|
+
This module provides utilities for validating parameters in tool functions.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from typing import Any, TypeVar, final
|
|
7
|
+
|
|
8
|
+
T = TypeVar("T")
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
@final
|
|
12
|
+
class ValidationResult:
|
|
13
|
+
"""Result of a parameter validation."""
|
|
14
|
+
|
|
15
|
+
def __init__(self, is_valid: bool, error_message: str = ""):
|
|
16
|
+
"""Initialize a validation result.
|
|
17
|
+
|
|
18
|
+
Args:
|
|
19
|
+
is_valid: Whether the parameter is valid
|
|
20
|
+
error_message: Optional error message for invalid parameters
|
|
21
|
+
"""
|
|
22
|
+
self.is_valid: bool = is_valid
|
|
23
|
+
self.error_message: str = error_message
|
|
24
|
+
|
|
25
|
+
@property
|
|
26
|
+
def is_error(self) -> bool:
|
|
27
|
+
"""Check if the validation resulted in an error.
|
|
28
|
+
|
|
29
|
+
Returns:
|
|
30
|
+
True if there was a validation error, False otherwise
|
|
31
|
+
"""
|
|
32
|
+
return not self.is_valid
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def validate_parameter(
|
|
36
|
+
parameter: Any, parameter_name: str, allow_empty: bool = False
|
|
37
|
+
) -> ValidationResult:
|
|
38
|
+
"""Validate a single parameter.
|
|
39
|
+
|
|
40
|
+
Args:
|
|
41
|
+
parameter: The parameter value to validate
|
|
42
|
+
parameter_name: The name of the parameter (for error messages)
|
|
43
|
+
allow_empty: Whether to allow empty strings, lists, etc.
|
|
44
|
+
|
|
45
|
+
Returns:
|
|
46
|
+
A ValidationResult indicating whether the parameter is valid
|
|
47
|
+
"""
|
|
48
|
+
# Check for None
|
|
49
|
+
if parameter is None:
|
|
50
|
+
return ValidationResult(
|
|
51
|
+
is_valid=False,
|
|
52
|
+
error_message=f"Parameter '{parameter_name}' is required but was None",
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
# Check for empty strings
|
|
56
|
+
if isinstance(parameter, str) and not allow_empty and parameter.strip() == "":
|
|
57
|
+
return ValidationResult(
|
|
58
|
+
is_valid=False,
|
|
59
|
+
error_message=f"Parameter '{parameter_name}' is required but was empty string",
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
# Check for empty collections
|
|
63
|
+
if (
|
|
64
|
+
isinstance(parameter, (list, tuple, dict, set))
|
|
65
|
+
and not allow_empty
|
|
66
|
+
and len(parameter) == 0
|
|
67
|
+
):
|
|
68
|
+
return ValidationResult(
|
|
69
|
+
is_valid=False,
|
|
70
|
+
error_message=f"Parameter '{parameter_name}' is required but was empty {type(parameter).__name__}",
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
# Parameter is valid
|
|
74
|
+
return ValidationResult(is_valid=True)
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def validate_path_parameter(
|
|
78
|
+
path: str | None, parameter_name: str = "path"
|
|
79
|
+
) -> ValidationResult:
|
|
80
|
+
"""Validate a path parameter.
|
|
81
|
+
|
|
82
|
+
Args:
|
|
83
|
+
path: The path parameter to validate
|
|
84
|
+
parameter_name: The name of the parameter (for error messages)
|
|
85
|
+
|
|
86
|
+
Returns:
|
|
87
|
+
A ValidationResult indicating whether the parameter is valid
|
|
88
|
+
"""
|
|
89
|
+
# Check for None
|
|
90
|
+
if path is None:
|
|
91
|
+
return ValidationResult(
|
|
92
|
+
is_valid=False,
|
|
93
|
+
error_message=f"Path parameter '{parameter_name}' is required but was None",
|
|
94
|
+
)
|
|
95
|
+
|
|
96
|
+
# Check for empty path
|
|
97
|
+
if path.strip() == "":
|
|
98
|
+
return ValidationResult(
|
|
99
|
+
is_valid=False,
|
|
100
|
+
error_message=f"Path parameter '{parameter_name}' is required but was empty string",
|
|
101
|
+
)
|
|
102
|
+
|
|
103
|
+
# Path is valid
|
|
104
|
+
return ValidationResult(is_valid=True)
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def validate_parameters(**kwargs: Any) -> ValidationResult:
|
|
108
|
+
"""Validate multiple parameters.
|
|
109
|
+
|
|
110
|
+
Accepts keyword arguments where the key is the parameter name and the value is the parameter value.
|
|
111
|
+
|
|
112
|
+
Args:
|
|
113
|
+
**kwargs: Parameters to validate as name=value pairs
|
|
114
|
+
|
|
115
|
+
Returns:
|
|
116
|
+
A ValidationResult for the first invalid parameter, or a valid result if all are valid
|
|
117
|
+
"""
|
|
118
|
+
for name, value in kwargs.items():
|
|
119
|
+
result = validate_parameter(value, name)
|
|
120
|
+
if result.is_error:
|
|
121
|
+
return result
|
|
122
|
+
|
|
123
|
+
# All parameters are valid
|
|
124
|
+
return ValidationResult(is_valid=True)
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
"""Filesystem tools package for Hanzo MCP.
|
|
2
|
+
|
|
3
|
+
This package provides tools for interacting with the filesystem, including reading, writing,
|
|
4
|
+
and editing files, directory navigation, and content searching.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from mcp.server.fastmcp import FastMCP
|
|
8
|
+
|
|
9
|
+
from hanzo_mcp.tools.common.base import BaseTool, ToolRegistry
|
|
10
|
+
from hanzo_mcp.tools.common.context import DocumentContext
|
|
11
|
+
from hanzo_mcp.tools.common.permissions import PermissionManager
|
|
12
|
+
from hanzo_mcp.tools.filesystem.content_replace import ContentReplaceTool
|
|
13
|
+
from hanzo_mcp.tools.filesystem.directory_tree import DirectoryTreeTool
|
|
14
|
+
from hanzo_mcp.tools.filesystem.edit_file import EditFileTool
|
|
15
|
+
from hanzo_mcp.tools.filesystem.get_file_info import GetFileInfoTool
|
|
16
|
+
from hanzo_mcp.tools.filesystem.read_files import ReadFilesTool
|
|
17
|
+
from hanzo_mcp.tools.filesystem.search_content import SearchContentTool
|
|
18
|
+
from hanzo_mcp.tools.filesystem.write_file import WriteFileTool
|
|
19
|
+
|
|
20
|
+
# Export all tool classes
|
|
21
|
+
__all__ = [
|
|
22
|
+
"ReadFilesTool",
|
|
23
|
+
"WriteFileTool",
|
|
24
|
+
"EditFileTool",
|
|
25
|
+
"DirectoryTreeTool",
|
|
26
|
+
"GetFileInfoTool",
|
|
27
|
+
"SearchContentTool",
|
|
28
|
+
"ContentReplaceTool",
|
|
29
|
+
"get_filesystem_tools",
|
|
30
|
+
"register_filesystem_tools",
|
|
31
|
+
]
|
|
32
|
+
|
|
33
|
+
def get_read_only_filesystem_tools(
|
|
34
|
+
document_context: DocumentContext, permission_manager: PermissionManager
|
|
35
|
+
) -> list[BaseTool]:
|
|
36
|
+
"""Create instances of read-only filesystem tools.
|
|
37
|
+
|
|
38
|
+
Args:
|
|
39
|
+
document_context: Document context for tracking file contents
|
|
40
|
+
permission_manager: Permission manager for access control
|
|
41
|
+
|
|
42
|
+
Returns:
|
|
43
|
+
List of read-only filesystem tool instances
|
|
44
|
+
"""
|
|
45
|
+
return [
|
|
46
|
+
ReadFilesTool(document_context, permission_manager),
|
|
47
|
+
DirectoryTreeTool(document_context, permission_manager),
|
|
48
|
+
GetFileInfoTool(document_context, permission_manager),
|
|
49
|
+
SearchContentTool(document_context, permission_manager),
|
|
50
|
+
]
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def get_filesystem_tools(
|
|
54
|
+
document_context: DocumentContext, permission_manager: PermissionManager
|
|
55
|
+
) -> list[BaseTool]:
|
|
56
|
+
"""Create instances of all filesystem tools.
|
|
57
|
+
|
|
58
|
+
Args:
|
|
59
|
+
document_context: Document context for tracking file contents
|
|
60
|
+
permission_manager: Permission manager for access control
|
|
61
|
+
|
|
62
|
+
Returns:
|
|
63
|
+
List of filesystem tool instances
|
|
64
|
+
"""
|
|
65
|
+
return [
|
|
66
|
+
ReadFilesTool(document_context, permission_manager),
|
|
67
|
+
WriteFileTool(document_context, permission_manager),
|
|
68
|
+
EditFileTool(document_context, permission_manager),
|
|
69
|
+
DirectoryTreeTool(document_context, permission_manager),
|
|
70
|
+
GetFileInfoTool(document_context, permission_manager),
|
|
71
|
+
SearchContentTool(document_context, permission_manager),
|
|
72
|
+
ContentReplaceTool(document_context, permission_manager),
|
|
73
|
+
]
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def register_filesystem_tools(
|
|
77
|
+
mcp_server: FastMCP,
|
|
78
|
+
document_context: DocumentContext,
|
|
79
|
+
permission_manager: PermissionManager,
|
|
80
|
+
) -> None:
|
|
81
|
+
"""Register all filesystem tools with the MCP server.
|
|
82
|
+
|
|
83
|
+
Args:
|
|
84
|
+
mcp_server: The FastMCP server instance
|
|
85
|
+
document_context: Document context for tracking file contents
|
|
86
|
+
permission_manager: Permission manager for access control
|
|
87
|
+
"""
|
|
88
|
+
tools = get_filesystem_tools(document_context, permission_manager)
|
|
89
|
+
ToolRegistry.register_tools(mcp_server, tools)
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
"""Base functionality for filesystem tools.
|
|
2
|
+
|
|
3
|
+
This module provides common functionality for filesystem tools including path handling,
|
|
4
|
+
error formatting, and shared utilities for file operations.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from abc import ABC
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
from mcp.server.fastmcp import Context as MCPContext
|
|
12
|
+
|
|
13
|
+
from hanzo_mcp.tools.common.base import FileSystemTool
|
|
14
|
+
from hanzo_mcp.tools.common.context import ToolContext, create_tool_context
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class FilesystemBaseTool(FileSystemTool,ABC):
|
|
18
|
+
"""Enhanced base class for all filesystem tools.
|
|
19
|
+
|
|
20
|
+
Provides additional utilities specific to filesystem operations beyond
|
|
21
|
+
the base functionality in FileSystemTool.
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
async def check_path_allowed(self, path: str, tool_ctx: Any, error_prefix: str = "Error") -> tuple[bool, str]:
|
|
25
|
+
"""Check if a path is allowed and log an error if not.
|
|
26
|
+
|
|
27
|
+
Args:
|
|
28
|
+
path: Path to check
|
|
29
|
+
tool_ctx: Tool context for logging
|
|
30
|
+
error_prefix: Prefix for error messages
|
|
31
|
+
|
|
32
|
+
Returns:
|
|
33
|
+
tuple of (is_allowed, error_message)
|
|
34
|
+
"""
|
|
35
|
+
if not self.is_path_allowed(path):
|
|
36
|
+
message = f"Access denied - path outside allowed directories: {path}"
|
|
37
|
+
await tool_ctx.error(message)
|
|
38
|
+
return False, f"{error_prefix}: {message}"
|
|
39
|
+
return True, ""
|
|
40
|
+
|
|
41
|
+
async def check_path_exists(self, path: str, tool_ctx: Any, error_prefix: str = "Error") -> tuple[bool, str]:
|
|
42
|
+
"""Check if a path exists and log an error if not.
|
|
43
|
+
|
|
44
|
+
Args:
|
|
45
|
+
path: Path to check
|
|
46
|
+
tool_ctx: Tool context for logging
|
|
47
|
+
error_prefix: Prefix for error messages
|
|
48
|
+
|
|
49
|
+
Returns:
|
|
50
|
+
tuple of (exists, error_message)
|
|
51
|
+
"""
|
|
52
|
+
file_path = Path(path)
|
|
53
|
+
if not file_path.exists():
|
|
54
|
+
message = f"Path does not exist: {path}"
|
|
55
|
+
await tool_ctx.error(message)
|
|
56
|
+
return False, f"{error_prefix}: {message}"
|
|
57
|
+
return True, ""
|
|
58
|
+
|
|
59
|
+
async def check_is_file(self, path: str, tool_ctx: Any, error_prefix: str = "Error") -> tuple[bool, str]:
|
|
60
|
+
"""Check if a path is a file and log an error if not.
|
|
61
|
+
|
|
62
|
+
Args:
|
|
63
|
+
path: Path to check
|
|
64
|
+
tool_ctx: Tool context for logging
|
|
65
|
+
error_prefix: Prefix for error messages
|
|
66
|
+
|
|
67
|
+
Returns:
|
|
68
|
+
tuple of (is_file, error_message)
|
|
69
|
+
"""
|
|
70
|
+
file_path = Path(path)
|
|
71
|
+
if not file_path.is_file():
|
|
72
|
+
message = f"Path is not a file: {path}"
|
|
73
|
+
await tool_ctx.error(message)
|
|
74
|
+
return False, f"{error_prefix}: {message}"
|
|
75
|
+
return True, ""
|
|
76
|
+
|
|
77
|
+
async def check_is_directory(self, path: str, tool_ctx: Any, error_prefix: str = "Error") -> tuple[bool, str]:
|
|
78
|
+
"""Check if a path is a directory and log an error if not.
|
|
79
|
+
|
|
80
|
+
Args:
|
|
81
|
+
path: Path to check
|
|
82
|
+
tool_ctx: Tool context for logging
|
|
83
|
+
error_prefix: Prefix for error messages
|
|
84
|
+
|
|
85
|
+
Returns:
|
|
86
|
+
tuple of (is_directory, error_message)
|
|
87
|
+
"""
|
|
88
|
+
dir_path = Path(path)
|
|
89
|
+
if not dir_path.is_dir():
|
|
90
|
+
message = f"Path is not a directory: {path}"
|
|
91
|
+
await tool_ctx.error(message)
|
|
92
|
+
return False, f"{error_prefix}: {message}"
|
|
93
|
+
return True, ""
|
|
94
|
+
|
|
95
|
+
def create_tool_context(self, ctx: MCPContext) -> ToolContext:
|
|
96
|
+
"""Create a tool context with the tool name.
|
|
97
|
+
|
|
98
|
+
Args:
|
|
99
|
+
ctx: MCP context
|
|
100
|
+
|
|
101
|
+
Returns:
|
|
102
|
+
Tool context
|
|
103
|
+
"""
|
|
104
|
+
tool_ctx = create_tool_context(ctx)
|
|
105
|
+
return tool_ctx
|
|
106
|
+
|
|
107
|
+
def set_tool_context_info(self, tool_ctx: ToolContext) -> None:
|
|
108
|
+
"""Set the tool info on the context.
|
|
109
|
+
|
|
110
|
+
Args:
|
|
111
|
+
tool_ctx: Tool context
|
|
112
|
+
"""
|
|
113
|
+
tool_ctx.set_tool_info(self.name)
|