hanzo-mcp 0.6.12__py3-none-any.whl → 0.7.0__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 +2 -2
- hanzo_mcp/analytics/__init__.py +5 -0
- hanzo_mcp/analytics/posthog_analytics.py +364 -0
- hanzo_mcp/cli.py +5 -5
- hanzo_mcp/cli_enhanced.py +7 -7
- hanzo_mcp/cli_plugin.py +91 -0
- hanzo_mcp/config/__init__.py +1 -1
- hanzo_mcp/config/settings.py +70 -7
- hanzo_mcp/config/tool_config.py +20 -6
- hanzo_mcp/dev_server.py +3 -3
- hanzo_mcp/prompts/project_system.py +1 -1
- hanzo_mcp/server.py +40 -3
- hanzo_mcp/server_enhanced.py +69 -0
- hanzo_mcp/tools/__init__.py +140 -31
- hanzo_mcp/tools/agent/__init__.py +85 -4
- hanzo_mcp/tools/agent/agent_tool.py +104 -6
- hanzo_mcp/tools/agent/agent_tool_v2.py +459 -0
- hanzo_mcp/tools/agent/clarification_protocol.py +220 -0
- hanzo_mcp/tools/agent/clarification_tool.py +68 -0
- hanzo_mcp/tools/agent/claude_cli_tool.py +125 -0
- hanzo_mcp/tools/agent/claude_desktop_auth.py +508 -0
- hanzo_mcp/tools/agent/cli_agent_base.py +191 -0
- hanzo_mcp/tools/agent/code_auth.py +436 -0
- hanzo_mcp/tools/agent/code_auth_tool.py +194 -0
- hanzo_mcp/tools/agent/codex_cli_tool.py +123 -0
- hanzo_mcp/tools/agent/critic_tool.py +376 -0
- hanzo_mcp/tools/agent/gemini_cli_tool.py +128 -0
- hanzo_mcp/tools/agent/grok_cli_tool.py +128 -0
- hanzo_mcp/tools/agent/iching_tool.py +380 -0
- hanzo_mcp/tools/agent/network_tool.py +273 -0
- hanzo_mcp/tools/agent/prompt.py +62 -20
- hanzo_mcp/tools/agent/review_tool.py +433 -0
- hanzo_mcp/tools/agent/swarm_tool.py +535 -0
- hanzo_mcp/tools/agent/swarm_tool_v2.py +594 -0
- hanzo_mcp/tools/common/__init__.py +15 -1
- hanzo_mcp/tools/common/base.py +5 -4
- hanzo_mcp/tools/common/batch_tool.py +103 -11
- hanzo_mcp/tools/common/config_tool.py +2 -2
- hanzo_mcp/tools/common/context.py +2 -2
- hanzo_mcp/tools/common/context_fix.py +26 -0
- hanzo_mcp/tools/common/critic_tool.py +196 -0
- hanzo_mcp/tools/common/decorators.py +208 -0
- hanzo_mcp/tools/common/enhanced_base.py +106 -0
- hanzo_mcp/tools/common/fastmcp_pagination.py +369 -0
- hanzo_mcp/tools/common/forgiving_edit.py +243 -0
- hanzo_mcp/tools/common/mode.py +116 -0
- hanzo_mcp/tools/common/mode_loader.py +105 -0
- hanzo_mcp/tools/common/paginated_base.py +230 -0
- hanzo_mcp/tools/common/paginated_response.py +307 -0
- hanzo_mcp/tools/common/pagination.py +226 -0
- hanzo_mcp/tools/common/permissions.py +1 -1
- hanzo_mcp/tools/common/personality.py +936 -0
- hanzo_mcp/tools/common/plugin_loader.py +287 -0
- hanzo_mcp/tools/common/stats.py +4 -4
- hanzo_mcp/tools/common/tool_list.py +4 -1
- hanzo_mcp/tools/common/truncate.py +101 -0
- hanzo_mcp/tools/common/validation.py +1 -1
- hanzo_mcp/tools/config/__init__.py +3 -1
- hanzo_mcp/tools/config/config_tool.py +1 -1
- hanzo_mcp/tools/config/mode_tool.py +209 -0
- hanzo_mcp/tools/database/__init__.py +1 -1
- hanzo_mcp/tools/editor/__init__.py +1 -1
- hanzo_mcp/tools/filesystem/__init__.py +48 -14
- hanzo_mcp/tools/filesystem/ast_multi_edit.py +562 -0
- hanzo_mcp/tools/filesystem/batch_search.py +3 -3
- hanzo_mcp/tools/filesystem/diff.py +2 -2
- hanzo_mcp/tools/filesystem/directory_tree_paginated.py +338 -0
- hanzo_mcp/tools/filesystem/rules_tool.py +235 -0
- hanzo_mcp/tools/filesystem/{unified_search.py → search_tool.py} +12 -12
- hanzo_mcp/tools/filesystem/{symbols_unified.py → symbols_tool.py} +104 -5
- hanzo_mcp/tools/filesystem/watch.py +3 -2
- hanzo_mcp/tools/jupyter/__init__.py +2 -2
- hanzo_mcp/tools/jupyter/jupyter.py +1 -1
- hanzo_mcp/tools/llm/__init__.py +3 -3
- hanzo_mcp/tools/llm/llm_tool.py +648 -143
- hanzo_mcp/tools/lsp/__init__.py +5 -0
- hanzo_mcp/tools/lsp/lsp_tool.py +512 -0
- hanzo_mcp/tools/mcp/__init__.py +2 -2
- hanzo_mcp/tools/mcp/{mcp_unified.py → mcp_tool.py} +3 -3
- hanzo_mcp/tools/memory/__init__.py +76 -0
- hanzo_mcp/tools/memory/knowledge_tools.py +518 -0
- hanzo_mcp/tools/memory/memory_tools.py +456 -0
- hanzo_mcp/tools/search/__init__.py +6 -0
- hanzo_mcp/tools/search/find_tool.py +581 -0
- hanzo_mcp/tools/search/unified_search.py +953 -0
- hanzo_mcp/tools/shell/__init__.py +11 -6
- hanzo_mcp/tools/shell/auto_background.py +203 -0
- hanzo_mcp/tools/shell/base_process.py +57 -29
- hanzo_mcp/tools/shell/bash_session_executor.py +1 -1
- hanzo_mcp/tools/shell/{bash_unified.py → bash_tool.py} +18 -34
- hanzo_mcp/tools/shell/command_executor.py +2 -2
- hanzo_mcp/tools/shell/{npx_unified.py → npx_tool.py} +16 -33
- hanzo_mcp/tools/shell/open.py +2 -2
- hanzo_mcp/tools/shell/{process_unified.py → process_tool.py} +1 -1
- hanzo_mcp/tools/shell/run_command_windows.py +1 -1
- hanzo_mcp/tools/shell/streaming_command.py +594 -0
- hanzo_mcp/tools/shell/uvx.py +47 -2
- hanzo_mcp/tools/shell/uvx_background.py +47 -2
- hanzo_mcp/tools/shell/{uvx_unified.py → uvx_tool.py} +16 -33
- hanzo_mcp/tools/todo/__init__.py +14 -19
- hanzo_mcp/tools/todo/todo.py +22 -1
- hanzo_mcp/tools/vector/__init__.py +1 -1
- hanzo_mcp/tools/vector/infinity_store.py +2 -2
- hanzo_mcp/tools/vector/project_manager.py +1 -1
- hanzo_mcp/types.py +23 -0
- hanzo_mcp-0.7.0.dist-info/METADATA +516 -0
- hanzo_mcp-0.7.0.dist-info/RECORD +180 -0
- {hanzo_mcp-0.6.12.dist-info → hanzo_mcp-0.7.0.dist-info}/entry_points.txt +1 -0
- hanzo_mcp/tools/common/palette.py +0 -344
- hanzo_mcp/tools/common/palette_loader.py +0 -108
- hanzo_mcp/tools/config/palette_tool.py +0 -179
- hanzo_mcp/tools/llm/llm_unified.py +0 -851
- hanzo_mcp-0.6.12.dist-info/METADATA +0 -339
- hanzo_mcp-0.6.12.dist-info/RECORD +0 -135
- hanzo_mcp-0.6.12.dist-info/licenses/LICENSE +0 -21
- {hanzo_mcp-0.6.12.dist-info → hanzo_mcp-0.7.0.dist-info}/WHEEL +0 -0
- {hanzo_mcp-0.6.12.dist-info → hanzo_mcp-0.7.0.dist-info}/top_level.txt +0 -0
|
@@ -0,0 +1,243 @@
|
|
|
1
|
+
"""Forgiving edit helper for AI-friendly text matching."""
|
|
2
|
+
|
|
3
|
+
import difflib
|
|
4
|
+
import re
|
|
5
|
+
from typing import List, Optional, Tuple
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class ForgivingEditHelper:
|
|
9
|
+
"""Helper class to make text editing more forgiving for AI usage.
|
|
10
|
+
|
|
11
|
+
This helper normalizes whitespace, handles partial matches, and provides
|
|
12
|
+
suggestions when exact matches fail.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
@staticmethod
|
|
16
|
+
def normalize_whitespace(text: str) -> str:
|
|
17
|
+
"""Normalize whitespace while preserving structure.
|
|
18
|
+
|
|
19
|
+
Args:
|
|
20
|
+
text: Text to normalize
|
|
21
|
+
|
|
22
|
+
Returns:
|
|
23
|
+
Text with normalized whitespace
|
|
24
|
+
"""
|
|
25
|
+
# Replace tabs with spaces for comparison
|
|
26
|
+
text = text.replace('\t', ' ')
|
|
27
|
+
|
|
28
|
+
# Normalize multiple spaces to single space (except at line start)
|
|
29
|
+
lines = []
|
|
30
|
+
for line in text.split('\n'):
|
|
31
|
+
# Preserve indentation
|
|
32
|
+
indent = len(line) - len(line.lstrip())
|
|
33
|
+
content = line.strip()
|
|
34
|
+
if content:
|
|
35
|
+
# Normalize spaces in content
|
|
36
|
+
content = ' '.join(content.split())
|
|
37
|
+
lines.append(' ' * indent + content)
|
|
38
|
+
|
|
39
|
+
return '\n'.join(lines)
|
|
40
|
+
|
|
41
|
+
@staticmethod
|
|
42
|
+
def find_fuzzy_match(
|
|
43
|
+
haystack: str,
|
|
44
|
+
needle: str,
|
|
45
|
+
threshold: float = 0.85
|
|
46
|
+
) -> Optional[Tuple[int, int, str]]:
|
|
47
|
+
"""Find a fuzzy match for the needle in the haystack.
|
|
48
|
+
|
|
49
|
+
Args:
|
|
50
|
+
haystack: Text to search in
|
|
51
|
+
needle: Text to search for
|
|
52
|
+
threshold: Similarity threshold (0-1)
|
|
53
|
+
|
|
54
|
+
Returns:
|
|
55
|
+
Tuple of (start_pos, end_pos, matched_text) or None
|
|
56
|
+
"""
|
|
57
|
+
# First try exact match
|
|
58
|
+
if needle in haystack:
|
|
59
|
+
start = haystack.index(needle)
|
|
60
|
+
return (start, start + len(needle), needle)
|
|
61
|
+
|
|
62
|
+
# Normalize for comparison
|
|
63
|
+
norm_haystack = ForgivingEditHelper.normalize_whitespace(haystack)
|
|
64
|
+
norm_needle = ForgivingEditHelper.normalize_whitespace(needle)
|
|
65
|
+
|
|
66
|
+
# Try normalized exact match
|
|
67
|
+
if norm_needle in norm_haystack:
|
|
68
|
+
# Find the match in normalized text
|
|
69
|
+
norm_start = norm_haystack.index(norm_needle)
|
|
70
|
+
|
|
71
|
+
# Map back to original text
|
|
72
|
+
# This is approximate but usually good enough
|
|
73
|
+
lines_before = norm_haystack[:norm_start].count('\n')
|
|
74
|
+
|
|
75
|
+
# Find corresponding position in original
|
|
76
|
+
original_lines = haystack.split('\n')
|
|
77
|
+
norm_lines = norm_haystack.split('\n')
|
|
78
|
+
|
|
79
|
+
start_pos = sum(len(line) + 1 for line in original_lines[:lines_before])
|
|
80
|
+
|
|
81
|
+
# Find end position by counting lines in needle
|
|
82
|
+
needle_lines = norm_needle.count('\n') + 1
|
|
83
|
+
end_pos = sum(len(line) + 1 for line in original_lines[:lines_before + needle_lines])
|
|
84
|
+
|
|
85
|
+
matched = '\n'.join(original_lines[lines_before:lines_before + needle_lines])
|
|
86
|
+
return (start_pos, end_pos - 1, matched)
|
|
87
|
+
|
|
88
|
+
# Try fuzzy matching on lines
|
|
89
|
+
haystack_lines = haystack.split('\n')
|
|
90
|
+
needle_lines = needle.split('\n')
|
|
91
|
+
|
|
92
|
+
if len(needle_lines) == 1:
|
|
93
|
+
# Single line - find best match
|
|
94
|
+
needle_norm = ForgivingEditHelper.normalize_whitespace(needle)
|
|
95
|
+
best_ratio = 0
|
|
96
|
+
best_match = None
|
|
97
|
+
|
|
98
|
+
for i, line in enumerate(haystack_lines):
|
|
99
|
+
line_norm = ForgivingEditHelper.normalize_whitespace(line)
|
|
100
|
+
ratio = difflib.SequenceMatcher(None, line_norm, needle_norm).ratio()
|
|
101
|
+
|
|
102
|
+
if ratio > best_ratio and ratio >= threshold:
|
|
103
|
+
best_ratio = ratio
|
|
104
|
+
start_pos = sum(len(l) + 1 for l in haystack_lines[:i])
|
|
105
|
+
best_match = (start_pos, start_pos + len(line), line)
|
|
106
|
+
|
|
107
|
+
return best_match
|
|
108
|
+
|
|
109
|
+
else:
|
|
110
|
+
# Multi-line - find sequence match
|
|
111
|
+
for i in range(len(haystack_lines) - len(needle_lines) + 1):
|
|
112
|
+
candidate_lines = haystack_lines[i:i + len(needle_lines)]
|
|
113
|
+
candidate = '\n'.join(candidate_lines)
|
|
114
|
+
candidate_norm = ForgivingEditHelper.normalize_whitespace(candidate)
|
|
115
|
+
needle_norm = ForgivingEditHelper.normalize_whitespace(needle)
|
|
116
|
+
|
|
117
|
+
ratio = difflib.SequenceMatcher(None, candidate_norm, needle_norm).ratio()
|
|
118
|
+
|
|
119
|
+
if ratio >= threshold:
|
|
120
|
+
start_pos = sum(len(l) + 1 for l in haystack_lines[:i])
|
|
121
|
+
return (start_pos, start_pos + len(candidate), candidate)
|
|
122
|
+
|
|
123
|
+
return None
|
|
124
|
+
|
|
125
|
+
@staticmethod
|
|
126
|
+
def suggest_matches(
|
|
127
|
+
haystack: str,
|
|
128
|
+
needle: str,
|
|
129
|
+
max_suggestions: int = 3
|
|
130
|
+
) -> List[Tuple[float, str]]:
|
|
131
|
+
"""Suggest possible matches when exact match fails.
|
|
132
|
+
|
|
133
|
+
Args:
|
|
134
|
+
haystack: Text to search in
|
|
135
|
+
needle: Text to search for
|
|
136
|
+
max_suggestions: Maximum number of suggestions
|
|
137
|
+
|
|
138
|
+
Returns:
|
|
139
|
+
List of (similarity_score, text) tuples
|
|
140
|
+
"""
|
|
141
|
+
suggestions = []
|
|
142
|
+
|
|
143
|
+
# Normalize needle
|
|
144
|
+
needle_norm = ForgivingEditHelper.normalize_whitespace(needle)
|
|
145
|
+
needle_lines = needle.split('\n')
|
|
146
|
+
|
|
147
|
+
if len(needle_lines) == 1:
|
|
148
|
+
# Single line - compare with all lines
|
|
149
|
+
for line in haystack.split('\n'):
|
|
150
|
+
if line.strip(): # Skip empty lines
|
|
151
|
+
line_norm = ForgivingEditHelper.normalize_whitespace(line)
|
|
152
|
+
ratio = difflib.SequenceMatcher(None, line_norm, needle_norm).ratio()
|
|
153
|
+
if ratio > 0.5: # Only reasonably similar lines
|
|
154
|
+
suggestions.append((ratio, line))
|
|
155
|
+
|
|
156
|
+
else:
|
|
157
|
+
# Multi-line - use sliding window
|
|
158
|
+
haystack_lines = haystack.split('\n')
|
|
159
|
+
window_size = len(needle_lines)
|
|
160
|
+
|
|
161
|
+
for i in range(len(haystack_lines) - window_size + 1):
|
|
162
|
+
candidate_lines = haystack_lines[i:i + window_size]
|
|
163
|
+
candidate = '\n'.join(candidate_lines)
|
|
164
|
+
candidate_norm = ForgivingEditHelper.normalize_whitespace(candidate)
|
|
165
|
+
|
|
166
|
+
ratio = difflib.SequenceMatcher(None, candidate_norm, needle_norm).ratio()
|
|
167
|
+
if ratio > 0.5:
|
|
168
|
+
suggestions.append((ratio, candidate))
|
|
169
|
+
|
|
170
|
+
# Sort by similarity and return top matches
|
|
171
|
+
suggestions.sort(reverse=True, key=lambda x: x[0])
|
|
172
|
+
return suggestions[:max_suggestions]
|
|
173
|
+
|
|
174
|
+
@staticmethod
|
|
175
|
+
def create_edit_suggestion(
|
|
176
|
+
file_content: str,
|
|
177
|
+
old_string: str,
|
|
178
|
+
new_string: str
|
|
179
|
+
) -> dict:
|
|
180
|
+
"""Create a helpful edit suggestion when match fails.
|
|
181
|
+
|
|
182
|
+
Args:
|
|
183
|
+
file_content: Current file content
|
|
184
|
+
old_string: String that couldn't be found
|
|
185
|
+
new_string: Replacement string
|
|
186
|
+
|
|
187
|
+
Returns:
|
|
188
|
+
Dict with error message and suggestions
|
|
189
|
+
"""
|
|
190
|
+
# Try fuzzy match
|
|
191
|
+
fuzzy_match = ForgivingEditHelper.find_fuzzy_match(file_content, old_string)
|
|
192
|
+
|
|
193
|
+
if fuzzy_match:
|
|
194
|
+
_, _, matched_text = fuzzy_match
|
|
195
|
+
return {
|
|
196
|
+
"error": "Exact match not found, but found similar text",
|
|
197
|
+
"found": matched_text,
|
|
198
|
+
"suggestion": "Use this as old_string instead",
|
|
199
|
+
"confidence": "high"
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
# Get suggestions
|
|
203
|
+
suggestions = ForgivingEditHelper.suggest_matches(file_content, old_string)
|
|
204
|
+
|
|
205
|
+
if suggestions:
|
|
206
|
+
return {
|
|
207
|
+
"error": "Could not find exact or fuzzy match",
|
|
208
|
+
"suggestions": [
|
|
209
|
+
{"similarity": f"{score:.0%}", "text": text}
|
|
210
|
+
for score, text in suggestions
|
|
211
|
+
],
|
|
212
|
+
"hint": "Try using one of these suggestions as old_string"
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
# No good matches - provide general help
|
|
216
|
+
return {
|
|
217
|
+
"error": "Could not find any matches",
|
|
218
|
+
"hints": [
|
|
219
|
+
"Check for whitespace differences (tabs vs spaces)",
|
|
220
|
+
"Ensure you're including complete lines",
|
|
221
|
+
"Try a smaller, more unique portion of text",
|
|
222
|
+
"Use the streaming_command tool to view the file with visible whitespace"
|
|
223
|
+
]
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
@staticmethod
|
|
227
|
+
def prepare_edit_string(text: str) -> str:
|
|
228
|
+
"""Prepare a string for editing by handling common issues.
|
|
229
|
+
|
|
230
|
+
Args:
|
|
231
|
+
text: Text to prepare
|
|
232
|
+
|
|
233
|
+
Returns:
|
|
234
|
+
Cleaned text ready for editing
|
|
235
|
+
"""
|
|
236
|
+
# Remove any line number prefixes (common in AI copy-paste)
|
|
237
|
+
lines = []
|
|
238
|
+
for line in text.split('\n'):
|
|
239
|
+
# Remove common line number patterns
|
|
240
|
+
cleaned = re.sub(r'^\s*\d+[:\|\-\s]\s*', '', line)
|
|
241
|
+
lines.append(cleaned)
|
|
242
|
+
|
|
243
|
+
return '\n'.join(lines)
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
"""Mode system for organizing development tools based on programmer personalities."""
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
from dataclasses import dataclass
|
|
5
|
+
from typing import Dict, List, Optional, Set
|
|
6
|
+
|
|
7
|
+
from hanzo_mcp.tools.common.personality import (
|
|
8
|
+
ToolPersonality,
|
|
9
|
+
register_default_personalities,
|
|
10
|
+
ensure_agent_enabled,
|
|
11
|
+
personalities
|
|
12
|
+
)
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@dataclass
|
|
16
|
+
class Mode(ToolPersonality):
|
|
17
|
+
"""Development mode combining tool preferences and environment settings."""
|
|
18
|
+
# Inherits all fields from ToolPersonality
|
|
19
|
+
# Adds mode-specific functionality
|
|
20
|
+
|
|
21
|
+
@property
|
|
22
|
+
def is_active(self) -> bool:
|
|
23
|
+
"""Check if this mode is currently active."""
|
|
24
|
+
return ModeRegistry.get_active() == self
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class ModeRegistry:
|
|
28
|
+
"""Registry for development modes."""
|
|
29
|
+
|
|
30
|
+
_modes: Dict[str, Mode] = {}
|
|
31
|
+
_active_mode: Optional[str] = None
|
|
32
|
+
|
|
33
|
+
@classmethod
|
|
34
|
+
def register(cls, mode: Mode) -> None:
|
|
35
|
+
"""Register a development mode."""
|
|
36
|
+
# Ensure agent is enabled if API keys present
|
|
37
|
+
mode = ensure_agent_enabled(mode)
|
|
38
|
+
cls._modes[mode.name] = mode
|
|
39
|
+
|
|
40
|
+
@classmethod
|
|
41
|
+
def get(cls, name: str) -> Optional[Mode]:
|
|
42
|
+
"""Get a mode by name."""
|
|
43
|
+
return cls._modes.get(name)
|
|
44
|
+
|
|
45
|
+
@classmethod
|
|
46
|
+
def list(cls) -> List[Mode]:
|
|
47
|
+
"""List all registered modes."""
|
|
48
|
+
return list(cls._modes.values())
|
|
49
|
+
|
|
50
|
+
@classmethod
|
|
51
|
+
def set_active(cls, name: str) -> None:
|
|
52
|
+
"""Set the active mode."""
|
|
53
|
+
if name not in cls._modes:
|
|
54
|
+
raise ValueError(f"Mode '{name}' not found")
|
|
55
|
+
cls._active_mode = name
|
|
56
|
+
|
|
57
|
+
# Apply environment variables from the mode
|
|
58
|
+
mode = cls._modes[name]
|
|
59
|
+
if mode.environment:
|
|
60
|
+
for key, value in mode.environment.items():
|
|
61
|
+
os.environ[key] = value
|
|
62
|
+
|
|
63
|
+
@classmethod
|
|
64
|
+
def get_active(cls) -> Optional[Mode]:
|
|
65
|
+
"""Get the active mode."""
|
|
66
|
+
if cls._active_mode:
|
|
67
|
+
return cls._modes.get(cls._active_mode)
|
|
68
|
+
return None
|
|
69
|
+
|
|
70
|
+
@classmethod
|
|
71
|
+
def get_active_tools(cls) -> Set[str]:
|
|
72
|
+
"""Get the set of tools from the active mode."""
|
|
73
|
+
mode = cls.get_active()
|
|
74
|
+
if mode:
|
|
75
|
+
return set(mode.tools)
|
|
76
|
+
return set()
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def register_default_modes():
|
|
80
|
+
"""Register all default development modes."""
|
|
81
|
+
# Convert personalities to modes
|
|
82
|
+
for personality in personalities:
|
|
83
|
+
mode = Mode(
|
|
84
|
+
name=personality.name,
|
|
85
|
+
programmer=personality.programmer,
|
|
86
|
+
description=personality.description,
|
|
87
|
+
tools=personality.tools,
|
|
88
|
+
environment=personality.environment,
|
|
89
|
+
philosophy=personality.philosophy,
|
|
90
|
+
)
|
|
91
|
+
ModeRegistry.register(mode)
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def get_mode_from_env() -> Optional[str]:
|
|
95
|
+
"""Get mode name from environment variables."""
|
|
96
|
+
# Check for HANZO_MODE, PERSONALITY, or MODE env vars
|
|
97
|
+
return (
|
|
98
|
+
os.environ.get("HANZO_MODE") or
|
|
99
|
+
os.environ.get("PERSONALITY") or
|
|
100
|
+
os.environ.get("MODE")
|
|
101
|
+
)
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def activate_mode_from_env():
|
|
105
|
+
"""Activate mode based on environment variables."""
|
|
106
|
+
mode_name = get_mode_from_env()
|
|
107
|
+
if mode_name:
|
|
108
|
+
try:
|
|
109
|
+
ModeRegistry.set_active(mode_name)
|
|
110
|
+
return True
|
|
111
|
+
except ValueError:
|
|
112
|
+
# Mode not found, ignore
|
|
113
|
+
pass
|
|
114
|
+
return False
|
|
115
|
+
|
|
116
|
+
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
"""Tool mode loader for dynamic tool configuration."""
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
from typing import Dict, List, Optional, Set
|
|
5
|
+
|
|
6
|
+
from hanzo_mcp.tools.common.mode import ModeRegistry, register_default_modes, activate_mode_from_env
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class ModeLoader:
|
|
10
|
+
"""Loads and manages tool modes for dynamic configuration."""
|
|
11
|
+
|
|
12
|
+
@staticmethod
|
|
13
|
+
def initialize_modes() -> None:
|
|
14
|
+
"""Initialize the mode system with defaults."""
|
|
15
|
+
# Initialize modes
|
|
16
|
+
register_default_modes()
|
|
17
|
+
|
|
18
|
+
# Check for mode from environment
|
|
19
|
+
activate_mode_from_env()
|
|
20
|
+
|
|
21
|
+
# If no mode set, use default
|
|
22
|
+
if not ModeRegistry.get_active():
|
|
23
|
+
default_mode = os.environ.get("HANZO_DEFAULT_MODE", "hanzo")
|
|
24
|
+
if ModeRegistry.get(default_mode):
|
|
25
|
+
ModeRegistry.set_active(default_mode)
|
|
26
|
+
|
|
27
|
+
@staticmethod
|
|
28
|
+
def get_enabled_tools_from_mode(
|
|
29
|
+
base_enabled_tools: Optional[Dict[str, bool]] = None,
|
|
30
|
+
force_mode: Optional[str] = None
|
|
31
|
+
) -> Dict[str, bool]:
|
|
32
|
+
"""Get enabled tools configuration from active mode.
|
|
33
|
+
|
|
34
|
+
Args:
|
|
35
|
+
base_enabled_tools: Base configuration to merge with
|
|
36
|
+
force_mode: Force a specific mode (overrides active)
|
|
37
|
+
|
|
38
|
+
Returns:
|
|
39
|
+
Dictionary of tool enable states
|
|
40
|
+
"""
|
|
41
|
+
# Initialize if needed
|
|
42
|
+
if not ModeRegistry.list():
|
|
43
|
+
ModeLoader.initialize_modes()
|
|
44
|
+
|
|
45
|
+
# Get mode to use
|
|
46
|
+
tools_list = None
|
|
47
|
+
|
|
48
|
+
if force_mode:
|
|
49
|
+
# Set and get mode
|
|
50
|
+
if ModeRegistry.get(force_mode):
|
|
51
|
+
ModeRegistry.set_active(force_mode)
|
|
52
|
+
mode = ModeRegistry.get_active()
|
|
53
|
+
tools_list = mode.tools if mode else None
|
|
54
|
+
else:
|
|
55
|
+
# Check active mode
|
|
56
|
+
mode = ModeRegistry.get_active()
|
|
57
|
+
if mode:
|
|
58
|
+
tools_list = mode.tools
|
|
59
|
+
|
|
60
|
+
if not tools_list:
|
|
61
|
+
# No active mode, return base config
|
|
62
|
+
return base_enabled_tools or {}
|
|
63
|
+
|
|
64
|
+
# Start with base configuration
|
|
65
|
+
result = base_enabled_tools.copy() if base_enabled_tools else {}
|
|
66
|
+
|
|
67
|
+
# Get all possible tools from registry
|
|
68
|
+
from hanzo_mcp.config.tool_config import TOOL_REGISTRY
|
|
69
|
+
all_possible_tools = set(TOOL_REGISTRY.keys())
|
|
70
|
+
|
|
71
|
+
# Disable all tools first (clean slate for mode)
|
|
72
|
+
for tool in all_possible_tools:
|
|
73
|
+
result[tool] = False
|
|
74
|
+
|
|
75
|
+
# Enable tools from mode
|
|
76
|
+
for tool in tools_list:
|
|
77
|
+
result[tool] = True
|
|
78
|
+
|
|
79
|
+
# Always enable mode tool (meta)
|
|
80
|
+
result["mode"] = True
|
|
81
|
+
|
|
82
|
+
return result
|
|
83
|
+
|
|
84
|
+
@staticmethod
|
|
85
|
+
def get_environment_from_mode() -> Dict[str, str]:
|
|
86
|
+
"""Get environment variables from active mode.
|
|
87
|
+
|
|
88
|
+
Returns:
|
|
89
|
+
Dictionary of environment variables
|
|
90
|
+
"""
|
|
91
|
+
# Check mode
|
|
92
|
+
mode = ModeRegistry.get_active()
|
|
93
|
+
if mode and mode.environment:
|
|
94
|
+
return mode.environment.copy()
|
|
95
|
+
|
|
96
|
+
return {}
|
|
97
|
+
|
|
98
|
+
@staticmethod
|
|
99
|
+
def apply_environment_from_mode() -> None:
|
|
100
|
+
"""Apply environment variables from active mode."""
|
|
101
|
+
env_vars = ModeLoader.get_environment_from_mode()
|
|
102
|
+
for key, value in env_vars.items():
|
|
103
|
+
os.environ[key] = value
|
|
104
|
+
|
|
105
|
+
|
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
"""Enhanced base class with automatic pagination support.
|
|
2
|
+
|
|
3
|
+
This module provides a base class that automatically handles pagination
|
|
4
|
+
for all tool responses that exceed MCP token limits.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import json
|
|
8
|
+
from abc import abstractmethod
|
|
9
|
+
from typing import Any, Dict, Optional, Union
|
|
10
|
+
|
|
11
|
+
from mcp.server.fastmcp import Context as MCPContext
|
|
12
|
+
|
|
13
|
+
from hanzo_mcp.tools.common.base import BaseTool, handle_connection_errors
|
|
14
|
+
from hanzo_mcp.tools.common.paginated_response import paginate_if_needed
|
|
15
|
+
from hanzo_mcp.tools.common.pagination import CursorManager
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class PaginatedBaseTool(BaseTool):
|
|
19
|
+
"""Base class for tools with automatic pagination support.
|
|
20
|
+
|
|
21
|
+
This base class automatically handles pagination for responses that
|
|
22
|
+
exceed MCP token limits, making all tools pagination-aware by default.
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
def __init__(self):
|
|
26
|
+
"""Initialize the paginated base tool."""
|
|
27
|
+
super().__init__()
|
|
28
|
+
self._supports_pagination = True
|
|
29
|
+
|
|
30
|
+
@abstractmethod
|
|
31
|
+
async def execute(self, ctx: MCPContext, **params: Any) -> Any:
|
|
32
|
+
"""Execute the tool logic and return raw results.
|
|
33
|
+
|
|
34
|
+
This method should be implemented by subclasses to perform the
|
|
35
|
+
actual tool logic. The base class will handle pagination of
|
|
36
|
+
the returned results automatically.
|
|
37
|
+
|
|
38
|
+
Args:
|
|
39
|
+
ctx: MCP context
|
|
40
|
+
**params: Tool parameters
|
|
41
|
+
|
|
42
|
+
Returns:
|
|
43
|
+
Raw tool results (will be paginated if needed)
|
|
44
|
+
"""
|
|
45
|
+
pass
|
|
46
|
+
|
|
47
|
+
@handle_connection_errors
|
|
48
|
+
async def call(self, ctx: MCPContext, **params: Any) -> Union[str, Dict[str, Any]]:
|
|
49
|
+
"""Execute the tool with automatic pagination support.
|
|
50
|
+
|
|
51
|
+
This method wraps the execute() method and automatically handles
|
|
52
|
+
pagination if the response exceeds token limits.
|
|
53
|
+
|
|
54
|
+
Args:
|
|
55
|
+
ctx: MCP context
|
|
56
|
+
**params: Tool parameters including optional 'cursor'
|
|
57
|
+
|
|
58
|
+
Returns:
|
|
59
|
+
Tool result, potentially paginated
|
|
60
|
+
"""
|
|
61
|
+
# Extract cursor if provided
|
|
62
|
+
cursor = params.pop("cursor", None)
|
|
63
|
+
|
|
64
|
+
# Validate cursor if provided
|
|
65
|
+
if cursor and not CursorManager.parse_cursor(cursor):
|
|
66
|
+
return {"error": "Invalid cursor provided", "code": -32602}
|
|
67
|
+
|
|
68
|
+
# Check if this is a continuation request
|
|
69
|
+
if cursor:
|
|
70
|
+
# For continuation, check if we have cached results
|
|
71
|
+
cursor_data = CursorManager.parse_cursor(cursor)
|
|
72
|
+
if cursor_data and "tool" in cursor_data and cursor_data["tool"] != self.name:
|
|
73
|
+
return {"error": "Cursor is for a different tool", "code": -32602}
|
|
74
|
+
|
|
75
|
+
# Execute the tool
|
|
76
|
+
try:
|
|
77
|
+
result = await self.execute(ctx, **params)
|
|
78
|
+
except Exception as e:
|
|
79
|
+
# Format errors consistently
|
|
80
|
+
return {"error": str(e), "type": type(e).__name__}
|
|
81
|
+
|
|
82
|
+
# Handle pagination automatically
|
|
83
|
+
if self._supports_pagination:
|
|
84
|
+
paginated_result = paginate_if_needed(result, cursor)
|
|
85
|
+
|
|
86
|
+
# If pagination occurred, add tool info to help with continuation
|
|
87
|
+
if isinstance(paginated_result, dict) and "nextCursor" in paginated_result:
|
|
88
|
+
# Enhance the cursor with tool information
|
|
89
|
+
if "nextCursor" in paginated_result:
|
|
90
|
+
cursor_data = CursorManager.parse_cursor(paginated_result["nextCursor"])
|
|
91
|
+
if cursor_data:
|
|
92
|
+
cursor_data["tool"] = self.name
|
|
93
|
+
cursor_data["params"] = params # Store params for continuation
|
|
94
|
+
paginated_result["nextCursor"] = CursorManager.create_cursor(cursor_data)
|
|
95
|
+
|
|
96
|
+
return paginated_result
|
|
97
|
+
else:
|
|
98
|
+
# Return raw result if pagination is disabled
|
|
99
|
+
return result
|
|
100
|
+
|
|
101
|
+
def disable_pagination(self):
|
|
102
|
+
"""Disable automatic pagination for this tool.
|
|
103
|
+
|
|
104
|
+
Some tools may want to handle their own pagination logic.
|
|
105
|
+
"""
|
|
106
|
+
self._supports_pagination = False
|
|
107
|
+
|
|
108
|
+
def enable_pagination(self):
|
|
109
|
+
"""Re-enable automatic pagination for this tool."""
|
|
110
|
+
self._supports_pagination = True
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
class PaginatedFileSystemTool(PaginatedBaseTool):
|
|
114
|
+
"""Base class for filesystem tools with pagination support."""
|
|
115
|
+
|
|
116
|
+
def __init__(self, permission_manager):
|
|
117
|
+
"""Initialize filesystem tool with pagination.
|
|
118
|
+
|
|
119
|
+
Args:
|
|
120
|
+
permission_manager: Permission manager for access control
|
|
121
|
+
"""
|
|
122
|
+
super().__init__()
|
|
123
|
+
self.permission_manager = permission_manager
|
|
124
|
+
|
|
125
|
+
def is_path_allowed(self, path: str) -> bool:
|
|
126
|
+
"""Check if a path is allowed according to permission settings.
|
|
127
|
+
|
|
128
|
+
Args:
|
|
129
|
+
path: Path to check
|
|
130
|
+
|
|
131
|
+
Returns:
|
|
132
|
+
True if the path is allowed, False otherwise
|
|
133
|
+
"""
|
|
134
|
+
return self.permission_manager.is_path_allowed(path)
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def migrate_tool_to_paginated(tool_class):
|
|
138
|
+
"""Decorator to migrate existing tools to use pagination.
|
|
139
|
+
|
|
140
|
+
This decorator can be applied to existing tool classes to add
|
|
141
|
+
automatic pagination support without modifying their code.
|
|
142
|
+
|
|
143
|
+
Usage:
|
|
144
|
+
@migrate_tool_to_paginated
|
|
145
|
+
class MyTool(BaseTool):
|
|
146
|
+
...
|
|
147
|
+
"""
|
|
148
|
+
class PaginatedWrapper(PaginatedBaseTool):
|
|
149
|
+
def __init__(self, *args, **kwargs):
|
|
150
|
+
super().__init__()
|
|
151
|
+
self._wrapped_tool = tool_class(*args, **kwargs)
|
|
152
|
+
|
|
153
|
+
@property
|
|
154
|
+
def name(self):
|
|
155
|
+
return self._wrapped_tool.name
|
|
156
|
+
|
|
157
|
+
@property
|
|
158
|
+
def description(self):
|
|
159
|
+
# Add pagination info to description
|
|
160
|
+
desc = self._wrapped_tool.description
|
|
161
|
+
if not "pagination" in desc.lower():
|
|
162
|
+
desc += "\n\nThis tool supports automatic pagination. If the response is too large, it will be split across multiple requests. Use the returned cursor to continue."
|
|
163
|
+
return desc
|
|
164
|
+
|
|
165
|
+
async def execute(self, ctx: MCPContext, **params: Any) -> Any:
|
|
166
|
+
# Call the wrapped tool's call method
|
|
167
|
+
return await self._wrapped_tool.call(ctx, **params)
|
|
168
|
+
|
|
169
|
+
def register(self, mcp_server):
|
|
170
|
+
# Need to create a new registration that includes cursor parameter
|
|
171
|
+
tool_self = self
|
|
172
|
+
|
|
173
|
+
# Get the original registration function
|
|
174
|
+
original_register = self._wrapped_tool.register
|
|
175
|
+
|
|
176
|
+
# Create a new registration that adds cursor support
|
|
177
|
+
def register_with_pagination(server):
|
|
178
|
+
# First register the original tool
|
|
179
|
+
original_register(server)
|
|
180
|
+
|
|
181
|
+
# Then override with pagination support
|
|
182
|
+
import inspect
|
|
183
|
+
|
|
184
|
+
# Get the registered function
|
|
185
|
+
tool_func = None
|
|
186
|
+
for name, func in server._tools.items():
|
|
187
|
+
if name == self.name:
|
|
188
|
+
tool_func = func
|
|
189
|
+
break
|
|
190
|
+
|
|
191
|
+
if tool_func:
|
|
192
|
+
# Get original signature
|
|
193
|
+
sig = inspect.signature(tool_func)
|
|
194
|
+
params = list(sig.parameters.values())
|
|
195
|
+
|
|
196
|
+
# Add cursor parameter if not present
|
|
197
|
+
has_cursor = any(p.name == "cursor" for p in params)
|
|
198
|
+
if not has_cursor:
|
|
199
|
+
from typing import Optional
|
|
200
|
+
import inspect
|
|
201
|
+
|
|
202
|
+
# Create new parameter with cursor
|
|
203
|
+
cursor_param = inspect.Parameter(
|
|
204
|
+
"cursor",
|
|
205
|
+
inspect.Parameter.KEYWORD_ONLY,
|
|
206
|
+
default=None,
|
|
207
|
+
annotation=Optional[str]
|
|
208
|
+
)
|
|
209
|
+
|
|
210
|
+
# Insert before ctx parameter
|
|
211
|
+
new_params = []
|
|
212
|
+
for p in params:
|
|
213
|
+
if p.name == "ctx":
|
|
214
|
+
new_params.append(cursor_param)
|
|
215
|
+
new_params.append(p)
|
|
216
|
+
|
|
217
|
+
# Create wrapper function
|
|
218
|
+
async def paginated_wrapper(**kwargs):
|
|
219
|
+
return await tool_self.call(kwargs.get("ctx"), **kwargs)
|
|
220
|
+
|
|
221
|
+
# Update registration
|
|
222
|
+
server._tools[self.name] = paginated_wrapper
|
|
223
|
+
|
|
224
|
+
register_with_pagination(mcp_server)
|
|
225
|
+
|
|
226
|
+
# Set the class name
|
|
227
|
+
PaginatedWrapper.__name__ = f"Paginated{tool_class.__name__}"
|
|
228
|
+
PaginatedWrapper.__qualname__ = f"Paginated{tool_class.__qualname__}"
|
|
229
|
+
|
|
230
|
+
return PaginatedWrapper
|