hanzo-mcp 0.7.3__py3-none-any.whl → 0.7.6__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.

Files changed (35) hide show
  1. hanzo_mcp/cli.py +10 -0
  2. hanzo_mcp/prompts/__init__.py +43 -0
  3. hanzo_mcp/prompts/example_custom_prompt.py +40 -0
  4. hanzo_mcp/prompts/tool_explorer.py +603 -0
  5. hanzo_mcp/tools/__init__.py +52 -51
  6. hanzo_mcp/tools/agent/__init__.py +3 -16
  7. hanzo_mcp/tools/agent/agent_tool.py +365 -525
  8. hanzo_mcp/tools/agent/agent_tool_v1_deprecated.py +641 -0
  9. hanzo_mcp/tools/agent/network_tool.py +3 -5
  10. hanzo_mcp/tools/agent/swarm_tool.py +447 -349
  11. hanzo_mcp/tools/agent/swarm_tool_v1_deprecated.py +535 -0
  12. hanzo_mcp/tools/agent/tool_adapter.py +21 -2
  13. hanzo_mcp/tools/common/forgiving_edit.py +24 -14
  14. hanzo_mcp/tools/common/permissions.py +8 -0
  15. hanzo_mcp/tools/filesystem/__init__.py +5 -5
  16. hanzo_mcp/tools/filesystem/{symbols.py → ast_tool.py} +8 -8
  17. hanzo_mcp/tools/filesystem/batch_search.py +2 -2
  18. hanzo_mcp/tools/filesystem/directory_tree.py +8 -1
  19. hanzo_mcp/tools/filesystem/find.py +1 -0
  20. hanzo_mcp/tools/filesystem/grep.py +11 -2
  21. hanzo_mcp/tools/filesystem/read.py +8 -1
  22. hanzo_mcp/tools/filesystem/search_tool.py +1 -1
  23. hanzo_mcp/tools/jupyter/__init__.py +5 -1
  24. hanzo_mcp/tools/jupyter/base.py +2 -2
  25. hanzo_mcp/tools/jupyter/jupyter.py +89 -18
  26. hanzo_mcp/tools/search/find_tool.py +49 -8
  27. hanzo_mcp/tools/shell/base_process.py +7 -1
  28. hanzo_mcp/tools/shell/streaming_command.py +34 -1
  29. {hanzo_mcp-0.7.3.dist-info → hanzo_mcp-0.7.6.dist-info}/METADATA +7 -1
  30. {hanzo_mcp-0.7.3.dist-info → hanzo_mcp-0.7.6.dist-info}/RECORD +33 -31
  31. hanzo_mcp/tools/agent/agent_tool_v2.py +0 -492
  32. hanzo_mcp/tools/agent/swarm_tool_v2.py +0 -654
  33. {hanzo_mcp-0.7.3.dist-info → hanzo_mcp-0.7.6.dist-info}/WHEEL +0 -0
  34. {hanzo_mcp-0.7.3.dist-info → hanzo_mcp-0.7.6.dist-info}/entry_points.txt +0 -0
  35. {hanzo_mcp-0.7.3.dist-info → hanzo_mcp-0.7.6.dist-info}/top_level.txt +0 -0
@@ -0,0 +1,535 @@
1
+ """Swarm tool implementation for parallel and hierarchical agent execution.
2
+
3
+ This module implements the SwarmTool that enables both parallel execution of multiple
4
+ agent instances and hierarchical workflows with specialized roles.
5
+ """
6
+
7
+ import asyncio
8
+ import json
9
+ import os
10
+ from typing import Annotated, Any, TypedDict, Unpack, final, override, Optional, Dict, List
11
+ from pathlib import Path
12
+
13
+ from mcp.server import FastMCP
14
+ from mcp.server.fastmcp import Context as MCPContext
15
+ from pydantic import Field
16
+
17
+ from hanzo_mcp.tools.agent.agent_tool import AgentTool
18
+ from hanzo_mcp.tools.common.base import BaseTool
19
+ from hanzo_mcp.tools.common.context import create_tool_context
20
+ from hanzo_mcp.tools.common.permissions import PermissionManager
21
+
22
+
23
+ class AgentNode(TypedDict):
24
+ """Node in the agent network.
25
+
26
+ Attributes:
27
+ id: Unique identifier for this agent
28
+ query: The specific query/task for this agent
29
+ model: Optional model override (e.g., 'claude-3-5-sonnet', 'gpt-4o')
30
+ role: Optional role description (e.g., 'architect', 'frontend', 'reviewer')
31
+ connections: List of agent IDs this agent connects to (sends results to)
32
+ receives_from: Optional list of agent IDs this agent receives input from
33
+ file_path: Optional specific file for the agent to work on
34
+ """
35
+ id: str
36
+ query: str
37
+ model: Optional[str]
38
+ role: Optional[str]
39
+ connections: Optional[List[str]]
40
+ receives_from: Optional[List[str]]
41
+ file_path: Optional[str]
42
+
43
+
44
+ class SwarmConfig(TypedDict):
45
+ """Configuration for an agent network.
46
+
47
+ Attributes:
48
+ agents: Dictionary of agent configurations keyed by ID
49
+ entry_point: ID of the first agent to execute (optional, defaults to finding roots)
50
+ topology: Optional topology type (tree, dag, pipeline, star, mesh)
51
+ """
52
+ agents: Dict[str, AgentNode]
53
+ entry_point: Optional[str]
54
+ topology: Optional[str]
55
+
56
+
57
+ class SwarmToolParams(TypedDict):
58
+ """Parameters for the SwarmTool.
59
+
60
+ Attributes:
61
+ config: Agent network configuration
62
+ query: Initial query to send to entry point agent(s)
63
+ context: Optional context shared by all agents
64
+ max_concurrent: Maximum number of concurrent agents (default: 10)
65
+ """
66
+ config: SwarmConfig
67
+ query: str
68
+ context: Optional[str]
69
+ max_concurrent: Optional[int]
70
+
71
+
72
+ @final
73
+ class SwarmTool(BaseTool):
74
+ """Tool for executing multiple agent tasks in parallel.
75
+
76
+ The SwarmTool enables efficient parallel processing of multiple files or tasks
77
+ by spawning independent agent instances for each task.
78
+ """
79
+
80
+ @property
81
+ @override
82
+ def name(self) -> str:
83
+ """Get the tool name."""
84
+ return "swarm"
85
+
86
+ @property
87
+ @override
88
+ def description(self) -> str:
89
+ """Get the tool description."""
90
+ return """Execute a network of AI agents with flexible connection topologies.
91
+
92
+ This tool enables sophisticated agent orchestration where agents can be connected
93
+ in various network patterns. Each agent can pass results to connected agents,
94
+ enabling complex workflows.
95
+
96
+ Features:
97
+ - Flexible agent networks (tree, DAG, pipeline, star, mesh)
98
+ - Each agent can use different models (Claude, GPT-4, Gemini, etc.)
99
+ - Agents automatically pass results to connected agents
100
+ - Parallel execution with dependency management
101
+ - Full editing capabilities for each agent
102
+
103
+ Common Topologies:
104
+
105
+ 1. Tree (Architect pattern):
106
+ architect → [frontend, backend, database] → reviewer
107
+
108
+ 2. Pipeline (Sequential processing):
109
+ analyzer → planner → implementer → tester → reviewer
110
+
111
+ 3. Star (Central coordinator):
112
+ coordinator ← → [agent1, agent2, agent3, agent4]
113
+
114
+ 4. DAG (Complex dependencies):
115
+ Multiple agents with custom connections
116
+
117
+ Usage Example:
118
+
119
+ swarm(
120
+ config={
121
+ "agents": {
122
+ "architect": {
123
+ "id": "architect",
124
+ "query": "Analyze codebase and create refactoring plan",
125
+ "model": "claude-3-5-sonnet",
126
+ "connections": ["frontend", "backend", "database"]
127
+ },
128
+ "frontend": {
129
+ "id": "frontend",
130
+ "query": "Refactor UI components based on architect's plan",
131
+ "role": "Frontend Developer",
132
+ "connections": ["reviewer"]
133
+ },
134
+ "backend": {
135
+ "id": "backend",
136
+ "query": "Refactor API endpoints based on architect's plan",
137
+ "role": "Backend Developer",
138
+ "connections": ["reviewer"]
139
+ },
140
+ "database": {
141
+ "id": "database",
142
+ "query": "Optimize database schema based on architect's plan",
143
+ "role": "Database Expert",
144
+ "connections": ["reviewer"]
145
+ },
146
+ "reviewer": {
147
+ "id": "reviewer",
148
+ "query": "Review all changes and ensure consistency",
149
+ "model": "gpt-4o",
150
+ "receives_from": ["frontend", "backend", "database"]
151
+ }
152
+ },
153
+ "entry_point": "architect"
154
+ },
155
+ query="Refactor the authentication system for better security and performance"
156
+ )
157
+
158
+ Models can be specified as:
159
+ - Full: 'anthropic/claude-3-5-sonnet-20241022'
160
+ - Short: 'claude-3-5-sonnet', 'gpt-4o', 'gemini-1.5-pro'
161
+ - CLI tools: 'claude_cli', 'codex_cli', 'gemini_cli', 'grok_cli'
162
+ """
163
+
164
+ def __init__(
165
+ self,
166
+ permission_manager: PermissionManager,
167
+ model: str | None = None,
168
+ api_key: str | None = None,
169
+ base_url: str | None = None,
170
+ max_tokens: int | None = None,
171
+ agent_max_iterations: int = 10,
172
+ agent_max_tool_uses: int = 30,
173
+ ):
174
+ """Initialize the swarm tool.
175
+
176
+ Args:
177
+ permission_manager: Permission manager for access control
178
+ model: Optional model name override (defaults to Claude Sonnet)
179
+ api_key: Optional API key for the model provider
180
+ base_url: Optional base URL for the model provider
181
+ max_tokens: Optional maximum tokens for model responses
182
+ agent_max_iterations: Max iterations per agent (default: 10)
183
+ agent_max_tool_uses: Max tool uses per agent (default: 30)
184
+ """
185
+ self.permission_manager = permission_manager
186
+ # Default to latest Claude Sonnet if no model specified
187
+ from hanzo_mcp.tools.agent.code_auth import get_latest_claude_model
188
+ self.model = model or f"anthropic/{get_latest_claude_model()}"
189
+ self.api_key = api_key or os.environ.get("ANTHROPIC_API_KEY") or os.environ.get("CLAUDE_API_KEY")
190
+ self.base_url = base_url
191
+ self.max_tokens = max_tokens
192
+ self.agent_max_iterations = agent_max_iterations
193
+ self.agent_max_tool_uses = agent_max_tool_uses
194
+
195
+ @override
196
+ async def call(
197
+ self,
198
+ ctx: MCPContext,
199
+ **params: Unpack[SwarmToolParams],
200
+ ) -> str:
201
+ """Execute the swarm tool.
202
+
203
+ Args:
204
+ ctx: MCP context
205
+ **params: Tool parameters
206
+
207
+ Returns:
208
+ Combined results from all agents
209
+ """
210
+ tool_ctx = create_tool_context(ctx)
211
+ await tool_ctx.set_tool_info(self.name)
212
+
213
+ # Extract parameters
214
+ agents = params.get("agents", [])
215
+ manager_query = params.get("manager_query")
216
+ reviewer_query = params.get("reviewer_query")
217
+ common_context = params.get("common_context", "")
218
+ max_concurrent = params.get("max_concurrent", 10)
219
+
220
+ if not agents:
221
+ await tool_ctx.error("No agents provided")
222
+ return "Error: At least one agent must be provided."
223
+
224
+ # Extract parameters
225
+ config = params.get("config", {})
226
+ initial_query = params.get("query", "")
227
+ context = params.get("context", "")
228
+
229
+ agents_config = config.get("agents", {})
230
+ entry_point = config.get("entry_point")
231
+
232
+ await tool_ctx.info(f"Starting swarm execution with {len(agents_config)} agents")
233
+
234
+ # Build agent network
235
+ agent_instances = {}
236
+ agent_results = {}
237
+ execution_queue = asyncio.Queue()
238
+ completed_agents = set()
239
+
240
+ # Create agent instances
241
+ for agent_id, agent_config in agents_config.items():
242
+ model = agent_config.get("model", self.model)
243
+
244
+ # Support CLI tools
245
+ cli_tools = {
246
+ "claude_cli": self._get_cli_tool("claude_cli"),
247
+ "codex_cli": self._get_cli_tool("codex_cli"),
248
+ "gemini_cli": self._get_cli_tool("gemini_cli"),
249
+ "grok_cli": self._get_cli_tool("grok_cli"),
250
+ }
251
+
252
+ if model in cli_tools:
253
+ agent = cli_tools[model]
254
+ else:
255
+ # Regular agent with model
256
+ agent = AgentTool(
257
+ permission_manager=self.permission_manager,
258
+ model=self._normalize_model(model),
259
+ api_key=self.api_key,
260
+ base_url=self.base_url,
261
+ max_tokens=self.max_tokens,
262
+ max_iterations=self.agent_max_iterations,
263
+ max_tool_uses=self.agent_max_tool_uses,
264
+ )
265
+
266
+ agent_instances[agent_id] = agent
267
+
268
+ # Find entry points (agents with no incoming connections)
269
+ if entry_point:
270
+ await execution_queue.put((entry_point, initial_query, {}))
271
+ else:
272
+ # Find root agents (no receives_from)
273
+ roots = []
274
+ for agent_id, agent_config in agents_config.items():
275
+ if not agent_config.get("receives_from"):
276
+ # Check if any other agent connects to this one
277
+ has_incoming = False
278
+ for other_config in agents_config.values():
279
+ if other_config.get("connections") and agent_id in other_config["connections"]:
280
+ has_incoming = True
281
+ break
282
+ if not has_incoming:
283
+ roots.append(agent_id)
284
+
285
+ if not roots:
286
+ await tool_ctx.error("No entry point found in agent network")
287
+ return "Error: Could not determine entry point for agent network"
288
+
289
+ for root in roots:
290
+ await execution_queue.put((root, initial_query, {}))
291
+
292
+ # Execute agents in network order
293
+ async def execute_agent(agent_id: str, query: str, inputs: Dict[str, str]) -> str:
294
+ """Execute a single agent in the network."""
295
+ async with semaphore:
296
+ try:
297
+ agent_config = agents_config[agent_id]
298
+ agent = agent_instances[agent_id]
299
+
300
+ await tool_ctx.info(f"Executing agent: {agent_id} ({agent_config.get('role', 'Agent')})")
301
+
302
+ # Build prompt with context and inputs
303
+ prompt_parts = []
304
+
305
+ # Add role context
306
+ if agent_config.get("role"):
307
+ prompt_parts.append(f"Your role: {agent_config['role']}")
308
+
309
+ # Add shared context
310
+ if context:
311
+ prompt_parts.append(f"Context:\n{context}")
312
+
313
+ # Add inputs from connected agents
314
+ if inputs:
315
+ prompt_parts.append("Input from previous agents:")
316
+ for input_agent, input_result in inputs.items():
317
+ prompt_parts.append(f"\n--- From {input_agent} ---\n{input_result}")
318
+
319
+ # Add file context if specified
320
+ if agent_config.get("file_path"):
321
+ prompt_parts.append(f"\nFile to work on: {agent_config['file_path']}")
322
+
323
+ # Add the main query
324
+ prompt_parts.append(f"\nTask: {agent_config['query']}")
325
+
326
+ # Combine query with initial query if this is entry point
327
+ if query and query != agent_config['query']:
328
+ prompt_parts.append(f"\nMain objective: {query}")
329
+
330
+ full_prompt = "\n\n".join(prompt_parts)
331
+
332
+ # Execute the agent
333
+ result = await agent.call(ctx, prompts=full_prompt)
334
+
335
+ await tool_ctx.info(f"Agent {agent_id} completed")
336
+ return result
337
+
338
+ except Exception as e:
339
+ error_msg = f"Agent {agent_id} failed: {str(e)}"
340
+ await tool_ctx.error(error_msg)
341
+ return f"Error: {error_msg}"
342
+
343
+ # Process agent network
344
+ running_tasks = set()
345
+
346
+ while not execution_queue.empty() or running_tasks:
347
+ # Start new tasks up to concurrency limit
348
+ while not execution_queue.empty() and len(running_tasks) < max_concurrent:
349
+ agent_id, query, inputs = await execution_queue.get()
350
+
351
+ if agent_id not in completed_agents:
352
+ # Check if all dependencies are met
353
+ agent_config = agents_config[agent_id]
354
+ receives_from = agent_config.get("receives_from", [])
355
+
356
+ # Collect inputs from dependencies
357
+ ready = True
358
+ for dep in receives_from:
359
+ if dep not in agent_results:
360
+ ready = False
361
+ # Re-queue for later
362
+ await execution_queue.put((agent_id, query, inputs))
363
+ break
364
+ else:
365
+ inputs[dep] = agent_results[dep]
366
+
367
+ if ready:
368
+ # Execute agent
369
+ task = asyncio.create_task(execute_agent(agent_id, query, inputs))
370
+ running_tasks.add(task)
371
+
372
+ async def handle_completion(task, agent_id=agent_id):
373
+ result = await task
374
+ agent_results[agent_id] = result
375
+ completed_agents.add(agent_id)
376
+ running_tasks.discard(task)
377
+
378
+ # Queue connected agents
379
+ agent_config = agents_config[agent_id]
380
+ connections = agent_config.get("connections", [])
381
+ for next_agent in connections:
382
+ if next_agent in agents_config:
383
+ await execution_queue.put((next_agent, "", {agent_id: result}))
384
+
385
+ asyncio.create_task(handle_completion(task))
386
+
387
+ # Wait a bit if we're at capacity
388
+ if running_tasks:
389
+ await asyncio.sleep(0.1)
390
+
391
+ # Wait for all tasks to complete
392
+ if running_tasks:
393
+ await asyncio.gather(*running_tasks, return_exceptions=True)
394
+
395
+ # Format results
396
+ return self._format_network_results(agents_config, agent_results, entry_point)
397
+
398
+ def _normalize_model(self, model: str) -> str:
399
+ """Normalize model names to full format."""
400
+ model_map = {
401
+ "claude-3-5-sonnet": "anthropic/claude-3-5-sonnet-20241022",
402
+ "claude-3-opus": "anthropic/claude-3-opus-20240229",
403
+ "gpt-4o": "openai/gpt-4o",
404
+ "gpt-4": "openai/gpt-4",
405
+ "gemini-1.5-pro": "google/gemini-1.5-pro",
406
+ "gemini-1.5-flash": "google/gemini-1.5-flash",
407
+ }
408
+ return model_map.get(model, model)
409
+
410
+ def _get_cli_tool(self, tool_name: str):
411
+ """Get CLI tool instance."""
412
+ # Import here to avoid circular imports
413
+ if tool_name == "claude_cli":
414
+ from hanzo_mcp.tools.agent.claude_cli_tool import ClaudeCLITool
415
+ return ClaudeCLITool(self.permission_manager)
416
+ elif tool_name == "codex_cli":
417
+ from hanzo_mcp.tools.agent.codex_cli_tool import CodexCLITool
418
+ return CodexCLITool(self.permission_manager)
419
+ elif tool_name == "gemini_cli":
420
+ from hanzo_mcp.tools.agent.gemini_cli_tool import GeminiCLITool
421
+ return GeminiCLITool(self.permission_manager)
422
+ elif tool_name == "grok_cli":
423
+ from hanzo_mcp.tools.agent.grok_cli_tool import GrokCLITool
424
+ return GrokCLITool(self.permission_manager)
425
+ return None
426
+
427
+ def _format_network_results(
428
+ self,
429
+ agents_config: Dict[str, Any],
430
+ results: Dict[str, str],
431
+ entry_point: Optional[str]
432
+ ) -> str:
433
+ """Format results from agent network execution."""
434
+ output = ["Agent Network Execution Results"]
435
+ output.append("=" * 80)
436
+ output.append(f"Total agents: {len(agents_config)}")
437
+ output.append(f"Completed: {len(results)}")
438
+ output.append(f"Failed: {len([r for r in results.values() if r.startswith('Error:')])}")
439
+
440
+ if entry_point:
441
+ output.append(f"Entry point: {entry_point}")
442
+
443
+ output.append("\nExecution Flow:")
444
+ output.append("-" * 40)
445
+
446
+ # Show results in execution order
447
+ def format_agent_tree(agent_id: str, level: int = 0) -> List[str]:
448
+ lines = []
449
+ indent = " " * level
450
+
451
+ if agent_id in agents_config:
452
+ config = agents_config[agent_id]
453
+ role = config.get("role", "Agent")
454
+ model = config.get("model", "default")
455
+
456
+ status = "✅" if agent_id in results and not results[agent_id].startswith("Error:") else "❌"
457
+ lines.append(f"{indent}{status} {agent_id} ({role}) [{model}]")
458
+
459
+ # Show connections
460
+ connections = config.get("connections", [])
461
+ for conn in connections:
462
+ if conn in agents_config:
463
+ lines.extend(format_agent_tree(conn, level + 1))
464
+
465
+ return lines
466
+
467
+ # Start from entry point or roots
468
+ if entry_point:
469
+ output.extend(format_agent_tree(entry_point))
470
+ else:
471
+ # Find roots
472
+ roots = []
473
+ for agent_id in agents_config:
474
+ has_incoming = False
475
+ for config in agents_config.values():
476
+ if config.get("connections") and agent_id in config["connections"]:
477
+ has_incoming = True
478
+ break
479
+ if not has_incoming:
480
+ roots.append(agent_id)
481
+
482
+ for root in roots:
483
+ output.extend(format_agent_tree(root))
484
+
485
+ # Detailed results
486
+ output.append("\n\nDetailed Results:")
487
+ output.append("=" * 80)
488
+
489
+ for agent_id, result in results.items():
490
+ config = agents_config.get(agent_id, {})
491
+ role = config.get("role", "Agent")
492
+
493
+ output.append(f"\n### {agent_id} ({role})")
494
+ output.append("-" * 40)
495
+
496
+ if result.startswith("Error:"):
497
+ output.append(result)
498
+ else:
499
+ # Show first part of result
500
+ lines = result.split('\n')
501
+ preview_lines = lines[:10]
502
+ output.extend(preview_lines)
503
+
504
+ if len(lines) > 10:
505
+ output.append(f"... ({len(lines) - 10} more lines)")
506
+
507
+ return "\n".join(output)
508
+
509
+ @override
510
+ def register(self, mcp_server: FastMCP) -> None:
511
+ """Register this swarm tool with the MCP server."""
512
+ tool_self = self
513
+
514
+ @mcp_server.tool(name=self.name, description=self.description)
515
+ async def swarm(
516
+ ctx: MCPContext,
517
+ config: dict[str, Any],
518
+ query: str,
519
+ context: Optional[str] = None,
520
+ max_concurrent: int = 10,
521
+ ) -> str:
522
+ # Convert to typed format
523
+ typed_config = SwarmConfig(
524
+ agents=config.get("agents", {}),
525
+ entry_point=config.get("entry_point"),
526
+ topology=config.get("topology")
527
+ )
528
+
529
+ return await tool_self.call(
530
+ ctx,
531
+ config=typed_config,
532
+ query=query,
533
+ context=context,
534
+ max_concurrent=max_concurrent
535
+ )
@@ -76,5 +76,24 @@ def supports_parallel_function_calling(model: str) -> bool:
76
76
  Returns:
77
77
  True if the model supports parallel function calling, False otherwise
78
78
  """
79
- # Use litellm's built-in parallel function calling support check
80
- return litellm.supports_parallel_function_calling(model=model)
79
+ # Since litellm doesn't have this function, we'll implement a simple check
80
+ # based on known models that support parallel function calling
81
+ parallel_capable_models = {
82
+ # OpenAI models that support parallel function calling
83
+ "gpt-4-turbo", "gpt-4-turbo-preview", "gpt-4-turbo-2024-04-09",
84
+ "gpt-4o", "gpt-4o-mini", "gpt-4o-2024-05-13", "gpt-4o-2024-08-06",
85
+ "gpt-3.5-turbo", "gpt-3.5-turbo-0125", "gpt-3.5-turbo-1106",
86
+ # Anthropic models with tool support
87
+ "claude-3-opus", "claude-3-sonnet", "claude-3-haiku",
88
+ "claude-3-5-sonnet", "claude-3-5-sonnet-20241022",
89
+ }
90
+
91
+ # Extract model name without provider prefix
92
+ model_name = model.split("/")[-1] if "/" in model else model
93
+
94
+ # Check if the base model name matches any known parallel-capable models
95
+ for capable_model in parallel_capable_models:
96
+ if model_name.startswith(capable_model):
97
+ return True
98
+
99
+ return False
@@ -22,19 +22,22 @@ class ForgivingEditHelper:
22
22
  Returns:
23
23
  Text with normalized whitespace
24
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)
25
+ # Handle the input line by line
29
26
  lines = []
30
27
  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)
28
+ # Replace tabs with 4 spaces everywhere in the line
29
+ line = line.replace('\t', ' ')
30
+
31
+ # Split into indentation and content
32
+ stripped = line.lstrip()
33
+ indent = line[:len(line) - len(stripped)]
34
+
35
+ if stripped:
36
+ # For content, normalize multiple spaces to single space
37
+ content = re.sub(r' {2,}', ' ', stripped)
38
+ lines.append(indent + content)
39
+ else:
40
+ lines.append(indent)
38
41
 
39
42
  return '\n'.join(lines)
40
43
 
@@ -236,8 +239,15 @@ class ForgivingEditHelper:
236
239
  # Remove any line number prefixes (common in AI copy-paste)
237
240
  lines = []
238
241
  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
+ # Remove common line number patterns while preserving indentation
243
+ # Match patterns like "1: ", "123: ", "1| ", "1- ", etc.
244
+ # But preserve the original indentation after the line number
245
+ match = re.match(r'^(\d+[:\|\-])\s(.*)', line)
246
+ if match:
247
+ # Keep only the content part (group 2) which includes any indentation
248
+ lines.append(match.group(2))
249
+ else:
250
+ # No line number pattern found, keep the line as-is
251
+ lines.append(line)
242
252
 
243
253
  return '\n'.join(lines)
@@ -28,6 +28,14 @@ class PermissionManager:
28
28
  else: # Unix/Linux/Mac
29
29
  self.allowed_paths.add(Path("/tmp").resolve())
30
30
  self.allowed_paths.add(Path("/var").resolve())
31
+
32
+ # Also allow user's home directory work folders
33
+ home = Path.home()
34
+ if home.exists():
35
+ # Add common development directories
36
+ work_dir = home / "work"
37
+ if work_dir.exists():
38
+ self.allowed_paths.add(work_dir.resolve())
31
39
 
32
40
  # Excluded paths
33
41
  self.excluded_paths: set[Path] = set()
@@ -13,7 +13,7 @@ from hanzo_mcp.tools.filesystem.content_replace import ContentReplaceTool
13
13
  from hanzo_mcp.tools.filesystem.directory_tree import DirectoryTreeTool
14
14
  from hanzo_mcp.tools.filesystem.edit import Edit
15
15
  from hanzo_mcp.tools.filesystem.grep import Grep
16
- from hanzo_mcp.tools.filesystem.symbols import SymbolsTool
16
+ from hanzo_mcp.tools.filesystem.ast_tool import ASTTool
17
17
  from hanzo_mcp.tools.filesystem.git_search import GitSearchTool
18
18
  from hanzo_mcp.tools.filesystem.multi_edit import MultiEdit
19
19
  from hanzo_mcp.tools.filesystem.read import ReadTool
@@ -41,7 +41,7 @@ __all__ = [
41
41
  "DirectoryTreeTool",
42
42
  "Grep",
43
43
  "ContentReplaceTool",
44
- "SymbolsTool",
44
+ "ASTTool",
45
45
  "GitSearchTool",
46
46
  "BatchSearchTool",
47
47
  "FindFilesTool",
@@ -69,7 +69,7 @@ def get_read_only_filesystem_tools(
69
69
  ReadTool(permission_manager),
70
70
  DirectoryTreeTool(permission_manager),
71
71
  Grep(permission_manager),
72
- SymbolsTool(permission_manager),
72
+ ASTTool(permission_manager),
73
73
  GitSearchTool(permission_manager),
74
74
  FindFilesTool(permission_manager),
75
75
  RulesTool(permission_manager),
@@ -109,7 +109,7 @@ def get_filesystem_tools(permission_manager: PermissionManager, project_manager=
109
109
  DirectoryTreeTool(permission_manager),
110
110
  Grep(permission_manager),
111
111
  ContentReplaceTool(permission_manager),
112
- SymbolsTool(permission_manager),
112
+ ASTTool(permission_manager),
113
113
  GitSearchTool(permission_manager),
114
114
  FindFilesTool(permission_manager),
115
115
  RulesTool(permission_manager),
@@ -160,7 +160,7 @@ def register_filesystem_tools(
160
160
  "multi_edit": MultiEdit,
161
161
  "directory_tree": DirectoryTreeTool,
162
162
  "grep": Grep,
163
- "symbols": SymbolsTool, # Unified symbols tool with grep_ast functionality
163
+ "ast": ASTTool, # AST-based code structure search with tree-sitter
164
164
  "git_search": GitSearchTool,
165
165
  "content_replace": ContentReplaceTool,
166
166
  "batch_search": BatchSearchTool,