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

@@ -0,0 +1,259 @@
1
+ """Unified CLI Tools - DRY implementation using base agent classes.
2
+
3
+ This module provides the single, clean implementation of all CLI tools
4
+ following Python best practices and eliminating all duplication.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import os
10
+ from typing import Any, Optional, List, Dict
11
+ from pathlib import Path
12
+
13
+ from mcp.server import FastMCP
14
+ from mcp.server.fastmcp import Context
15
+
16
+ from ...core.model_registry import registry
17
+ from ...core.base_agent import CLIAgent, AgentConfig
18
+ from ..common.base import BaseTool
19
+ from ..common.permissions import PermissionManager
20
+
21
+
22
+ class UnifiedCLITool(BaseTool, CLIAgent):
23
+ """Unified CLI tool that combines BaseTool and CLIAgent functionality.
24
+
25
+ MRO: BaseTool first for proper method resolution order.
26
+ """
27
+
28
+ def __init__(
29
+ self,
30
+ name: str,
31
+ description: str,
32
+ cli_command: str,
33
+ default_model: str,
34
+ permission_manager: Optional[PermissionManager] = None,
35
+ ):
36
+ """Initialize unified CLI tool.
37
+
38
+ Args:
39
+ name: Tool name
40
+ description: Tool description
41
+ cli_command: CLI command to execute
42
+ default_model: Default model to use
43
+ permission_manager: Permission manager for access control
44
+ """
45
+ # Initialize CLIAgent with config
46
+ config = AgentConfig(model=default_model)
47
+ CLIAgent.__init__(self, config)
48
+
49
+ # Store tool metadata
50
+ self._name = name
51
+ self._description = description
52
+ self._cli_command = cli_command
53
+ self.permission_manager = permission_manager
54
+
55
+ @property
56
+ def name(self) -> str:
57
+ return self._name
58
+
59
+ @property
60
+ def description(self) -> str:
61
+ return self._description
62
+
63
+ @property
64
+ def cli_command(self) -> str:
65
+ return self._cli_command
66
+
67
+ def build_command(self, prompt: str, **kwargs: Any) -> List[str]:
68
+ """Build the CLI command with model-specific formatting.
69
+
70
+ Args:
71
+ prompt: The prompt
72
+ **kwargs: Additional parameters
73
+
74
+ Returns:
75
+ Command arguments list
76
+ """
77
+ command = [self.cli_command]
78
+
79
+ # Get model config from registry
80
+ model_config = registry.get(self.config.model)
81
+
82
+ # Handle different CLI tool formats
83
+ if self.cli_command == "claude":
84
+ if model_config:
85
+ command.extend(["--model", model_config.full_name])
86
+ # Claude takes prompt via stdin
87
+ return command
88
+
89
+ elif self.cli_command == "openai":
90
+ # OpenAI CLI format
91
+ command.extend(["api", "chat.completions.create"])
92
+ if model_config:
93
+ command.extend(["-m", model_config.full_name])
94
+ command.extend(["-g", "user", prompt])
95
+ return command
96
+
97
+ elif self.cli_command in ["gemini", "grok"]:
98
+ # Simple format: command --model MODEL prompt
99
+ if model_config:
100
+ command.extend(["--model", model_config.full_name])
101
+ command.append(prompt)
102
+ return command
103
+
104
+ elif self.cli_command == "openhands":
105
+ # OpenHands format
106
+ command.extend(["run", prompt])
107
+ if model_config:
108
+ command.extend(["--model", model_config.full_name])
109
+ if self.config.working_dir:
110
+ command.extend(["--workspace", str(self.config.working_dir)])
111
+ return command
112
+
113
+ elif self.cli_command == "hanzo":
114
+ # Hanzo dev format
115
+ command.append("dev")
116
+ if model_config:
117
+ command.extend(["--model", model_config.full_name])
118
+ command.extend(["--prompt", prompt])
119
+ return command
120
+
121
+ elif self.cli_command == "cline":
122
+ # Cline format
123
+ command.append(prompt)
124
+ command.append("--no-interactive")
125
+ return command
126
+
127
+ elif self.cli_command == "aider":
128
+ # Aider format
129
+ if model_config:
130
+ command.extend(["--model", model_config.full_name])
131
+ command.extend(["--message", prompt])
132
+ command.extend(["--yes", "--no-stream"])
133
+ return command
134
+
135
+ elif self.cli_command == "ollama":
136
+ # Ollama format for local models
137
+ command.extend(["run", self.config.model.replace("ollama/", "")])
138
+ command.append(prompt)
139
+ return command
140
+
141
+ # Default format
142
+ command.append(prompt)
143
+ return command
144
+
145
+ async def call(self, ctx: Context[Any, Any, Any], **params: Any) -> str:
146
+ """Execute the CLI tool via MCP interface.
147
+
148
+ Args:
149
+ ctx: MCP context
150
+ **params: Tool parameters
151
+
152
+ Returns:
153
+ Execution result
154
+ """
155
+ # Update config from params
156
+ if params.get("model"):
157
+ self.config.model = registry.resolve(params["model"])
158
+ if params.get("working_dir"):
159
+ self.config.working_dir = Path(params["working_dir"])
160
+ if params.get("timeout"):
161
+ self.config.timeout = params["timeout"]
162
+
163
+ # Execute using base agent
164
+ result = await self.execute(
165
+ params.get("prompt", ""),
166
+ context=ctx,
167
+ )
168
+
169
+ return result.content
170
+
171
+ def register(self, mcp_server: FastMCP) -> None:
172
+ """Register this tool with the MCP server.
173
+
174
+ Args:
175
+ mcp_server: The FastMCP server instance
176
+ """
177
+ tool_self = self
178
+
179
+ @mcp_server.tool(name=self.name, description=self.description)
180
+ async def tool_wrapper(
181
+ prompt: str,
182
+ ctx: Context[Any, Any, Any],
183
+ model: Optional[str] = None,
184
+ working_dir: Optional[str] = None,
185
+ timeout: int = 300,
186
+ ) -> str:
187
+ return await tool_self.call(
188
+ ctx,
189
+ prompt=prompt,
190
+ model=model,
191
+ working_dir=working_dir,
192
+ timeout=timeout,
193
+ )
194
+
195
+
196
+ def create_cli_tools(permission_manager: Optional[PermissionManager] = None) -> Dict[str, UnifiedCLITool]:
197
+ """Create all CLI tools with unified implementation.
198
+
199
+ Args:
200
+ permission_manager: Permission manager for access control
201
+
202
+ Returns:
203
+ Dictionary of tool name to tool instance
204
+ """
205
+ tools = {}
206
+
207
+ # Define all tools with their configurations
208
+ tool_configs = [
209
+ ("claude", "Execute Claude CLI for AI assistance", "claude", "claude"),
210
+ ("cc", "Claude Code CLI (alias for claude)", "claude", "claude"),
211
+ ("codex", "Execute OpenAI Codex/GPT-4 CLI", "openai", "gpt-4-turbo"),
212
+ ("gemini", "Execute Google Gemini CLI", "gemini", "gemini"),
213
+ ("grok", "Execute xAI Grok CLI", "grok", "grok"),
214
+ ("openhands", "Execute OpenHands for autonomous coding", "openhands", "claude"),
215
+ ("oh", "OpenHands CLI (alias)", "openhands", "claude"),
216
+ ("hanzo_dev", "Execute Hanzo Dev AI assistant", "hanzo", "claude"),
217
+ ("cline", "Execute Cline for autonomous coding", "cline", "claude"),
218
+ ("aider", "Execute Aider for AI pair programming", "aider", "gpt-4-turbo"),
219
+ ]
220
+
221
+ for name, description, cli_command, default_model in tool_configs:
222
+ tools[name] = UnifiedCLITool(
223
+ name=name,
224
+ description=description,
225
+ cli_command=cli_command,
226
+ default_model=default_model,
227
+ permission_manager=permission_manager,
228
+ )
229
+
230
+ return tools
231
+
232
+
233
+ def register_cli_tools(
234
+ mcp_server: FastMCP,
235
+ permission_manager: Optional[PermissionManager] = None,
236
+ ) -> List[BaseTool]:
237
+ """Register all CLI tools with the MCP server.
238
+
239
+ Args:
240
+ mcp_server: The FastMCP server instance
241
+ permission_manager: Permission manager for access control
242
+
243
+ Returns:
244
+ List of registered CLI tools
245
+ """
246
+ tools = create_cli_tools(permission_manager)
247
+
248
+ # Register each tool
249
+ for tool in tools.values():
250
+ tool.register(mcp_server)
251
+
252
+ return list(tools.values())
253
+
254
+
255
+ __all__ = [
256
+ "UnifiedCLITool",
257
+ "create_cli_tools",
258
+ "register_cli_tools",
259
+ ]
@@ -140,6 +140,8 @@ To make a batch call, provide the following:
140
140
 
141
141
  Available tools in batch call:
142
142
  Tool: dispatch_agent,read,directory_tree,grep,grep_ast,run_command,notebook_read
143
+ CLI Tools: claude,cc,codex,gemini,grok,openhands,oh,hanzo_dev,cline,aider
144
+ AST/Symbols: ast,symbols (tree-sitter based code analysis)
143
145
  Not available: think,write,edit,multi_edit,notebook_edit
144
146
  """
145
147
 
@@ -67,7 +67,9 @@ class ToolContext:
67
67
  """
68
68
  return self._mcp_context.client_id
69
69
 
70
- def set_tool_info(self, tool_name: str, execution_id: str | None = None) -> None:
70
+ async def set_tool_info(
71
+ self, tool_name: str, execution_id: str | None = None
72
+ ) -> None:
71
73
  """Set information about the currently executing tool.
72
74
 
73
75
  Args:
@@ -4,6 +4,7 @@ Git-style config tool for managing settings.
4
4
  """
5
5
 
6
6
  from typing import Unpack, Optional, Annotated, TypedDict, final, override
7
+ from pathlib import Path
7
8
 
8
9
  from pydantic import Field
9
10
  from mcp.server.fastmcp import Context as MCPContext
@@ -11,6 +12,7 @@ from mcp.server.fastmcp import Context as MCPContext
11
12
  from hanzo_mcp.tools.common.base import BaseTool
12
13
  from hanzo_mcp.tools.common.permissions import PermissionManager
13
14
  from hanzo_mcp.tools.config.index_config import IndexScope, IndexConfig
15
+ from hanzo_mcp.config import load_settings, save_settings
14
16
 
15
17
  # Parameter types
16
18
  Action = Annotated[
@@ -24,7 +26,7 @@ Action = Annotated[
24
26
  Key = Annotated[
25
27
  Optional[str],
26
28
  Field(
27
- description="Configuration key (e.g., index.scope, vector.enabled)",
29
+ description="Configuration key (e.g., tools.write.enabled, enabled_tools.write, index.scope)",
28
30
  default=None,
29
31
  ),
30
32
  ]
@@ -64,6 +66,15 @@ class ConfigParams(TypedDict, total=False):
64
66
  path: Optional[str]
65
67
 
66
68
 
69
+ def _parse_bool(value: str) -> Optional[bool]:
70
+ v = value.strip().lower()
71
+ if v in {"true", "1", "yes", "on"}:
72
+ return True
73
+ if v in {"false", "0", "no", "off"}:
74
+ return False
75
+ return None
76
+
77
+
67
78
  @final
68
79
  class ConfigTool(BaseTool):
69
80
  """Git-style configuration management tool."""
@@ -88,6 +99,7 @@ class ConfigTool(BaseTool):
88
99
  Usage:
89
100
  config index.scope
90
101
  config --action set index.scope project
102
+ config --action set tools.write.enabled false
91
103
  config --action list
92
104
  config --action toggle index.scope --path ./project"""
93
105
 
@@ -131,7 +143,22 @@ config --action toggle index.scope --path ./project"""
131
143
  current_scope = self.index_config.get_scope(path)
132
144
  return f"index.scope={current_scope.value}"
133
145
 
134
- # Handle tool-specific settings
146
+ # tools.<name>.enabled enabled_tools lookup
147
+ if key.startswith("tools.") and key.endswith(".enabled"):
148
+ parts = key.split(".")
149
+ if len(parts) == 3:
150
+ tool_name = parts[1]
151
+ settings = load_settings(project_dir=path if scope == "local" else None)
152
+ return f"{key}={settings.is_tool_enabled(tool_name)}"
153
+
154
+ # enabled_tools.<name>
155
+ if key.startswith("enabled_tools."):
156
+ tool_name = key.split(".", 1)[1]
157
+ settings = load_settings(project_dir=path if scope == "local" else None)
158
+ val = settings.enabled_tools.get(tool_name)
159
+ return f"{key}={val if val is not None else 'unset'}"
160
+
161
+ # Indexing (legacy) per-tool setting: <tool>.enabled
135
162
  if "." in key:
136
163
  tool, setting = key.split(".", 1)
137
164
  if setting == "enabled":
@@ -140,6 +167,17 @@ config --action toggle index.scope --path ./project"""
140
167
 
141
168
  return f"Unknown key: {key}"
142
169
 
170
+ def _save_project_settings(self, settings, project_dir: Optional[str]) -> Path:
171
+ """Save to project config if path provided; else global."""
172
+ if project_dir:
173
+ project_path = Path(project_dir)
174
+ project_path.mkdir(parents=True, exist_ok=True)
175
+ cfg = project_path / ".hanzo-mcp.json"
176
+ cfg.write_text(__import__("json").dumps(settings.__dict__ if hasattr(settings, "__dict__") else {}, indent=2))
177
+ return cfg
178
+ # Fallback to global handler
179
+ return save_settings(settings, global_config=True)
180
+
143
181
  async def _handle_set(
144
182
  self,
145
183
  key: Optional[str],
@@ -151,7 +189,7 @@ config --action toggle index.scope --path ./project"""
151
189
  """Set configuration value."""
152
190
  if not key:
153
191
  return "Error: key required for set action"
154
- if not value:
192
+ if value is None:
155
193
  return "Error: value required for set action"
156
194
 
157
195
  # Handle index scope
@@ -165,13 +203,54 @@ config --action toggle index.scope --path ./project"""
165
203
  except ValueError:
166
204
  return f"Error: Invalid scope value '{value}'. Valid: project, global, auto"
167
205
 
168
- # Handle tool-specific settings
206
+ # tools.<name>.enabled enabled_tools mapping
207
+ if key.startswith("tools.") and key.endswith(".enabled"):
208
+ parts = key.split(".")
209
+ if len(parts) == 3:
210
+ tool_name = parts[1]
211
+ parsed = _parse_bool(value)
212
+ if parsed is None:
213
+ return "Error: value must be boolean (true/false)"
214
+ settings = load_settings(project_dir=path if scope == "local" else None)
215
+ et = dict(settings.enabled_tools)
216
+ et[tool_name] = parsed
217
+ settings.enabled_tools = et
218
+ # Save
219
+ if scope == "local" and path:
220
+ # Write a project .hanzo-mcp.json adjacent to the path
221
+ # Note: save_settings(local) saves to CWD; we target specific path here
222
+ out = self._save_project_settings(settings, path)
223
+ return f"Set {key}={parsed} (project: {out})"
224
+ else:
225
+ out = save_settings(settings, global_config=True)
226
+ return f"Set {key}={parsed} (global: {out})"
227
+
228
+ # enabled_tools.<name>
229
+ if key.startswith("enabled_tools."):
230
+ tool_name = key.split(".", 1)[1]
231
+ parsed = _parse_bool(value)
232
+ if parsed is None:
233
+ return "Error: value must be boolean (true/false)"
234
+ settings = load_settings(project_dir=path if scope == "local" else None)
235
+ et = dict(settings.enabled_tools)
236
+ et[tool_name] = parsed
237
+ settings.enabled_tools = et
238
+ if scope == "local" and path:
239
+ out = self._save_project_settings(settings, path)
240
+ return f"Set {key}={parsed} (project: {out})"
241
+ else:
242
+ out = save_settings(settings, global_config=True)
243
+ return f"Set {key}={parsed} (global: {out})"
244
+
245
+ # Indexing (legacy) per-tool setting: <tool>.enabled (search indexers)
169
246
  if "." in key:
170
247
  tool, setting = key.split(".", 1)
171
248
  if setting == "enabled":
172
- enabled = value.lower() in ["true", "yes", "1", "on"]
173
- self.index_config.set_indexing_enabled(tool, enabled)
174
- return f"Set {key}={enabled}"
249
+ parsed = _parse_bool(value)
250
+ if parsed is None:
251
+ return "Error: value must be boolean (true/false)"
252
+ self.index_config.set_indexing_enabled(tool, parsed)
253
+ return f"Set {key}={parsed}"
175
254
 
176
255
  return f"Unknown key: {key}"
177
256
 
@@ -188,12 +267,18 @@ config --action toggle index.scope --path ./project"""
188
267
 
189
268
  output.append(f"\nProjects with custom config: {status['project_count']}")
190
269
 
191
- output.append("\nTool settings:")
270
+ output.append("\nTool settings (indexing):")
192
271
  for tool, settings in status["tools"].items():
193
272
  output.append(f" {tool}:")
194
273
  output.append(f" enabled: {settings['enabled']}")
195
274
  output.append(f" per_project: {settings['per_project']}")
196
275
 
276
+ # Also show enabled_tools snapshot
277
+ settings_snapshot = load_settings(project_dir=path if scope == "local" else None)
278
+ output.append("\nEnabled tools (execution):")
279
+ for tool_name, enabled in sorted(settings_snapshot.enabled_tools.items()):
280
+ output.append(f" {tool_name}: {enabled}")
281
+
197
282
  return "\n".join(output)
198
283
 
199
284
  async def _handle_toggle(
@@ -210,7 +295,34 @@ config --action toggle index.scope --path ./project"""
210
295
  )
211
296
  return f"Toggled index.scope to {new_scope.value}"
212
297
 
213
- # Handle tool enable/disable toggle
298
+ # Handle execution tool enable/disable: tools.<name>.enabled or enabled_tools.<name>
299
+ if key.startswith("tools.") and key.endswith(".enabled"):
300
+ parts = key.split(".")
301
+ if len(parts) == 3:
302
+ tool_name = parts[1]
303
+ settings = load_settings(project_dir=path if scope == "local" else None)
304
+ current = bool(settings.enabled_tools.get(tool_name, True))
305
+ settings.enabled_tools[tool_name] = not current
306
+ if scope == "local" and path:
307
+ out = self._save_project_settings(settings, path)
308
+ return f"Toggled {key} to {not current} (project: {out})"
309
+ else:
310
+ out = save_settings(settings, global_config=True)
311
+ return f"Toggled {key} to {not current} (global: {out})"
312
+
313
+ if key.startswith("enabled_tools."):
314
+ tool_name = key.split(".", 1)[1]
315
+ settings = load_settings(project_dir=path if scope == "local" else None)
316
+ current = bool(settings.enabled_tools.get(tool_name, True))
317
+ settings.enabled_tools[tool_name] = not current
318
+ if scope == "local" and path:
319
+ out = self._save_project_settings(settings, path)
320
+ return f"Toggled {key} to {not current} (project: {out})"
321
+ else:
322
+ out = save_settings(settings, global_config=True)
323
+ return f"Toggled {key} to {not current} (global: {out})"
324
+
325
+ # Handle indexing toggles (legacy)
214
326
  if "." in key:
215
327
  tool, setting = key.split(".", 1)
216
328
  if setting == "enabled":
@@ -221,4 +221,22 @@ def register_filesystem_tools(
221
221
  tools = get_filesystem_tools(permission_manager, project_manager)
222
222
 
223
223
  ToolRegistry.register_tools(mcp_server, tools)
224
+
225
+ # Register 'symbols' as an alias for 'ast' to match common terminology
226
+ try:
227
+ ast_tool = next((t for t in tools if getattr(t, "name", "") == "ast"), None)
228
+ if ast_tool is not None:
229
+ class _SymbolsAlias(ASTTool): # type: ignore[misc]
230
+ @property
231
+ def name(self) -> str: # type: ignore[override]
232
+ return "symbols"
233
+
234
+ @property
235
+ def description(self) -> str: # type: ignore[override]
236
+ return f"Alias of 'ast' tool.\n\n{ast_tool.description}"
237
+
238
+ ToolRegistry.register_tool(mcp_server, _SymbolsAlias(permission_manager))
239
+ except Exception:
240
+ # Alias is best-effort; don't fail tool registration if aliasing fails
241
+ pass
224
242
  return tools
@@ -1,22 +1,50 @@
1
- """LLM tools for Hanzo AI."""
2
-
3
- # Legacy imports for backwards compatibility
4
- from hanzo_mcp.tools.llm.llm_tool import LLMTool
5
- from hanzo_mcp.tools.llm.llm_manage import LLMManageTool
6
- from hanzo_mcp.tools.llm.consensus_tool import ConsensusTool
7
- from hanzo_mcp.tools.llm.provider_tools import (
8
- GroqTool,
9
- GeminiTool,
10
- OpenAITool,
11
- MistralTool,
12
- AnthropicTool,
13
- PerplexityTool,
14
- create_provider_tools,
15
- )
1
+ """LLM tools for Hanzo AI.
2
+
3
+ This package exposes LLM-related tools. Imports are guarded to keep the
4
+ package importable on environments lacking optional dependencies or newer
5
+ typing features.
6
+ """
7
+
8
+ try: # pragma: no cover - guard heavy imports
9
+ from hanzo_mcp.tools.llm.llm_tool import LLMTool
10
+ except Exception: # pragma: no cover
11
+ LLMTool = None # type: ignore
12
+
13
+ try: # pragma: no cover
14
+ from hanzo_mcp.tools.llm.llm_manage import LLMManageTool
15
+ except Exception:
16
+ LLMManageTool = None # type: ignore
17
+
18
+ try: # pragma: no cover
19
+ from hanzo_mcp.tools.llm.consensus_tool import ConsensusTool
20
+ except Exception:
21
+ ConsensusTool = None # type: ignore
22
+
23
+ try: # pragma: no cover
24
+ from hanzo_mcp.tools.llm.llm_unified import UnifiedLLMTool
25
+ except Exception:
26
+ UnifiedLLMTool = None # type: ignore
27
+
28
+ try: # pragma: no cover
29
+ from hanzo_mcp.tools.llm.provider_tools import (
30
+ GroqTool,
31
+ GeminiTool,
32
+ OpenAITool,
33
+ MistralTool,
34
+ AnthropicTool,
35
+ PerplexityTool,
36
+ create_provider_tools,
37
+ )
38
+ except Exception: # pragma: no cover
39
+ GroqTool = GeminiTool = OpenAITool = MistralTool = AnthropicTool = PerplexityTool = None # type: ignore
40
+
41
+ def create_provider_tools(*args, **kwargs): # type: ignore
42
+ return []
43
+
16
44
 
17
45
  __all__ = [
18
46
  "LLMTool",
19
- "LLMTool",
47
+ "UnifiedLLMTool",
20
48
  "ConsensusTool",
21
49
  "LLMManageTool",
22
50
  "create_provider_tools",
@@ -280,6 +280,19 @@ Available: {", ".join(available) if available else "None"}"""
280
280
  # Running in test mode without MCP context
281
281
  pass
282
282
 
283
+ # Fast test path: allow offline deterministic responses
284
+ if os.getenv("HANZO_MCP_FAST_TESTS") == "1":
285
+ action = params.get("action", "query")
286
+ if action == "query":
287
+ prompt = params.get("prompt", "") or ""
288
+ # Simple keyword routing for tests
289
+ if "generate tasks" in prompt.lower():
290
+ return '{"project": "Demo", "tasks": [{"title": "Init repo", "slug": "init-repo"}, {"title": "Add CI", "slug": "add-ci"}]}'
291
+ return "Architecture Proposal: minimal service layers and tests"
292
+ elif action == "consensus":
293
+ return "Consensus: aligned"
294
+ # Other actions can fall through to regular path if available
295
+
283
296
  if not LITELLM_AVAILABLE:
284
297
  return (
285
298
  "Error: LiteLLM is not installed. Install it with: pip install litellm"