hanzo-mcp 0.3.8__py3-none-any.whl → 0.5.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.
- hanzo_mcp/__init__.py +1 -1
- hanzo_mcp/cli.py +118 -170
- hanzo_mcp/cli_enhanced.py +438 -0
- hanzo_mcp/config/__init__.py +19 -0
- hanzo_mcp/config/settings.py +449 -0
- hanzo_mcp/config/tool_config.py +197 -0
- hanzo_mcp/prompts/__init__.py +117 -0
- hanzo_mcp/prompts/compact_conversation.py +77 -0
- hanzo_mcp/prompts/create_release.py +38 -0
- hanzo_mcp/prompts/project_system.py +120 -0
- hanzo_mcp/prompts/project_todo_reminder.py +111 -0
- hanzo_mcp/prompts/utils.py +286 -0
- hanzo_mcp/server.py +117 -99
- hanzo_mcp/tools/__init__.py +121 -33
- hanzo_mcp/tools/agent/__init__.py +8 -11
- hanzo_mcp/tools/agent/agent_tool.py +290 -224
- hanzo_mcp/tools/agent/prompt.py +16 -13
- hanzo_mcp/tools/agent/tool_adapter.py +9 -9
- hanzo_mcp/tools/common/__init__.py +17 -16
- hanzo_mcp/tools/common/base.py +79 -110
- hanzo_mcp/tools/common/batch_tool.py +330 -0
- hanzo_mcp/tools/common/config_tool.py +396 -0
- hanzo_mcp/tools/common/context.py +26 -292
- hanzo_mcp/tools/common/permissions.py +12 -12
- hanzo_mcp/tools/common/thinking_tool.py +153 -0
- hanzo_mcp/tools/common/validation.py +1 -63
- hanzo_mcp/tools/filesystem/__init__.py +97 -57
- hanzo_mcp/tools/filesystem/base.py +32 -24
- hanzo_mcp/tools/filesystem/content_replace.py +114 -107
- hanzo_mcp/tools/filesystem/directory_tree.py +129 -105
- hanzo_mcp/tools/filesystem/edit.py +279 -0
- hanzo_mcp/tools/filesystem/grep.py +458 -0
- hanzo_mcp/tools/filesystem/grep_ast_tool.py +250 -0
- hanzo_mcp/tools/filesystem/multi_edit.py +362 -0
- hanzo_mcp/tools/filesystem/read.py +255 -0
- hanzo_mcp/tools/filesystem/unified_search.py +689 -0
- hanzo_mcp/tools/filesystem/write.py +156 -0
- hanzo_mcp/tools/jupyter/__init__.py +41 -29
- hanzo_mcp/tools/jupyter/base.py +66 -57
- hanzo_mcp/tools/jupyter/{edit_notebook.py → notebook_edit.py} +162 -139
- hanzo_mcp/tools/jupyter/notebook_read.py +152 -0
- hanzo_mcp/tools/shell/__init__.py +29 -20
- hanzo_mcp/tools/shell/base.py +87 -45
- hanzo_mcp/tools/shell/bash_session.py +731 -0
- hanzo_mcp/tools/shell/bash_session_executor.py +295 -0
- hanzo_mcp/tools/shell/command_executor.py +435 -384
- hanzo_mcp/tools/shell/run_command.py +284 -131
- hanzo_mcp/tools/shell/run_command_windows.py +328 -0
- hanzo_mcp/tools/shell/session_manager.py +196 -0
- hanzo_mcp/tools/shell/session_storage.py +325 -0
- hanzo_mcp/tools/todo/__init__.py +66 -0
- hanzo_mcp/tools/todo/base.py +319 -0
- hanzo_mcp/tools/todo/todo_read.py +148 -0
- hanzo_mcp/tools/todo/todo_write.py +378 -0
- hanzo_mcp/tools/vector/__init__.py +99 -0
- hanzo_mcp/tools/vector/ast_analyzer.py +459 -0
- hanzo_mcp/tools/vector/git_ingester.py +482 -0
- hanzo_mcp/tools/vector/infinity_store.py +731 -0
- hanzo_mcp/tools/vector/mock_infinity.py +162 -0
- hanzo_mcp/tools/vector/project_manager.py +361 -0
- hanzo_mcp/tools/vector/vector_index.py +116 -0
- hanzo_mcp/tools/vector/vector_search.py +225 -0
- hanzo_mcp-0.5.1.dist-info/METADATA +276 -0
- hanzo_mcp-0.5.1.dist-info/RECORD +68 -0
- {hanzo_mcp-0.3.8.dist-info → hanzo_mcp-0.5.1.dist-info}/WHEEL +1 -1
- hanzo_mcp/tools/agent/base_provider.py +0 -73
- hanzo_mcp/tools/agent/litellm_provider.py +0 -45
- hanzo_mcp/tools/agent/lmstudio_agent.py +0 -385
- hanzo_mcp/tools/agent/lmstudio_provider.py +0 -219
- hanzo_mcp/tools/agent/provider_registry.py +0 -120
- hanzo_mcp/tools/common/error_handling.py +0 -86
- hanzo_mcp/tools/common/logging_config.py +0 -115
- hanzo_mcp/tools/common/session.py +0 -91
- hanzo_mcp/tools/common/think_tool.py +0 -123
- hanzo_mcp/tools/common/version_tool.py +0 -120
- hanzo_mcp/tools/filesystem/edit_file.py +0 -287
- hanzo_mcp/tools/filesystem/get_file_info.py +0 -170
- hanzo_mcp/tools/filesystem/read_files.py +0 -199
- hanzo_mcp/tools/filesystem/search_content.py +0 -275
- hanzo_mcp/tools/filesystem/write_file.py +0 -162
- hanzo_mcp/tools/jupyter/notebook_operations.py +0 -514
- hanzo_mcp/tools/jupyter/read_notebook.py +0 -165
- hanzo_mcp/tools/project/__init__.py +0 -64
- hanzo_mcp/tools/project/analysis.py +0 -886
- hanzo_mcp/tools/project/base.py +0 -66
- hanzo_mcp/tools/project/project_analyze.py +0 -173
- hanzo_mcp/tools/shell/run_script.py +0 -215
- hanzo_mcp/tools/shell/script_tool.py +0 -244
- hanzo_mcp-0.3.8.dist-info/METADATA +0 -196
- hanzo_mcp-0.3.8.dist-info/RECORD +0 -53
- {hanzo_mcp-0.3.8.dist-info → hanzo_mcp-0.5.1.dist-info}/entry_points.txt +0 -0
- {hanzo_mcp-0.3.8.dist-info → hanzo_mcp-0.5.1.dist-info}/licenses/LICENSE +0 -0
- {hanzo_mcp-0.3.8.dist-info → hanzo_mcp-0.5.1.dist-info}/top_level.txt +0 -0
|
@@ -11,7 +11,7 @@ from collections.abc import Iterable
|
|
|
11
11
|
from pathlib import Path
|
|
12
12
|
from typing import Any, ClassVar, final
|
|
13
13
|
|
|
14
|
-
from
|
|
14
|
+
from fastmcp import Context as MCPContext
|
|
15
15
|
from mcp.server.lowlevel.helper_types import ReadResourceContents
|
|
16
16
|
|
|
17
17
|
|
|
@@ -87,7 +87,11 @@ class ToolContext:
|
|
|
87
87
|
Args:
|
|
88
88
|
message: The message to log
|
|
89
89
|
"""
|
|
90
|
-
|
|
90
|
+
try:
|
|
91
|
+
await self._mcp_context.info(self._format_message(message))
|
|
92
|
+
except Exception:
|
|
93
|
+
# Silently ignore errors when client has disconnected
|
|
94
|
+
pass
|
|
91
95
|
|
|
92
96
|
async def debug(self, message: str) -> None:
|
|
93
97
|
"""Log a debug message.
|
|
@@ -95,7 +99,11 @@ class ToolContext:
|
|
|
95
99
|
Args:
|
|
96
100
|
message: The message to log
|
|
97
101
|
"""
|
|
98
|
-
|
|
102
|
+
try:
|
|
103
|
+
await self._mcp_context.debug(self._format_message(message))
|
|
104
|
+
except Exception:
|
|
105
|
+
# Silently ignore errors when client has disconnected
|
|
106
|
+
pass
|
|
99
107
|
|
|
100
108
|
async def warning(self, message: str) -> None:
|
|
101
109
|
"""Log a warning message.
|
|
@@ -103,7 +111,11 @@ class ToolContext:
|
|
|
103
111
|
Args:
|
|
104
112
|
message: The message to log
|
|
105
113
|
"""
|
|
106
|
-
|
|
114
|
+
try:
|
|
115
|
+
await self._mcp_context.warning(self._format_message(message))
|
|
116
|
+
except Exception:
|
|
117
|
+
# Silently ignore errors when client has disconnected
|
|
118
|
+
pass
|
|
107
119
|
|
|
108
120
|
async def error(self, message: str) -> None:
|
|
109
121
|
"""Log an error message.
|
|
@@ -111,7 +123,11 @@ class ToolContext:
|
|
|
111
123
|
Args:
|
|
112
124
|
message: The message to log
|
|
113
125
|
"""
|
|
114
|
-
|
|
126
|
+
try:
|
|
127
|
+
await self._mcp_context.error(self._format_message(message))
|
|
128
|
+
except Exception:
|
|
129
|
+
# Silently ignore errors when client has disconnected
|
|
130
|
+
pass
|
|
115
131
|
|
|
116
132
|
def _format_message(self, message: str) -> str:
|
|
117
133
|
"""Format a message with tool information if available.
|
|
@@ -135,7 +151,11 @@ class ToolContext:
|
|
|
135
151
|
current: Current progress value
|
|
136
152
|
total: Total progress value
|
|
137
153
|
"""
|
|
138
|
-
|
|
154
|
+
try:
|
|
155
|
+
await self._mcp_context.report_progress(current, total)
|
|
156
|
+
except Exception:
|
|
157
|
+
# Silently ignore errors when client has disconnected
|
|
158
|
+
pass
|
|
139
159
|
|
|
140
160
|
async def read_resource(self, uri: str) -> Iterable[ReadResourceContents]:
|
|
141
161
|
"""Read a resource via the MCP protocol.
|
|
@@ -160,289 +180,3 @@ def create_tool_context(mcp_context: MCPContext) -> ToolContext:
|
|
|
160
180
|
A new ToolContext
|
|
161
181
|
"""
|
|
162
182
|
return ToolContext(mcp_context)
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
@final
|
|
166
|
-
class DocumentContext:
|
|
167
|
-
"""Manages document context and codebase understanding."""
|
|
168
|
-
|
|
169
|
-
def __init__(self) -> None:
|
|
170
|
-
"""Initialize the document context."""
|
|
171
|
-
self.documents: dict[str, str] = {}
|
|
172
|
-
self.document_metadata: dict[str, dict[str, Any]] = {}
|
|
173
|
-
self.modified_times: dict[str, float] = {}
|
|
174
|
-
self.allowed_paths: set[Path] = set()
|
|
175
|
-
|
|
176
|
-
def add_allowed_path(self, path: str) -> None:
|
|
177
|
-
"""Add a path to the allowed paths.
|
|
178
|
-
|
|
179
|
-
Args:
|
|
180
|
-
path: The path to allow
|
|
181
|
-
"""
|
|
182
|
-
# Expand user path (e.g., ~/ or $HOME)
|
|
183
|
-
expanded_path = os.path.expanduser(path)
|
|
184
|
-
resolved_path: Path = Path(expanded_path).resolve()
|
|
185
|
-
self.allowed_paths.add(resolved_path)
|
|
186
|
-
|
|
187
|
-
def is_path_allowed(self, path: str) -> bool:
|
|
188
|
-
"""Check if a path is allowed.
|
|
189
|
-
|
|
190
|
-
Args:
|
|
191
|
-
path: The path to check
|
|
192
|
-
|
|
193
|
-
Returns:
|
|
194
|
-
True if the path is allowed, False otherwise
|
|
195
|
-
"""
|
|
196
|
-
# Expand user path (e.g., ~/ or $HOME)
|
|
197
|
-
expanded_path = os.path.expanduser(path)
|
|
198
|
-
resolved_path: Path = Path(expanded_path).resolve()
|
|
199
|
-
|
|
200
|
-
# Check if the path is within any allowed path
|
|
201
|
-
for allowed_path in self.allowed_paths:
|
|
202
|
-
try:
|
|
203
|
-
_ = resolved_path.relative_to(allowed_path)
|
|
204
|
-
return True
|
|
205
|
-
except ValueError:
|
|
206
|
-
continue
|
|
207
|
-
|
|
208
|
-
return False
|
|
209
|
-
|
|
210
|
-
def add_document(
|
|
211
|
-
self, path: str, content: str, metadata: dict[str, Any] | None = None
|
|
212
|
-
) -> None:
|
|
213
|
-
"""Add a document to the context.
|
|
214
|
-
|
|
215
|
-
Args:
|
|
216
|
-
path: The path of the document
|
|
217
|
-
content: The content of the document
|
|
218
|
-
metadata: Optional metadata about the document
|
|
219
|
-
"""
|
|
220
|
-
self.documents[path] = content
|
|
221
|
-
self.modified_times[path] = time.time()
|
|
222
|
-
|
|
223
|
-
if metadata:
|
|
224
|
-
self.document_metadata[path] = metadata
|
|
225
|
-
else:
|
|
226
|
-
# Try to infer metadata
|
|
227
|
-
self.document_metadata[path] = self._infer_metadata(path, content)
|
|
228
|
-
|
|
229
|
-
def get_document(self, path: str) -> str | None:
|
|
230
|
-
"""Get a document from the context.
|
|
231
|
-
|
|
232
|
-
Args:
|
|
233
|
-
path: The path of the document
|
|
234
|
-
|
|
235
|
-
Returns:
|
|
236
|
-
The document content, or None if not found
|
|
237
|
-
"""
|
|
238
|
-
return self.documents.get(path)
|
|
239
|
-
|
|
240
|
-
def get_document_metadata(self, path: str) -> dict[str, Any] | None:
|
|
241
|
-
"""Get document metadata.
|
|
242
|
-
|
|
243
|
-
Args:
|
|
244
|
-
path: The path of the document
|
|
245
|
-
|
|
246
|
-
Returns:
|
|
247
|
-
The document metadata, or None if not found
|
|
248
|
-
"""
|
|
249
|
-
return self.document_metadata.get(path)
|
|
250
|
-
|
|
251
|
-
def update_document(self, path: str, content: str) -> None:
|
|
252
|
-
"""Update a document in the context.
|
|
253
|
-
|
|
254
|
-
Args:
|
|
255
|
-
path: The path of the document
|
|
256
|
-
content: The new content of the document
|
|
257
|
-
"""
|
|
258
|
-
self.documents[path] = content
|
|
259
|
-
self.modified_times[path] = time.time()
|
|
260
|
-
|
|
261
|
-
# Update metadata
|
|
262
|
-
self.document_metadata[path] = self._infer_metadata(path, content)
|
|
263
|
-
|
|
264
|
-
def remove_document(self, path: str) -> None:
|
|
265
|
-
"""Remove a document from the context.
|
|
266
|
-
|
|
267
|
-
Args:
|
|
268
|
-
path: The path of the document
|
|
269
|
-
"""
|
|
270
|
-
if path in self.documents:
|
|
271
|
-
del self.documents[path]
|
|
272
|
-
|
|
273
|
-
if path in self.document_metadata:
|
|
274
|
-
del self.document_metadata[path]
|
|
275
|
-
|
|
276
|
-
if path in self.modified_times:
|
|
277
|
-
del self.modified_times[path]
|
|
278
|
-
|
|
279
|
-
def _infer_metadata(self, path: str, content: str) -> dict[str, Any]:
|
|
280
|
-
"""Infer metadata about a document.
|
|
281
|
-
|
|
282
|
-
Args:
|
|
283
|
-
path: The path of the document
|
|
284
|
-
content: The content of the document
|
|
285
|
-
|
|
286
|
-
Returns:
|
|
287
|
-
Inferred metadata
|
|
288
|
-
"""
|
|
289
|
-
extension: str = Path(path).suffix.lower()
|
|
290
|
-
|
|
291
|
-
metadata: dict[str, Any] = {
|
|
292
|
-
"extension": extension,
|
|
293
|
-
"size": len(content),
|
|
294
|
-
"line_count": content.count("\n") + 1,
|
|
295
|
-
}
|
|
296
|
-
|
|
297
|
-
# Infer language based on extension
|
|
298
|
-
language_map: dict[str, list[str]] = {
|
|
299
|
-
"python": [".py"],
|
|
300
|
-
"javascript": [".js", ".jsx"],
|
|
301
|
-
"typescript": [".ts", ".tsx"],
|
|
302
|
-
"java": [".java"],
|
|
303
|
-
"c++": [".c", ".cpp", ".h", ".hpp"],
|
|
304
|
-
"go": [".go"],
|
|
305
|
-
"rust": [".rs"],
|
|
306
|
-
"ruby": [".rb"],
|
|
307
|
-
"php": [".php"],
|
|
308
|
-
"html": [".html", ".htm"],
|
|
309
|
-
"css": [".css"],
|
|
310
|
-
"markdown": [".md"],
|
|
311
|
-
"json": [".json"],
|
|
312
|
-
"yaml": [".yaml", ".yml"],
|
|
313
|
-
"xml": [".xml"],
|
|
314
|
-
"sql": [".sql"],
|
|
315
|
-
"shell": [".sh", ".bash"],
|
|
316
|
-
}
|
|
317
|
-
|
|
318
|
-
# Find matching language
|
|
319
|
-
for language, extensions in language_map.items():
|
|
320
|
-
if extension in extensions:
|
|
321
|
-
metadata["language"] = language
|
|
322
|
-
break
|
|
323
|
-
else:
|
|
324
|
-
metadata["language"] = "text"
|
|
325
|
-
|
|
326
|
-
return metadata
|
|
327
|
-
|
|
328
|
-
def load_directory(
|
|
329
|
-
self,
|
|
330
|
-
directory: str,
|
|
331
|
-
recursive: bool = True,
|
|
332
|
-
exclude_patterns: list[str] | None = None,
|
|
333
|
-
) -> None:
|
|
334
|
-
"""Load all files in a directory into the context.
|
|
335
|
-
|
|
336
|
-
Args:
|
|
337
|
-
directory: The directory to load
|
|
338
|
-
recursive: Whether to load subdirectories
|
|
339
|
-
exclude_patterns: Patterns to exclude
|
|
340
|
-
"""
|
|
341
|
-
if not self.is_path_allowed(directory):
|
|
342
|
-
raise ValueError(f"Directory not allowed: {directory}")
|
|
343
|
-
|
|
344
|
-
dir_path: Path = Path(directory)
|
|
345
|
-
|
|
346
|
-
if not dir_path.exists() or not dir_path.is_dir():
|
|
347
|
-
raise ValueError(f"Not a valid directory: {directory}")
|
|
348
|
-
|
|
349
|
-
if exclude_patterns is None:
|
|
350
|
-
exclude_patterns = []
|
|
351
|
-
|
|
352
|
-
# Common directories and files to exclude
|
|
353
|
-
default_excludes: list[str] = [
|
|
354
|
-
"__pycache__",
|
|
355
|
-
".git",
|
|
356
|
-
".github",
|
|
357
|
-
".ssh",
|
|
358
|
-
".gnupg",
|
|
359
|
-
".config",
|
|
360
|
-
"node_modules",
|
|
361
|
-
"__pycache__",
|
|
362
|
-
".venv",
|
|
363
|
-
"venv",
|
|
364
|
-
"env",
|
|
365
|
-
".idea",
|
|
366
|
-
".vscode",
|
|
367
|
-
".DS_Store",
|
|
368
|
-
]
|
|
369
|
-
|
|
370
|
-
exclude_patterns.extend(default_excludes)
|
|
371
|
-
|
|
372
|
-
def should_exclude(path: Path) -> bool:
|
|
373
|
-
"""Check if a path should be excluded.
|
|
374
|
-
|
|
375
|
-
Args:
|
|
376
|
-
path: The path to check
|
|
377
|
-
|
|
378
|
-
Returns:
|
|
379
|
-
True if the path should be excluded, False otherwise
|
|
380
|
-
"""
|
|
381
|
-
for pattern in exclude_patterns:
|
|
382
|
-
if pattern.startswith("*"):
|
|
383
|
-
if path.name.endswith(pattern[1:]):
|
|
384
|
-
return True
|
|
385
|
-
elif pattern in str(path):
|
|
386
|
-
return True
|
|
387
|
-
return False
|
|
388
|
-
|
|
389
|
-
# Walk the directory
|
|
390
|
-
for root, dirs, files in os.walk(dir_path):
|
|
391
|
-
# Skip excluded directories
|
|
392
|
-
dirs[:] = [d for d in dirs if not should_exclude(Path(root) / d)]
|
|
393
|
-
|
|
394
|
-
# Process files
|
|
395
|
-
for file in files:
|
|
396
|
-
file_path: Path = Path(root) / file
|
|
397
|
-
|
|
398
|
-
if should_exclude(file_path):
|
|
399
|
-
continue
|
|
400
|
-
|
|
401
|
-
try:
|
|
402
|
-
with open(file_path, "r", encoding="utf-8") as f:
|
|
403
|
-
content: str = f.read()
|
|
404
|
-
|
|
405
|
-
# Add to context
|
|
406
|
-
self.add_document(str(file_path), content)
|
|
407
|
-
except UnicodeDecodeError:
|
|
408
|
-
# Skip binary files
|
|
409
|
-
continue
|
|
410
|
-
|
|
411
|
-
# Stop if not recursive
|
|
412
|
-
if not recursive:
|
|
413
|
-
break
|
|
414
|
-
|
|
415
|
-
def to_json(self) -> str:
|
|
416
|
-
"""Convert the context to a JSON string.
|
|
417
|
-
|
|
418
|
-
Returns:
|
|
419
|
-
A JSON string representation of the context
|
|
420
|
-
"""
|
|
421
|
-
data: dict[str, Any] = {
|
|
422
|
-
"documents": self.documents,
|
|
423
|
-
"metadata": self.document_metadata,
|
|
424
|
-
"modified_times": self.modified_times,
|
|
425
|
-
"allowed_paths": [str(p) for p in self.allowed_paths],
|
|
426
|
-
}
|
|
427
|
-
|
|
428
|
-
return json.dumps(data)
|
|
429
|
-
|
|
430
|
-
@classmethod
|
|
431
|
-
def from_json(cls, json_str: str) -> "DocumentContext":
|
|
432
|
-
"""Create a context from a JSON string.
|
|
433
|
-
|
|
434
|
-
Args:
|
|
435
|
-
json_str: The JSON string
|
|
436
|
-
|
|
437
|
-
Returns:
|
|
438
|
-
A new DocumentContext instance
|
|
439
|
-
"""
|
|
440
|
-
data: dict[str, Any] = json.loads(json_str)
|
|
441
|
-
|
|
442
|
-
context = cls()
|
|
443
|
-
context.documents = data.get("documents", {})
|
|
444
|
-
context.document_metadata = data.get("metadata", {})
|
|
445
|
-
context.modified_times = data.get("modified_times", {})
|
|
446
|
-
context.allowed_paths = set(Path(p) for p in data.get("allowed_paths", []))
|
|
447
|
-
|
|
448
|
-
return context
|
|
@@ -2,6 +2,8 @@
|
|
|
2
2
|
|
|
3
3
|
import json
|
|
4
4
|
import os
|
|
5
|
+
import sys
|
|
6
|
+
import tempfile
|
|
5
7
|
from collections.abc import Awaitable, Callable
|
|
6
8
|
from pathlib import Path
|
|
7
9
|
from typing import Any, TypeVar, final
|
|
@@ -18,9 +20,14 @@ class PermissionManager:
|
|
|
18
20
|
def __init__(self) -> None:
|
|
19
21
|
"""Initialize the permission manager."""
|
|
20
22
|
# Allowed paths
|
|
21
|
-
self.allowed_paths: set[Path] = set(
|
|
22
|
-
|
|
23
|
-
|
|
23
|
+
self.allowed_paths: set[Path] = set()
|
|
24
|
+
|
|
25
|
+
# Allowed paths based on platform
|
|
26
|
+
if sys.platform == "win32": # Windows
|
|
27
|
+
self.allowed_paths.add(Path(tempfile.gettempdir()).resolve())
|
|
28
|
+
else: # Unix/Linux/Mac
|
|
29
|
+
self.allowed_paths.add(Path("/tmp").resolve())
|
|
30
|
+
self.allowed_paths.add(Path("/var").resolve())
|
|
24
31
|
|
|
25
32
|
# Excluded paths
|
|
26
33
|
self.excluded_paths: set[Path] = set()
|
|
@@ -33,17 +40,14 @@ class PermissionManager:
|
|
|
33
40
|
"""Add default exclusions for sensitive files and directories."""
|
|
34
41
|
# Sensitive directories
|
|
35
42
|
sensitive_dirs: list[str] = [
|
|
36
|
-
# ".git" is now allowed by default
|
|
37
43
|
".ssh",
|
|
38
44
|
".gnupg",
|
|
39
|
-
".config",
|
|
40
45
|
"node_modules",
|
|
41
46
|
"__pycache__",
|
|
42
47
|
".venv",
|
|
43
48
|
"venv",
|
|
44
49
|
"env",
|
|
45
50
|
".idea",
|
|
46
|
-
".vscode",
|
|
47
51
|
".DS_Store",
|
|
48
52
|
]
|
|
49
53
|
self.excluded_patterns.extend(sensitive_dirs)
|
|
@@ -69,9 +73,7 @@ class PermissionManager:
|
|
|
69
73
|
Args:
|
|
70
74
|
path: The path to allow
|
|
71
75
|
"""
|
|
72
|
-
|
|
73
|
-
expanded_path = os.path.expanduser(path)
|
|
74
|
-
resolved_path: Path = Path(expanded_path).resolve()
|
|
76
|
+
resolved_path: Path = Path(path).resolve()
|
|
75
77
|
self.allowed_paths.add(resolved_path)
|
|
76
78
|
|
|
77
79
|
def remove_allowed_path(self, path: str) -> None:
|
|
@@ -110,9 +112,7 @@ class PermissionManager:
|
|
|
110
112
|
Returns:
|
|
111
113
|
True if the path is allowed, False otherwise
|
|
112
114
|
"""
|
|
113
|
-
|
|
114
|
-
expanded_path = os.path.expanduser(path)
|
|
115
|
-
resolved_path: Path = Path(expanded_path).resolve()
|
|
115
|
+
resolved_path: Path = Path(path).resolve()
|
|
116
116
|
|
|
117
117
|
# Check exclusions first
|
|
118
118
|
if self._is_path_excluded(resolved_path):
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
"""Thinking tool implementation.
|
|
2
|
+
|
|
3
|
+
This module provides the ThinkingTool for Claude to engage in structured thinking.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from typing import Annotated, TypedDict, Unpack, final, override
|
|
7
|
+
|
|
8
|
+
from fastmcp import Context as MCPContext
|
|
9
|
+
from fastmcp import FastMCP
|
|
10
|
+
from fastmcp.server.dependencies import get_context
|
|
11
|
+
from pydantic import Field
|
|
12
|
+
|
|
13
|
+
from hanzo_mcp.tools.common.base import BaseTool
|
|
14
|
+
from hanzo_mcp.tools.common.context import create_tool_context
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
Thought = Annotated[
|
|
18
|
+
str,
|
|
19
|
+
Field(
|
|
20
|
+
description="The detailed thought process to record",
|
|
21
|
+
min_length=1,
|
|
22
|
+
),
|
|
23
|
+
]
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class ThinkingToolParams(TypedDict):
|
|
27
|
+
"""Parameters for the ThinkingTool.
|
|
28
|
+
|
|
29
|
+
Attributes:
|
|
30
|
+
thought: The detailed thought process to record
|
|
31
|
+
"""
|
|
32
|
+
|
|
33
|
+
thought: Thought
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
@final
|
|
37
|
+
class ThinkingTool(BaseTool):
|
|
38
|
+
"""Tool for Claude to engage in structured thinking."""
|
|
39
|
+
|
|
40
|
+
@property
|
|
41
|
+
@override
|
|
42
|
+
def name(self) -> str:
|
|
43
|
+
"""Get the tool name.
|
|
44
|
+
|
|
45
|
+
Returns:
|
|
46
|
+
Tool name
|
|
47
|
+
"""
|
|
48
|
+
return "think"
|
|
49
|
+
|
|
50
|
+
@property
|
|
51
|
+
@override
|
|
52
|
+
def description(self) -> str:
|
|
53
|
+
"""Get the tool description.
|
|
54
|
+
|
|
55
|
+
Returns:
|
|
56
|
+
Tool description
|
|
57
|
+
"""
|
|
58
|
+
return """Use the tool to think about something. 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.
|
|
59
|
+
Ensure thinking content is concise and accurate, without needing to include code details
|
|
60
|
+
|
|
61
|
+
Common use cases:
|
|
62
|
+
1. When exploring a repository and discovering 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
|
|
63
|
+
2. After receiving test results, use this tool to brainstorm ways to fix failing tests
|
|
64
|
+
3. When planning a complex refactoring, use this tool to outline different approaches and their tradeoffs
|
|
65
|
+
4. When designing a new feature, use this tool to think through architecture decisions and implementation details
|
|
66
|
+
5. When debugging a complex issue, use this tool to organize your thoughts and hypotheses
|
|
67
|
+
6. When considering changes to the plan or shifts in thinking that the user has not previously mentioned, consider whether it is necessary to confirm with the user.
|
|
68
|
+
|
|
69
|
+
<think_example>
|
|
70
|
+
Feature Implementation Planning
|
|
71
|
+
- New code search feature requirements:
|
|
72
|
+
* Search for code patterns across multiple files
|
|
73
|
+
* Identify function usages and references
|
|
74
|
+
* Analyze import relationships
|
|
75
|
+
* Generate summary of matching patterns
|
|
76
|
+
- Implementation considerations:
|
|
77
|
+
* Need to leverage existing search mechanisms
|
|
78
|
+
* Should use regex for pattern matching
|
|
79
|
+
* Results need consistent format with other search methods
|
|
80
|
+
* Must handle large codebases efficiently
|
|
81
|
+
- Design approach:
|
|
82
|
+
1. Create new CodeSearcher class that follows existing search patterns
|
|
83
|
+
2. Implement core pattern matching algorithm
|
|
84
|
+
3. Add result formatting methods
|
|
85
|
+
4. Integrate with file traversal system
|
|
86
|
+
5. Add caching for performance optimization
|
|
87
|
+
- Testing strategy:
|
|
88
|
+
* Unit tests for search accuracy
|
|
89
|
+
* Integration tests with existing components
|
|
90
|
+
* Performance tests with large codebases
|
|
91
|
+
</think_example>"""
|
|
92
|
+
|
|
93
|
+
def __init__(self) -> None:
|
|
94
|
+
"""Initialize the thinking tool."""
|
|
95
|
+
pass
|
|
96
|
+
|
|
97
|
+
@override
|
|
98
|
+
async def call(
|
|
99
|
+
self,
|
|
100
|
+
ctx: MCPContext,
|
|
101
|
+
**params: Unpack[ThinkingToolParams],
|
|
102
|
+
) -> str:
|
|
103
|
+
"""Execute the tool with the given parameters.
|
|
104
|
+
|
|
105
|
+
Args:
|
|
106
|
+
ctx: MCP context
|
|
107
|
+
**params: Tool parameters
|
|
108
|
+
|
|
109
|
+
Returns:
|
|
110
|
+
Tool result
|
|
111
|
+
"""
|
|
112
|
+
tool_ctx = create_tool_context(ctx)
|
|
113
|
+
tool_ctx.set_tool_info(self.name)
|
|
114
|
+
|
|
115
|
+
# Extract parameters
|
|
116
|
+
thought = params.get("thought")
|
|
117
|
+
|
|
118
|
+
# Validate required thought parameter
|
|
119
|
+
if not thought:
|
|
120
|
+
await tool_ctx.error(
|
|
121
|
+
"Parameter 'thought' is required but was None or empty"
|
|
122
|
+
)
|
|
123
|
+
return "Error: Parameter 'thought' is required but was None or empty"
|
|
124
|
+
|
|
125
|
+
if thought.strip() == "":
|
|
126
|
+
await tool_ctx.error("Parameter 'thought' cannot be empty")
|
|
127
|
+
return "Error: Parameter 'thought' cannot be empty"
|
|
128
|
+
|
|
129
|
+
# Log the thought but don't take action
|
|
130
|
+
await tool_ctx.info("Thinking process recorded")
|
|
131
|
+
|
|
132
|
+
# Return confirmation
|
|
133
|
+
return "I've recorded your thinking process. You can continue with your next action based on this analysis."
|
|
134
|
+
|
|
135
|
+
@override
|
|
136
|
+
def register(self, mcp_server: FastMCP) -> None:
|
|
137
|
+
"""Register this thinking tool with the MCP server.
|
|
138
|
+
|
|
139
|
+
Creates a wrapper function with explicitly defined parameters that match
|
|
140
|
+
the tool's parameter schema and registers it with the MCP server.
|
|
141
|
+
|
|
142
|
+
Args:
|
|
143
|
+
mcp_server: The FastMCP server instance
|
|
144
|
+
"""
|
|
145
|
+
tool_self = self # Create a reference to self for use in the closure
|
|
146
|
+
|
|
147
|
+
@mcp_server.tool(name=self.name, description=self.description)
|
|
148
|
+
async def think(
|
|
149
|
+
ctx: MCPContext,
|
|
150
|
+
thought: Thought,
|
|
151
|
+
) -> str:
|
|
152
|
+
ctx = get_context()
|
|
153
|
+
return await tool_self.call(ctx, thought=thought)
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
This module provides utilities for validating parameters in tool functions.
|
|
4
4
|
"""
|
|
5
5
|
|
|
6
|
-
from typing import
|
|
6
|
+
from typing import TypeVar, final
|
|
7
7
|
|
|
8
8
|
T = TypeVar("T")
|
|
9
9
|
|
|
@@ -32,48 +32,6 @@ class ValidationResult:
|
|
|
32
32
|
return not self.is_valid
|
|
33
33
|
|
|
34
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
35
|
def validate_path_parameter(
|
|
78
36
|
path: str | None, parameter_name: str = "path"
|
|
79
37
|
) -> ValidationResult:
|
|
@@ -102,23 +60,3 @@ def validate_path_parameter(
|
|
|
102
60
|
|
|
103
61
|
# Path is valid
|
|
104
62
|
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)
|