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,338 @@
|
|
|
1
|
+
"""Paginated directory tree tool implementation.
|
|
2
|
+
|
|
3
|
+
This module provides a paginated version of DirectoryTreeTool that supports
|
|
4
|
+
MCP cursor-based pagination for large directory structures.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Annotated, Any, Dict, List, Optional, TypedDict, Unpack, final, override
|
|
9
|
+
|
|
10
|
+
from mcp.server.fastmcp import Context as MCPContext
|
|
11
|
+
from mcp.server import FastMCP
|
|
12
|
+
from pydantic import Field
|
|
13
|
+
|
|
14
|
+
from hanzo_mcp.tools.filesystem.base import FilesystemBaseTool
|
|
15
|
+
from hanzo_mcp.tools.common.pagination import (
|
|
16
|
+
CursorManager,
|
|
17
|
+
PaginatedResponse,
|
|
18
|
+
paginate_list
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
DirectoryPath = Annotated[
|
|
22
|
+
str,
|
|
23
|
+
Field(
|
|
24
|
+
description="The path to the directory to view",
|
|
25
|
+
title="Path",
|
|
26
|
+
),
|
|
27
|
+
]
|
|
28
|
+
|
|
29
|
+
Depth = Annotated[
|
|
30
|
+
int,
|
|
31
|
+
Field(
|
|
32
|
+
default=3,
|
|
33
|
+
description="The maximum depth to traverse (0 for unlimited)",
|
|
34
|
+
title="Depth",
|
|
35
|
+
),
|
|
36
|
+
]
|
|
37
|
+
|
|
38
|
+
IncludeFiltered = Annotated[
|
|
39
|
+
bool,
|
|
40
|
+
Field(
|
|
41
|
+
default=False,
|
|
42
|
+
description="Include directories that are normally filtered",
|
|
43
|
+
title="Include Filtered",
|
|
44
|
+
),
|
|
45
|
+
]
|
|
46
|
+
|
|
47
|
+
PageSize = Annotated[
|
|
48
|
+
int,
|
|
49
|
+
Field(
|
|
50
|
+
default=100,
|
|
51
|
+
description="Number of entries per page",
|
|
52
|
+
title="Page Size",
|
|
53
|
+
),
|
|
54
|
+
]
|
|
55
|
+
|
|
56
|
+
Cursor = Annotated[
|
|
57
|
+
Optional[str],
|
|
58
|
+
Field(
|
|
59
|
+
default=None,
|
|
60
|
+
description="Pagination cursor for continuing from previous request",
|
|
61
|
+
title="Cursor",
|
|
62
|
+
),
|
|
63
|
+
]
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
class DirectoryTreePaginatedParams(TypedDict):
|
|
67
|
+
"""Parameters for the paginated DirectoryTreeTool.
|
|
68
|
+
|
|
69
|
+
Attributes:
|
|
70
|
+
path: The path to the directory to view
|
|
71
|
+
depth: The maximum depth to traverse (0 for unlimited)
|
|
72
|
+
include_filtered: Include directories that are normally filtered
|
|
73
|
+
page_size: Number of entries per page
|
|
74
|
+
cursor: Pagination cursor
|
|
75
|
+
"""
|
|
76
|
+
|
|
77
|
+
path: DirectoryPath
|
|
78
|
+
depth: Depth
|
|
79
|
+
include_filtered: IncludeFiltered
|
|
80
|
+
page_size: PageSize
|
|
81
|
+
cursor: Cursor
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
@final
|
|
85
|
+
class DirectoryTreePaginatedTool(FilesystemBaseTool):
|
|
86
|
+
"""Tool for viewing directory structure as a tree with pagination support."""
|
|
87
|
+
|
|
88
|
+
@property
|
|
89
|
+
@override
|
|
90
|
+
def name(self) -> str:
|
|
91
|
+
"""Get the tool name."""
|
|
92
|
+
return "directory_tree_paginated"
|
|
93
|
+
|
|
94
|
+
@property
|
|
95
|
+
@override
|
|
96
|
+
def description(self) -> str:
|
|
97
|
+
"""Get the tool description."""
|
|
98
|
+
return """Get a paginated recursive tree view of files and directories.
|
|
99
|
+
|
|
100
|
+
This is a paginated version of directory_tree that supports cursor-based pagination
|
|
101
|
+
for large directory structures. Returns a structured view with files and subdirectories.
|
|
102
|
+
|
|
103
|
+
Directories are marked with trailing slashes. Common development directories like
|
|
104
|
+
.git, node_modules, and venv are noted but not traversed unless explicitly requested.
|
|
105
|
+
|
|
106
|
+
Use the cursor field to continue from where the previous request left off.
|
|
107
|
+
Returns nextCursor if more entries are available."""
|
|
108
|
+
|
|
109
|
+
@override
|
|
110
|
+
async def call(
|
|
111
|
+
self,
|
|
112
|
+
ctx: MCPContext,
|
|
113
|
+
**params: Unpack[DirectoryTreePaginatedParams],
|
|
114
|
+
) -> Dict[str, Any]:
|
|
115
|
+
"""Execute the tool with the given parameters.
|
|
116
|
+
|
|
117
|
+
Args:
|
|
118
|
+
ctx: MCP context
|
|
119
|
+
**params: Tool parameters
|
|
120
|
+
|
|
121
|
+
Returns:
|
|
122
|
+
Dictionary with entries and optional nextCursor
|
|
123
|
+
"""
|
|
124
|
+
tool_ctx = self.create_tool_context(ctx)
|
|
125
|
+
|
|
126
|
+
# Extract parameters
|
|
127
|
+
path: DirectoryPath = params["path"]
|
|
128
|
+
depth = params.get("depth", 3)
|
|
129
|
+
include_filtered = params.get("include_filtered", False)
|
|
130
|
+
page_size = params.get("page_size", 100)
|
|
131
|
+
cursor = params.get("cursor", None)
|
|
132
|
+
|
|
133
|
+
# Validate cursor if provided
|
|
134
|
+
if cursor:
|
|
135
|
+
cursor_data = CursorManager.parse_cursor(cursor)
|
|
136
|
+
if not cursor_data:
|
|
137
|
+
await tool_ctx.error("Invalid cursor provided")
|
|
138
|
+
return {"error": "Invalid cursor"}
|
|
139
|
+
|
|
140
|
+
# Validate path parameter
|
|
141
|
+
path_validation = self.validate_path(path)
|
|
142
|
+
if path_validation.is_error:
|
|
143
|
+
await tool_ctx.error(path_validation.error_message)
|
|
144
|
+
return {"error": path_validation.error_message}
|
|
145
|
+
|
|
146
|
+
await tool_ctx.info(
|
|
147
|
+
f"Getting paginated directory tree: {path} (depth: {depth}, page_size: {page_size})"
|
|
148
|
+
)
|
|
149
|
+
|
|
150
|
+
# Check if path is allowed
|
|
151
|
+
allowed, error_msg = await self.check_path_allowed(path, tool_ctx)
|
|
152
|
+
if not allowed:
|
|
153
|
+
return {"error": error_msg}
|
|
154
|
+
|
|
155
|
+
try:
|
|
156
|
+
dir_path = Path(path)
|
|
157
|
+
|
|
158
|
+
# Check if path exists
|
|
159
|
+
exists, error_msg = await self.check_path_exists(path, tool_ctx)
|
|
160
|
+
if not exists:
|
|
161
|
+
return {"error": error_msg}
|
|
162
|
+
|
|
163
|
+
# Check if path is a directory
|
|
164
|
+
is_dir, error_msg = await self.check_is_directory(path, tool_ctx)
|
|
165
|
+
if not is_dir:
|
|
166
|
+
return {"error": error_msg}
|
|
167
|
+
|
|
168
|
+
# Define filtered directories
|
|
169
|
+
FILTERED_DIRECTORIES = {
|
|
170
|
+
".git",
|
|
171
|
+
"node_modules",
|
|
172
|
+
".venv",
|
|
173
|
+
"venv",
|
|
174
|
+
"__pycache__",
|
|
175
|
+
".pytest_cache",
|
|
176
|
+
".idea",
|
|
177
|
+
".vs",
|
|
178
|
+
".vscode",
|
|
179
|
+
"dist",
|
|
180
|
+
"build",
|
|
181
|
+
"target",
|
|
182
|
+
".ruff_cache",
|
|
183
|
+
".llm-context",
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
# Check if a directory should be filtered
|
|
187
|
+
def should_filter(current_path: Path) -> bool:
|
|
188
|
+
if str(current_path.absolute()) == str(dir_path.absolute()):
|
|
189
|
+
return False
|
|
190
|
+
return (
|
|
191
|
+
current_path.name in FILTERED_DIRECTORIES and not include_filtered
|
|
192
|
+
)
|
|
193
|
+
|
|
194
|
+
# Collect all entries in a flat list for pagination
|
|
195
|
+
all_entries: List[Dict[str, Any]] = []
|
|
196
|
+
|
|
197
|
+
# Build the tree and collect entries
|
|
198
|
+
def collect_entries(
|
|
199
|
+
current_path: Path,
|
|
200
|
+
current_depth: int = 0,
|
|
201
|
+
parent_path: str = ""
|
|
202
|
+
) -> None:
|
|
203
|
+
"""Collect entries in a flat list for pagination."""
|
|
204
|
+
if not self.is_path_allowed(str(current_path)):
|
|
205
|
+
return
|
|
206
|
+
|
|
207
|
+
try:
|
|
208
|
+
# Sort entries: directories first, then files alphabetically
|
|
209
|
+
entries = sorted(
|
|
210
|
+
current_path.iterdir(),
|
|
211
|
+
key=lambda x: (not x.is_dir(), x.name)
|
|
212
|
+
)
|
|
213
|
+
|
|
214
|
+
for entry in entries:
|
|
215
|
+
if not self.is_path_allowed(str(entry)):
|
|
216
|
+
continue
|
|
217
|
+
|
|
218
|
+
# Calculate relative path for display
|
|
219
|
+
relative_path = f"{parent_path}/{entry.name}" if parent_path else entry.name
|
|
220
|
+
|
|
221
|
+
if entry.is_dir():
|
|
222
|
+
entry_data: Dict[str, Any] = {
|
|
223
|
+
"path": relative_path,
|
|
224
|
+
"type": "directory",
|
|
225
|
+
"depth": current_depth,
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
# Check if we should filter this directory
|
|
229
|
+
if should_filter(entry):
|
|
230
|
+
entry_data["skipped"] = "filtered-directory"
|
|
231
|
+
all_entries.append(entry_data)
|
|
232
|
+
continue
|
|
233
|
+
|
|
234
|
+
# Check depth limit
|
|
235
|
+
if depth > 0 and current_depth >= depth:
|
|
236
|
+
entry_data["skipped"] = "depth-limit"
|
|
237
|
+
all_entries.append(entry_data)
|
|
238
|
+
continue
|
|
239
|
+
|
|
240
|
+
# Add directory entry
|
|
241
|
+
all_entries.append(entry_data)
|
|
242
|
+
|
|
243
|
+
# Process children recursively
|
|
244
|
+
collect_entries(
|
|
245
|
+
entry,
|
|
246
|
+
current_depth + 1,
|
|
247
|
+
relative_path
|
|
248
|
+
)
|
|
249
|
+
else:
|
|
250
|
+
# Add file entry
|
|
251
|
+
if depth <= 0 or current_depth < depth:
|
|
252
|
+
all_entries.append({
|
|
253
|
+
"path": relative_path,
|
|
254
|
+
"type": "file",
|
|
255
|
+
"depth": current_depth,
|
|
256
|
+
})
|
|
257
|
+
|
|
258
|
+
except Exception as e:
|
|
259
|
+
await tool_ctx.warning(f"Error processing {current_path}: {str(e)}")
|
|
260
|
+
|
|
261
|
+
# Collect all entries
|
|
262
|
+
await tool_ctx.info("Collecting directory entries...")
|
|
263
|
+
collect_entries(dir_path)
|
|
264
|
+
|
|
265
|
+
# Paginate the results
|
|
266
|
+
paginated = paginate_list(all_entries, cursor, page_size)
|
|
267
|
+
|
|
268
|
+
# Format the paginated entries for display
|
|
269
|
+
formatted_entries = []
|
|
270
|
+
for entry in paginated.items:
|
|
271
|
+
indent = " " * entry["depth"]
|
|
272
|
+
if entry["type"] == "directory":
|
|
273
|
+
if "skipped" in entry:
|
|
274
|
+
formatted_entries.append({
|
|
275
|
+
"entry": f"{indent}{entry['path'].split('/')[-1]}/ [skipped - {entry['skipped']}]",
|
|
276
|
+
"type": "directory",
|
|
277
|
+
"skipped": entry.get("skipped")
|
|
278
|
+
})
|
|
279
|
+
else:
|
|
280
|
+
formatted_entries.append({
|
|
281
|
+
"entry": f"{indent}{entry['path'].split('/')[-1]}/",
|
|
282
|
+
"type": "directory"
|
|
283
|
+
})
|
|
284
|
+
else:
|
|
285
|
+
formatted_entries.append({
|
|
286
|
+
"entry": f"{indent}{entry['path'].split('/')[-1]}",
|
|
287
|
+
"type": "file"
|
|
288
|
+
})
|
|
289
|
+
|
|
290
|
+
# Build response
|
|
291
|
+
response = {
|
|
292
|
+
"entries": formatted_entries,
|
|
293
|
+
"total_collected": len(all_entries),
|
|
294
|
+
"page_size": page_size,
|
|
295
|
+
"current_page_count": len(formatted_entries)
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
# Add next cursor if available
|
|
299
|
+
if paginated.next_cursor:
|
|
300
|
+
response["nextCursor"] = paginated.next_cursor
|
|
301
|
+
|
|
302
|
+
await tool_ctx.info(
|
|
303
|
+
f"Returning page with {len(formatted_entries)} entries"
|
|
304
|
+
f"{' (more available)' if paginated.next_cursor else ' (end of results)'}"
|
|
305
|
+
)
|
|
306
|
+
|
|
307
|
+
return response
|
|
308
|
+
|
|
309
|
+
except Exception as e:
|
|
310
|
+
await tool_ctx.error(f"Error generating directory tree: {str(e)}")
|
|
311
|
+
return {"error": f"Error generating directory tree: {str(e)}"}
|
|
312
|
+
|
|
313
|
+
@override
|
|
314
|
+
def register(self, mcp_server: FastMCP) -> None:
|
|
315
|
+
"""Register this paginated directory tree tool with the MCP server."""
|
|
316
|
+
tool_self = self
|
|
317
|
+
|
|
318
|
+
@mcp_server.tool(name=self.name, description=self.description)
|
|
319
|
+
async def directory_tree_paginated(
|
|
320
|
+
path: DirectoryPath,
|
|
321
|
+
depth: Depth = 3,
|
|
322
|
+
include_filtered: IncludeFiltered = False,
|
|
323
|
+
page_size: PageSize = 100,
|
|
324
|
+
cursor: Cursor = None,
|
|
325
|
+
ctx: MCPContext = None,
|
|
326
|
+
) -> Dict[str, Any]:
|
|
327
|
+
return await tool_self.call(
|
|
328
|
+
ctx,
|
|
329
|
+
path=path,
|
|
330
|
+
depth=depth,
|
|
331
|
+
include_filtered=include_filtered,
|
|
332
|
+
page_size=page_size,
|
|
333
|
+
cursor=cursor,
|
|
334
|
+
)
|
|
335
|
+
|
|
336
|
+
|
|
337
|
+
# Create the tool instance
|
|
338
|
+
directory_tree_paginated_tool = DirectoryTreePaginatedTool()
|
|
@@ -0,0 +1,235 @@
|
|
|
1
|
+
"""Rules tool implementation.
|
|
2
|
+
|
|
3
|
+
This module provides the RulesTool for reading local preferences from .cursor rules
|
|
4
|
+
or .claude code configuration files.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import json
|
|
8
|
+
import os
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from typing import Annotated, TypedDict, Unpack, final, override, Optional
|
|
11
|
+
|
|
12
|
+
from mcp.server.fastmcp import Context as MCPContext
|
|
13
|
+
from mcp.server import FastMCP
|
|
14
|
+
from pydantic import Field
|
|
15
|
+
|
|
16
|
+
from hanzo_mcp.tools.filesystem.base import FilesystemBaseTool
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
SearchPath = Annotated[
|
|
20
|
+
str,
|
|
21
|
+
Field(
|
|
22
|
+
description="Directory path to search for configuration files (defaults to current directory)",
|
|
23
|
+
default=".",
|
|
24
|
+
),
|
|
25
|
+
]
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class RulesToolParams(TypedDict, total=False):
|
|
29
|
+
"""Parameters for the RulesTool.
|
|
30
|
+
|
|
31
|
+
Attributes:
|
|
32
|
+
path: Directory path to search for configuration files
|
|
33
|
+
"""
|
|
34
|
+
|
|
35
|
+
path: str
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
@final
|
|
39
|
+
class RulesTool(FilesystemBaseTool):
|
|
40
|
+
"""Tool for reading local preferences from configuration files."""
|
|
41
|
+
|
|
42
|
+
@property
|
|
43
|
+
@override
|
|
44
|
+
def name(self) -> str:
|
|
45
|
+
"""Get the tool name.
|
|
46
|
+
|
|
47
|
+
Returns:
|
|
48
|
+
Tool name
|
|
49
|
+
"""
|
|
50
|
+
return "rules"
|
|
51
|
+
|
|
52
|
+
@property
|
|
53
|
+
@override
|
|
54
|
+
def description(self) -> str:
|
|
55
|
+
"""Get the tool description.
|
|
56
|
+
|
|
57
|
+
Returns:
|
|
58
|
+
Tool description
|
|
59
|
+
"""
|
|
60
|
+
return """Read local preferences and rules from .cursor/rules or .claude/code configuration files.
|
|
61
|
+
|
|
62
|
+
This tool searches for and reads configuration files that contain project-specific
|
|
63
|
+
preferences, coding standards, and rules for AI assistants.
|
|
64
|
+
|
|
65
|
+
Searches for (in order of priority):
|
|
66
|
+
1. .cursorrules in current directory
|
|
67
|
+
2. .cursor/rules in current directory
|
|
68
|
+
3. .claude/code.md in current directory
|
|
69
|
+
4. .claude/rules.md in current directory
|
|
70
|
+
5. Recursively searches parent directories up to project root
|
|
71
|
+
|
|
72
|
+
Usage:
|
|
73
|
+
rules # Search from current directory
|
|
74
|
+
rules --path /project # Search from specific directory
|
|
75
|
+
|
|
76
|
+
The tool returns the contents of all found configuration files to help
|
|
77
|
+
understand project-specific requirements and preferences."""
|
|
78
|
+
|
|
79
|
+
@override
|
|
80
|
+
async def call(
|
|
81
|
+
self,
|
|
82
|
+
ctx: MCPContext,
|
|
83
|
+
**params: Unpack[RulesToolParams],
|
|
84
|
+
) -> str:
|
|
85
|
+
"""Execute the tool with the given parameters.
|
|
86
|
+
|
|
87
|
+
Args:
|
|
88
|
+
ctx: MCP context
|
|
89
|
+
**params: Tool parameters
|
|
90
|
+
|
|
91
|
+
Returns:
|
|
92
|
+
Tool result
|
|
93
|
+
"""
|
|
94
|
+
tool_ctx = self.create_tool_context(ctx)
|
|
95
|
+
self.set_tool_context_info(tool_ctx)
|
|
96
|
+
|
|
97
|
+
# Extract parameters
|
|
98
|
+
search_path = params.get("path", ".")
|
|
99
|
+
|
|
100
|
+
# Validate path
|
|
101
|
+
path_validation = self.validate_path(search_path)
|
|
102
|
+
if not path_validation.is_valid:
|
|
103
|
+
await tool_ctx.error(f"Invalid path: {path_validation.error_message}")
|
|
104
|
+
return f"Error: Invalid path: {path_validation.error_message}"
|
|
105
|
+
|
|
106
|
+
# Check permissions
|
|
107
|
+
is_allowed, error_message = await self.check_path_allowed(search_path, tool_ctx)
|
|
108
|
+
if not is_allowed:
|
|
109
|
+
return error_message
|
|
110
|
+
|
|
111
|
+
# Check existence
|
|
112
|
+
is_exists, error_message = await self.check_path_exists(search_path, tool_ctx)
|
|
113
|
+
if not is_exists:
|
|
114
|
+
return error_message
|
|
115
|
+
|
|
116
|
+
# Convert to Path object
|
|
117
|
+
start_path = Path(search_path).resolve()
|
|
118
|
+
|
|
119
|
+
# Configuration files to search for
|
|
120
|
+
config_files = [
|
|
121
|
+
".cursorrules",
|
|
122
|
+
".cursor/rules",
|
|
123
|
+
".cursor/rules.md",
|
|
124
|
+
".claude/code.md",
|
|
125
|
+
".claude/rules.md",
|
|
126
|
+
".claude/config.md",
|
|
127
|
+
]
|
|
128
|
+
|
|
129
|
+
found_configs = []
|
|
130
|
+
|
|
131
|
+
# Search in current directory and parent directories
|
|
132
|
+
current_path = start_path
|
|
133
|
+
while True:
|
|
134
|
+
for config_file in config_files:
|
|
135
|
+
config_path = current_path / config_file
|
|
136
|
+
|
|
137
|
+
# Check if file exists and we have permission
|
|
138
|
+
if config_path.exists() and config_path.is_file():
|
|
139
|
+
try:
|
|
140
|
+
# Check permissions for this specific file
|
|
141
|
+
if self.is_path_allowed(str(config_path)):
|
|
142
|
+
with open(config_path, "r", encoding="utf-8") as f:
|
|
143
|
+
content = f.read()
|
|
144
|
+
|
|
145
|
+
found_configs.append({
|
|
146
|
+
"path": str(config_path),
|
|
147
|
+
"relative_path": str(config_path.relative_to(start_path)),
|
|
148
|
+
"content": content,
|
|
149
|
+
"size": len(content)
|
|
150
|
+
})
|
|
151
|
+
|
|
152
|
+
await tool_ctx.info(f"Found configuration: {config_path}")
|
|
153
|
+
except Exception as e:
|
|
154
|
+
await tool_ctx.warning(f"Could not read {config_path}: {str(e)}")
|
|
155
|
+
|
|
156
|
+
# Check if we've reached the root or a git repository root
|
|
157
|
+
if current_path.parent == current_path:
|
|
158
|
+
break
|
|
159
|
+
|
|
160
|
+
# Check if this is a git repository root
|
|
161
|
+
if (current_path / ".git").exists():
|
|
162
|
+
# Search one more time in the git root before stopping
|
|
163
|
+
if current_path != start_path:
|
|
164
|
+
for config_file in config_files:
|
|
165
|
+
config_path = current_path / config_file
|
|
166
|
+
if config_path.exists() and config_path.is_file() and str(config_path) not in [c["path"] for c in found_configs]:
|
|
167
|
+
try:
|
|
168
|
+
if self.is_path_allowed(str(config_path)):
|
|
169
|
+
with open(config_path, "r", encoding="utf-8") as f:
|
|
170
|
+
content = f.read()
|
|
171
|
+
|
|
172
|
+
found_configs.append({
|
|
173
|
+
"path": str(config_path),
|
|
174
|
+
"relative_path": str(config_path.relative_to(start_path)),
|
|
175
|
+
"content": content,
|
|
176
|
+
"size": len(content)
|
|
177
|
+
})
|
|
178
|
+
|
|
179
|
+
await tool_ctx.info(f"Found configuration: {config_path}")
|
|
180
|
+
except Exception as e:
|
|
181
|
+
await tool_ctx.warning(f"Could not read {config_path}: {str(e)}")
|
|
182
|
+
break
|
|
183
|
+
|
|
184
|
+
# Move to parent directory
|
|
185
|
+
parent = current_path.parent
|
|
186
|
+
|
|
187
|
+
# Check if parent is still within allowed paths
|
|
188
|
+
if not self.is_path_allowed(str(parent)):
|
|
189
|
+
await tool_ctx.info(f"Stopped at directory boundary: {parent}")
|
|
190
|
+
break
|
|
191
|
+
|
|
192
|
+
current_path = parent
|
|
193
|
+
|
|
194
|
+
# Format results
|
|
195
|
+
if not found_configs:
|
|
196
|
+
return f"""No configuration files found.
|
|
197
|
+
|
|
198
|
+
Searched for:
|
|
199
|
+
{chr(10).join('- ' + cf for cf in config_files)}
|
|
200
|
+
|
|
201
|
+
Starting from: {start_path}
|
|
202
|
+
|
|
203
|
+
To create project rules, create one of these files with your preferences:
|
|
204
|
+
- .cursorrules: For Cursor IDE rules
|
|
205
|
+
- .cursor/rules: Alternative Cursor location
|
|
206
|
+
- .claude/code.md: For Claude-specific coding preferences
|
|
207
|
+
- .claude/rules.md: For general Claude interaction rules"""
|
|
208
|
+
|
|
209
|
+
# Build output
|
|
210
|
+
output = [f"=== Found {len(found_configs)} Configuration File(s) ===\n"]
|
|
211
|
+
|
|
212
|
+
for i, config in enumerate(found_configs, 1):
|
|
213
|
+
output.append(f"--- [{i}] {config['path']} ({config['size']} bytes) ---")
|
|
214
|
+
output.append(config['content'])
|
|
215
|
+
output.append("") # Empty line between configs
|
|
216
|
+
|
|
217
|
+
output.append(f"\nSearched from: {start_path}")
|
|
218
|
+
|
|
219
|
+
return "\n".join(output)
|
|
220
|
+
|
|
221
|
+
@override
|
|
222
|
+
def register(self, mcp_server: FastMCP) -> None:
|
|
223
|
+
"""Register this rules tool with the MCP server.
|
|
224
|
+
|
|
225
|
+
Args:
|
|
226
|
+
mcp_server: The FastMCP server instance
|
|
227
|
+
"""
|
|
228
|
+
tool_self = self # Create a reference to self for use in the closure
|
|
229
|
+
|
|
230
|
+
@mcp_server.tool(name=self.name, description=self.description)
|
|
231
|
+
async def rules(
|
|
232
|
+
path: SearchPath = ".",
|
|
233
|
+
ctx: MCPContext = None,
|
|
234
|
+
) -> str:
|
|
235
|
+
return await tool_self.call(ctx, path=path)
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
"""
|
|
1
|
+
"""Search tool that runs multiple search types in parallel.
|
|
2
2
|
|
|
3
3
|
This tool consolidates all search capabilities and runs them concurrently:
|
|
4
4
|
- grep: Fast pattern/regex search using ripgrep
|
|
@@ -41,7 +41,7 @@ class SearchType(Enum):
|
|
|
41
41
|
|
|
42
42
|
@dataclass
|
|
43
43
|
class SearchResult:
|
|
44
|
-
"""
|
|
44
|
+
"""Search result from any search type."""
|
|
45
45
|
file_path: str
|
|
46
46
|
line_number: Optional[int]
|
|
47
47
|
content: str
|
|
@@ -133,7 +133,7 @@ IncludeContext = Annotated[
|
|
|
133
133
|
|
|
134
134
|
|
|
135
135
|
class UnifiedSearchParams(TypedDict):
|
|
136
|
-
"""Parameters for
|
|
136
|
+
"""Parameters for search."""
|
|
137
137
|
pattern: Pattern
|
|
138
138
|
path: SearchPath
|
|
139
139
|
include: Include
|
|
@@ -147,12 +147,12 @@ class UnifiedSearchParams(TypedDict):
|
|
|
147
147
|
|
|
148
148
|
|
|
149
149
|
@final
|
|
150
|
-
class
|
|
151
|
-
"""
|
|
150
|
+
class SearchTool(FilesystemBaseTool):
|
|
151
|
+
"""Search tool that runs multiple search types in parallel."""
|
|
152
152
|
|
|
153
153
|
def __init__(self, permission_manager: PermissionManager,
|
|
154
154
|
project_manager: Optional[ProjectVectorManager] = None):
|
|
155
|
-
"""Initialize the
|
|
155
|
+
"""Initialize the search tool.
|
|
156
156
|
|
|
157
157
|
Args:
|
|
158
158
|
permission_manager: Permission manager for access control
|
|
@@ -175,13 +175,13 @@ class UnifiedSearchTool(FilesystemBaseTool):
|
|
|
175
175
|
@override
|
|
176
176
|
def name(self) -> str:
|
|
177
177
|
"""Get the tool name."""
|
|
178
|
-
return "
|
|
178
|
+
return "search"
|
|
179
179
|
|
|
180
180
|
@property
|
|
181
181
|
@override
|
|
182
182
|
def description(self) -> str:
|
|
183
183
|
"""Get the tool description."""
|
|
184
|
-
return """
|
|
184
|
+
return """Search that runs multiple search strategies in parallel.
|
|
185
185
|
|
|
186
186
|
Automatically runs the most appropriate search types based on your pattern:
|
|
187
187
|
- Pattern matching (grep) for exact text/regex
|
|
@@ -527,7 +527,7 @@ This is the recommended search tool for comprehensive results."""
|
|
|
527
527
|
ctx: MCPContext,
|
|
528
528
|
**params: Unpack[UnifiedSearchParams],
|
|
529
529
|
) -> str:
|
|
530
|
-
"""Execute
|
|
530
|
+
"""Execute search across all enabled search types."""
|
|
531
531
|
import time
|
|
532
532
|
start_time = time.time()
|
|
533
533
|
|
|
@@ -559,7 +559,7 @@ This is the recommended search tool for comprehensive results."""
|
|
|
559
559
|
# Analyze pattern to determine best search strategies
|
|
560
560
|
pattern_analysis = self._analyze_pattern(pattern)
|
|
561
561
|
|
|
562
|
-
await tool_ctx.info(f"Starting
|
|
562
|
+
await tool_ctx.info(f"Starting search for '{pattern}' in {path}")
|
|
563
563
|
|
|
564
564
|
# Build list of search tasks based on enabled types and pattern analysis
|
|
565
565
|
search_tasks = []
|
|
@@ -679,11 +679,11 @@ This is the recommended search tool for comprehensive results."""
|
|
|
679
679
|
|
|
680
680
|
@override
|
|
681
681
|
def register(self, mcp_server: FastMCP) -> None:
|
|
682
|
-
"""Register the
|
|
682
|
+
"""Register the search tool with the MCP server."""
|
|
683
683
|
tool_self = self
|
|
684
684
|
|
|
685
685
|
@mcp_server.tool(name=self.name, description=self.description)
|
|
686
|
-
async def
|
|
686
|
+
async def search(
|
|
687
687
|
ctx: MCPContext,
|
|
688
688
|
pattern: Pattern,
|
|
689
689
|
path: SearchPath = ".",
|