hanzo-mcp 0.5.2__py3-none-any.whl → 0.6.1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Potentially problematic release.
This version of hanzo-mcp might be problematic. Click here for more details.
- hanzo_mcp/__init__.py +1 -1
- hanzo_mcp/cli.py +32 -0
- hanzo_mcp/dev_server.py +246 -0
- hanzo_mcp/prompts/__init__.py +1 -1
- hanzo_mcp/prompts/project_system.py +43 -7
- hanzo_mcp/server.py +5 -1
- hanzo_mcp/tools/__init__.py +66 -35
- hanzo_mcp/tools/agent/__init__.py +1 -1
- hanzo_mcp/tools/agent/agent.py +401 -0
- hanzo_mcp/tools/agent/agent_tool.py +3 -4
- hanzo_mcp/tools/common/__init__.py +1 -1
- hanzo_mcp/tools/common/base.py +2 -2
- hanzo_mcp/tools/common/batch_tool.py +3 -5
- hanzo_mcp/tools/common/config_tool.py +1 -1
- hanzo_mcp/tools/common/context.py +1 -1
- hanzo_mcp/tools/common/palette.py +344 -0
- hanzo_mcp/tools/common/palette_loader.py +108 -0
- hanzo_mcp/tools/common/stats.py +1 -1
- hanzo_mcp/tools/common/thinking_tool.py +3 -5
- hanzo_mcp/tools/common/tool_disable.py +1 -1
- hanzo_mcp/tools/common/tool_enable.py +1 -1
- hanzo_mcp/tools/common/tool_list.py +49 -52
- hanzo_mcp/tools/config/__init__.py +10 -0
- hanzo_mcp/tools/config/config_tool.py +212 -0
- hanzo_mcp/tools/config/index_config.py +176 -0
- hanzo_mcp/tools/config/palette_tool.py +166 -0
- hanzo_mcp/tools/database/__init__.py +1 -1
- hanzo_mcp/tools/database/graph.py +482 -0
- hanzo_mcp/tools/database/graph_add.py +1 -1
- hanzo_mcp/tools/database/graph_query.py +1 -1
- hanzo_mcp/tools/database/graph_remove.py +1 -1
- hanzo_mcp/tools/database/graph_search.py +1 -1
- hanzo_mcp/tools/database/graph_stats.py +1 -1
- hanzo_mcp/tools/database/sql.py +411 -0
- hanzo_mcp/tools/database/sql_query.py +1 -1
- hanzo_mcp/tools/database/sql_search.py +1 -1
- hanzo_mcp/tools/database/sql_stats.py +1 -1
- hanzo_mcp/tools/editor/neovim_command.py +1 -1
- hanzo_mcp/tools/editor/neovim_edit.py +1 -1
- hanzo_mcp/tools/editor/neovim_session.py +1 -1
- hanzo_mcp/tools/filesystem/__init__.py +42 -13
- hanzo_mcp/tools/filesystem/base.py +1 -1
- hanzo_mcp/tools/filesystem/batch_search.py +4 -4
- hanzo_mcp/tools/filesystem/content_replace.py +3 -5
- hanzo_mcp/tools/filesystem/diff.py +193 -0
- hanzo_mcp/tools/filesystem/directory_tree.py +3 -5
- hanzo_mcp/tools/filesystem/edit.py +3 -5
- hanzo_mcp/tools/filesystem/find.py +443 -0
- hanzo_mcp/tools/filesystem/find_files.py +1 -1
- hanzo_mcp/tools/filesystem/git_search.py +1 -1
- hanzo_mcp/tools/filesystem/grep.py +2 -2
- hanzo_mcp/tools/filesystem/multi_edit.py +3 -5
- hanzo_mcp/tools/filesystem/read.py +17 -5
- hanzo_mcp/tools/filesystem/{grep_ast_tool.py → symbols.py} +17 -27
- hanzo_mcp/tools/filesystem/symbols_unified.py +376 -0
- hanzo_mcp/tools/filesystem/tree.py +268 -0
- hanzo_mcp/tools/filesystem/unified_search.py +711 -0
- hanzo_mcp/tools/filesystem/unix_aliases.py +99 -0
- hanzo_mcp/tools/filesystem/watch.py +174 -0
- hanzo_mcp/tools/filesystem/write.py +3 -5
- hanzo_mcp/tools/jupyter/__init__.py +9 -12
- hanzo_mcp/tools/jupyter/base.py +1 -1
- hanzo_mcp/tools/jupyter/jupyter.py +326 -0
- hanzo_mcp/tools/jupyter/notebook_edit.py +3 -4
- hanzo_mcp/tools/jupyter/notebook_read.py +3 -5
- hanzo_mcp/tools/llm/__init__.py +4 -0
- hanzo_mcp/tools/llm/consensus_tool.py +1 -1
- hanzo_mcp/tools/llm/llm_manage.py +1 -1
- hanzo_mcp/tools/llm/llm_tool.py +1 -1
- hanzo_mcp/tools/llm/llm_unified.py +851 -0
- hanzo_mcp/tools/llm/provider_tools.py +1 -1
- hanzo_mcp/tools/mcp/__init__.py +4 -0
- hanzo_mcp/tools/mcp/mcp_add.py +1 -1
- hanzo_mcp/tools/mcp/mcp_remove.py +1 -1
- hanzo_mcp/tools/mcp/mcp_stats.py +1 -1
- hanzo_mcp/tools/mcp/mcp_unified.py +503 -0
- hanzo_mcp/tools/shell/__init__.py +20 -42
- hanzo_mcp/tools/shell/base.py +1 -1
- hanzo_mcp/tools/shell/base_process.py +303 -0
- hanzo_mcp/tools/shell/bash_unified.py +134 -0
- hanzo_mcp/tools/shell/logs.py +1 -1
- hanzo_mcp/tools/shell/npx.py +1 -1
- hanzo_mcp/tools/shell/npx_background.py +1 -1
- hanzo_mcp/tools/shell/npx_unified.py +101 -0
- hanzo_mcp/tools/shell/open.py +107 -0
- hanzo_mcp/tools/shell/pkill.py +1 -1
- hanzo_mcp/tools/shell/process_unified.py +131 -0
- hanzo_mcp/tools/shell/processes.py +1 -1
- hanzo_mcp/tools/shell/run_background.py +1 -1
- hanzo_mcp/tools/shell/run_command.py +3 -4
- hanzo_mcp/tools/shell/run_command_windows.py +3 -4
- hanzo_mcp/tools/shell/uvx.py +1 -1
- hanzo_mcp/tools/shell/uvx_background.py +1 -1
- hanzo_mcp/tools/shell/uvx_unified.py +101 -0
- hanzo_mcp/tools/todo/__init__.py +1 -1
- hanzo_mcp/tools/todo/base.py +1 -1
- hanzo_mcp/tools/todo/todo.py +265 -0
- hanzo_mcp/tools/todo/todo_read.py +3 -5
- hanzo_mcp/tools/todo/todo_write.py +3 -5
- hanzo_mcp/tools/vector/__init__.py +1 -1
- hanzo_mcp/tools/vector/index_tool.py +1 -1
- hanzo_mcp/tools/vector/project_manager.py +27 -5
- hanzo_mcp/tools/vector/vector.py +311 -0
- hanzo_mcp/tools/vector/vector_index.py +1 -1
- hanzo_mcp/tools/vector/vector_search.py +1 -1
- hanzo_mcp-0.6.1.dist-info/METADATA +336 -0
- hanzo_mcp-0.6.1.dist-info/RECORD +134 -0
- hanzo_mcp-0.6.1.dist-info/entry_points.txt +3 -0
- hanzo_mcp-0.5.2.dist-info/METADATA +0 -276
- hanzo_mcp-0.5.2.dist-info/RECORD +0 -106
- hanzo_mcp-0.5.2.dist-info/entry_points.txt +0 -2
- {hanzo_mcp-0.5.2.dist-info → hanzo_mcp-0.6.1.dist-info}/WHEEL +0 -0
- {hanzo_mcp-0.5.2.dist-info → hanzo_mcp-0.6.1.dist-info}/licenses/LICENSE +0 -0
- {hanzo_mcp-0.5.2.dist-info → hanzo_mcp-0.6.1.dist-info}/top_level.txt +0 -0
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
"""Unix command aliases for common tools.
|
|
2
|
+
|
|
3
|
+
Provides familiar Unix command names that map to MCP tools.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from typing import Dict, Type
|
|
7
|
+
from hanzo_mcp.tools.common.base import BaseTool
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def get_unix_aliases() -> Dict[str, str]:
|
|
11
|
+
"""Get mapping of Unix commands to MCP tool names.
|
|
12
|
+
|
|
13
|
+
Returns:
|
|
14
|
+
Dictionary mapping Unix command names to MCP tool names
|
|
15
|
+
"""
|
|
16
|
+
return {
|
|
17
|
+
# File operations
|
|
18
|
+
"ls": "list_directory",
|
|
19
|
+
"cat": "read_file",
|
|
20
|
+
"head": "read_file", # With line limit
|
|
21
|
+
"tail": "read_file", # With offset
|
|
22
|
+
"cp": "copy_path",
|
|
23
|
+
"mv": "move_path",
|
|
24
|
+
"rm": "delete_path",
|
|
25
|
+
"mkdir": "create_directory",
|
|
26
|
+
"touch": "write_file", # Create empty file
|
|
27
|
+
|
|
28
|
+
# Search operations
|
|
29
|
+
"grep": "find", # Our unified find tool
|
|
30
|
+
"find": "glob", # For finding files by name
|
|
31
|
+
"ffind": "find", # Fast file content search
|
|
32
|
+
"rg": "find", # Ripgrep alias
|
|
33
|
+
"ag": "find", # Silver searcher alias
|
|
34
|
+
"ack": "find", # Ack alias
|
|
35
|
+
|
|
36
|
+
# Directory operations
|
|
37
|
+
"tree": "tree", # Already named correctly
|
|
38
|
+
"pwd": "get_working_directory",
|
|
39
|
+
"cd": "change_directory",
|
|
40
|
+
|
|
41
|
+
# Git operations (if git tools enabled)
|
|
42
|
+
"git": "git_command",
|
|
43
|
+
|
|
44
|
+
# Process operations
|
|
45
|
+
"ps": "list_processes",
|
|
46
|
+
"kill": "kill_process",
|
|
47
|
+
|
|
48
|
+
# Archive operations
|
|
49
|
+
"tar": "archive",
|
|
50
|
+
"unzip": "extract",
|
|
51
|
+
|
|
52
|
+
# Network operations
|
|
53
|
+
"curl": "http_request",
|
|
54
|
+
"wget": "download_file",
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
class UnixAliasRegistry:
|
|
59
|
+
"""Registry for Unix command aliases."""
|
|
60
|
+
|
|
61
|
+
def __init__(self):
|
|
62
|
+
self.aliases = get_unix_aliases()
|
|
63
|
+
|
|
64
|
+
def register_aliases(self, mcp_server, tools: Dict[str, BaseTool]) -> None:
|
|
65
|
+
"""Register Unix aliases for tools.
|
|
66
|
+
|
|
67
|
+
Args:
|
|
68
|
+
mcp_server: The MCP server instance
|
|
69
|
+
tools: Dictionary of tool name to tool instance
|
|
70
|
+
"""
|
|
71
|
+
for alias, tool_name in self.aliases.items():
|
|
72
|
+
if tool_name in tools:
|
|
73
|
+
tool = tools[tool_name]
|
|
74
|
+
# Register the tool under its alias name
|
|
75
|
+
self._register_alias(mcp_server, alias, tool)
|
|
76
|
+
|
|
77
|
+
def _register_alias(self, mcp_server, alias: str, tool: BaseTool) -> None:
|
|
78
|
+
"""Register a single alias for a tool.
|
|
79
|
+
|
|
80
|
+
Args:
|
|
81
|
+
mcp_server: The MCP server instance
|
|
82
|
+
alias: The Unix command alias
|
|
83
|
+
tool: The tool instance
|
|
84
|
+
"""
|
|
85
|
+
# Create a wrapper that preserves the original tool's functionality
|
|
86
|
+
# but registers under the alias name
|
|
87
|
+
original_name = tool.name
|
|
88
|
+
original_description = tool.description
|
|
89
|
+
|
|
90
|
+
# Temporarily change the tool's name for registration
|
|
91
|
+
tool.name = alias
|
|
92
|
+
tool.description = f"{original_description}\n\n(Unix alias for {original_name})"
|
|
93
|
+
|
|
94
|
+
# Register the tool
|
|
95
|
+
tool.register(mcp_server)
|
|
96
|
+
|
|
97
|
+
# Restore original name
|
|
98
|
+
tool.name = original_name
|
|
99
|
+
tool.description = original_description
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
"""Watch files for changes."""
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import os
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import override
|
|
7
|
+
import time
|
|
8
|
+
from datetime import datetime
|
|
9
|
+
|
|
10
|
+
from mcp.server.fastmcp import Context as MCPContext
|
|
11
|
+
|
|
12
|
+
from hanzo_mcp.tools.common.base import BaseTool
|
|
13
|
+
from mcp.server import FastMCP
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class WatchTool(BaseTool):
|
|
17
|
+
"""Tool for watching files for changes."""
|
|
18
|
+
|
|
19
|
+
name = "watch"
|
|
20
|
+
|
|
21
|
+
def register(self, server: FastMCP) -> None:
|
|
22
|
+
"""Register the tool with the MCP server."""
|
|
23
|
+
server.tool(name=self.name, description=self.description)(self.call)
|
|
24
|
+
|
|
25
|
+
async def call(self, **kwargs) -> str:
|
|
26
|
+
"""Call the tool with arguments."""
|
|
27
|
+
return await self.run(None, **kwargs)
|
|
28
|
+
|
|
29
|
+
@property
|
|
30
|
+
@override
|
|
31
|
+
def description(self) -> str:
|
|
32
|
+
"""Get the tool description."""
|
|
33
|
+
return """Watch files for changes. Reports modifications.
|
|
34
|
+
|
|
35
|
+
Usage:
|
|
36
|
+
watch ./src --pattern "*.py" --interval 2
|
|
37
|
+
watch config.json
|
|
38
|
+
watch . --recursive --exclude "__pycache__"
|
|
39
|
+
"""
|
|
40
|
+
|
|
41
|
+
@override
|
|
42
|
+
async def run(
|
|
43
|
+
self,
|
|
44
|
+
ctx: MCPContext,
|
|
45
|
+
path: str,
|
|
46
|
+
pattern: str = "*",
|
|
47
|
+
interval: int = 1,
|
|
48
|
+
recursive: bool = True,
|
|
49
|
+
exclude: str = "",
|
|
50
|
+
duration: int = 30,
|
|
51
|
+
) -> str:
|
|
52
|
+
"""Watch files for changes.
|
|
53
|
+
|
|
54
|
+
Args:
|
|
55
|
+
ctx: MCP context
|
|
56
|
+
path: Path to watch (file or directory)
|
|
57
|
+
pattern: Glob pattern for files to watch (default: "*")
|
|
58
|
+
interval: Check interval in seconds (default: 1)
|
|
59
|
+
recursive: Watch subdirectories (default: True)
|
|
60
|
+
exclude: Patterns to exclude (comma-separated)
|
|
61
|
+
duration: Max watch duration in seconds (default: 30)
|
|
62
|
+
|
|
63
|
+
Returns:
|
|
64
|
+
Report of file changes
|
|
65
|
+
"""
|
|
66
|
+
watch_path = Path(path).expanduser().resolve()
|
|
67
|
+
|
|
68
|
+
if not watch_path.exists():
|
|
69
|
+
raise ValueError(f"Path does not exist: {watch_path}")
|
|
70
|
+
|
|
71
|
+
# Parse exclude patterns
|
|
72
|
+
exclude_patterns = [p.strip() for p in exclude.split(",") if p.strip()]
|
|
73
|
+
|
|
74
|
+
# Track file states
|
|
75
|
+
file_states = {}
|
|
76
|
+
changes = []
|
|
77
|
+
start_time = time.time()
|
|
78
|
+
|
|
79
|
+
def should_exclude(file_path: Path) -> bool:
|
|
80
|
+
"""Check if file should be excluded."""
|
|
81
|
+
for pattern in exclude_patterns:
|
|
82
|
+
if pattern in str(file_path):
|
|
83
|
+
return True
|
|
84
|
+
if file_path.match(pattern):
|
|
85
|
+
return True
|
|
86
|
+
return False
|
|
87
|
+
|
|
88
|
+
def get_files() -> dict[Path, float]:
|
|
89
|
+
"""Get all matching files with their modification times."""
|
|
90
|
+
files = {}
|
|
91
|
+
|
|
92
|
+
if watch_path.is_file():
|
|
93
|
+
# Watching a single file
|
|
94
|
+
if not should_exclude(watch_path):
|
|
95
|
+
try:
|
|
96
|
+
files[watch_path] = watch_path.stat().st_mtime
|
|
97
|
+
except:
|
|
98
|
+
pass
|
|
99
|
+
else:
|
|
100
|
+
# Watching a directory
|
|
101
|
+
if recursive:
|
|
102
|
+
paths = watch_path.rglob(pattern)
|
|
103
|
+
else:
|
|
104
|
+
paths = watch_path.glob(pattern)
|
|
105
|
+
|
|
106
|
+
for file_path in paths:
|
|
107
|
+
if file_path.is_file() and not should_exclude(file_path):
|
|
108
|
+
try:
|
|
109
|
+
files[file_path] = file_path.stat().st_mtime
|
|
110
|
+
except:
|
|
111
|
+
pass
|
|
112
|
+
|
|
113
|
+
return files
|
|
114
|
+
|
|
115
|
+
# Initial scan
|
|
116
|
+
file_states = get_files()
|
|
117
|
+
initial_count = len(file_states)
|
|
118
|
+
|
|
119
|
+
output = [f"Watching {watch_path} (pattern: {pattern})"]
|
|
120
|
+
output.append(f"Found {initial_count} files to monitor")
|
|
121
|
+
if exclude_patterns:
|
|
122
|
+
output.append(f"Excluding: {', '.join(exclude_patterns)}")
|
|
123
|
+
output.append(f"Monitoring for {duration} seconds...\n")
|
|
124
|
+
|
|
125
|
+
# Monitor for changes
|
|
126
|
+
try:
|
|
127
|
+
while (time.time() - start_time) < duration:
|
|
128
|
+
await asyncio.sleep(interval)
|
|
129
|
+
|
|
130
|
+
current_files = get_files()
|
|
131
|
+
|
|
132
|
+
# Check for new files
|
|
133
|
+
for file_path, mtime in current_files.items():
|
|
134
|
+
if file_path not in file_states:
|
|
135
|
+
timestamp = datetime.now().strftime("%H:%M:%S")
|
|
136
|
+
change = f"[{timestamp}] CREATED: {file_path.relative_to(watch_path.parent)}"
|
|
137
|
+
changes.append(change)
|
|
138
|
+
output.append(change)
|
|
139
|
+
|
|
140
|
+
# Check for deleted files
|
|
141
|
+
for file_path in list(file_states.keys()):
|
|
142
|
+
if file_path not in current_files:
|
|
143
|
+
timestamp = datetime.now().strftime("%H:%M:%S")
|
|
144
|
+
change = f"[{timestamp}] DELETED: {file_path.relative_to(watch_path.parent)}"
|
|
145
|
+
changes.append(change)
|
|
146
|
+
output.append(change)
|
|
147
|
+
del file_states[file_path]
|
|
148
|
+
|
|
149
|
+
# Check for modified files
|
|
150
|
+
for file_path, mtime in current_files.items():
|
|
151
|
+
if file_path in file_states and mtime != file_states[file_path]:
|
|
152
|
+
timestamp = datetime.now().strftime("%H:%M:%S")
|
|
153
|
+
change = f"[{timestamp}] MODIFIED: {file_path.relative_to(watch_path.parent)}"
|
|
154
|
+
changes.append(change)
|
|
155
|
+
output.append(change)
|
|
156
|
+
file_states[file_path] = mtime
|
|
157
|
+
|
|
158
|
+
# Update file states for new files
|
|
159
|
+
for file_path, mtime in current_files.items():
|
|
160
|
+
if file_path not in file_states:
|
|
161
|
+
file_states[file_path] = mtime
|
|
162
|
+
|
|
163
|
+
except asyncio.CancelledError:
|
|
164
|
+
output.append("\nWatch cancelled")
|
|
165
|
+
|
|
166
|
+
# Summary
|
|
167
|
+
output.append(f"\nWatch completed after {int(time.time() - start_time)} seconds")
|
|
168
|
+
output.append(f"Total changes detected: {len(changes)}")
|
|
169
|
+
|
|
170
|
+
return "\n".join(output)
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
# Create tool instance
|
|
174
|
+
watch_tool = WatchTool()
|
|
@@ -6,9 +6,8 @@ This module provides the Write tool for creating or overwriting files.
|
|
|
6
6
|
from pathlib import Path
|
|
7
7
|
from typing import Annotated, TypedDict, Unpack, final, override
|
|
8
8
|
|
|
9
|
-
from fastmcp import Context as MCPContext
|
|
10
|
-
from
|
|
11
|
-
from fastmcp.server.dependencies import get_context
|
|
9
|
+
from mcp.server.fastmcp import Context as MCPContext
|
|
10
|
+
from mcp.server import FastMCP
|
|
12
11
|
from pydantic import Field
|
|
13
12
|
|
|
14
13
|
from hanzo_mcp.tools.filesystem.base import FilesystemBaseTool
|
|
@@ -148,9 +147,8 @@ Usage:
|
|
|
148
147
|
|
|
149
148
|
@mcp_server.tool(name=self.name, description=self.description)
|
|
150
149
|
async def write(
|
|
151
|
-
ctx: MCPContext,
|
|
152
150
|
file_path: FilePath,
|
|
153
151
|
content: Content,
|
|
152
|
+
ctx: MCPContext
|
|
154
153
|
) -> str:
|
|
155
|
-
ctx = get_context()
|
|
156
154
|
return await tool_self.call(ctx, file_path=file_path, content=content)
|
|
@@ -4,17 +4,15 @@ This package provides tools for working with Jupyter notebooks (.ipynb files),
|
|
|
4
4
|
including reading and editing notebook cells.
|
|
5
5
|
"""
|
|
6
6
|
|
|
7
|
-
from
|
|
7
|
+
from mcp.server import FastMCP
|
|
8
8
|
|
|
9
9
|
from hanzo_mcp.tools.common.base import BaseTool, ToolRegistry
|
|
10
10
|
from hanzo_mcp.tools.common.permissions import PermissionManager
|
|
11
|
-
from hanzo_mcp.tools.jupyter.
|
|
12
|
-
from hanzo_mcp.tools.jupyter.notebook_read import NotebookReadTool
|
|
11
|
+
from hanzo_mcp.tools.jupyter.jupyter import JupyterTool
|
|
13
12
|
|
|
14
13
|
# Export all tool classes
|
|
15
14
|
__all__ = [
|
|
16
|
-
"
|
|
17
|
-
"NoteBookEditTool",
|
|
15
|
+
"JupyterTool",
|
|
18
16
|
"get_jupyter_tools",
|
|
19
17
|
"register_jupyter_tools",
|
|
20
18
|
]
|
|
@@ -31,9 +29,7 @@ def get_read_only_jupyter_tools(
|
|
|
31
29
|
Returns:
|
|
32
30
|
List of Jupyter notebook tool instances
|
|
33
31
|
"""
|
|
34
|
-
return [
|
|
35
|
-
NotebookReadTool(permission_manager),
|
|
36
|
-
]
|
|
32
|
+
return [] # Unified tool handles both read and write
|
|
37
33
|
|
|
38
34
|
|
|
39
35
|
def get_jupyter_tools(permission_manager: PermissionManager) -> list[BaseTool]:
|
|
@@ -46,8 +42,7 @@ def get_jupyter_tools(permission_manager: PermissionManager) -> list[BaseTool]:
|
|
|
46
42
|
List of Jupyter notebook tool instances
|
|
47
43
|
"""
|
|
48
44
|
return [
|
|
49
|
-
|
|
50
|
-
NoteBookEditTool(permission_manager),
|
|
45
|
+
JupyterTool(permission_manager),
|
|
51
46
|
]
|
|
52
47
|
|
|
53
48
|
|
|
@@ -68,8 +63,10 @@ def register_jupyter_tools(
|
|
|
68
63
|
"""
|
|
69
64
|
# Define tool mapping
|
|
70
65
|
tool_classes = {
|
|
71
|
-
"
|
|
72
|
-
|
|
66
|
+
"jupyter": JupyterTool,
|
|
67
|
+
# Legacy names for backward compatibility
|
|
68
|
+
"notebook_read": JupyterTool,
|
|
69
|
+
"notebook_edit": JupyterTool,
|
|
73
70
|
}
|
|
74
71
|
|
|
75
72
|
tools = []
|
hanzo_mcp/tools/jupyter/base.py
CHANGED
|
@@ -10,7 +10,7 @@ import re
|
|
|
10
10
|
from pathlib import Path
|
|
11
11
|
from typing import Any, final
|
|
12
12
|
|
|
13
|
-
from fastmcp import Context as MCPContext
|
|
13
|
+
from mcp.server.fastmcp import Context as MCPContext
|
|
14
14
|
|
|
15
15
|
from hanzo_mcp.tools.common.base import FileSystemTool
|
|
16
16
|
from hanzo_mcp.tools.common.context import ToolContext, create_tool_context
|
|
@@ -0,0 +1,326 @@
|
|
|
1
|
+
"""Unified Jupyter notebook tool."""
|
|
2
|
+
|
|
3
|
+
from typing import Annotated, TypedDict, Unpack, final, override, Optional, List, Dict, Any
|
|
4
|
+
import json
|
|
5
|
+
import nbformat
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
from mcp.server.fastmcp import Context as MCPContext
|
|
9
|
+
from pydantic import Field
|
|
10
|
+
|
|
11
|
+
from hanzo_mcp.tools.jupyter.base import JupyterBaseTool
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
# Parameter types
|
|
15
|
+
Action = Annotated[
|
|
16
|
+
str,
|
|
17
|
+
Field(
|
|
18
|
+
description="Action to perform: read (default), edit, create, delete, execute",
|
|
19
|
+
default="read",
|
|
20
|
+
),
|
|
21
|
+
]
|
|
22
|
+
|
|
23
|
+
NotebookPath = Annotated[
|
|
24
|
+
str,
|
|
25
|
+
Field(
|
|
26
|
+
description="Path to the Jupyter notebook file (.ipynb)",
|
|
27
|
+
),
|
|
28
|
+
]
|
|
29
|
+
|
|
30
|
+
CellId = Annotated[
|
|
31
|
+
Optional[str],
|
|
32
|
+
Field(
|
|
33
|
+
description="Cell ID for targeted operations",
|
|
34
|
+
default=None,
|
|
35
|
+
),
|
|
36
|
+
]
|
|
37
|
+
|
|
38
|
+
CellIndex = Annotated[
|
|
39
|
+
Optional[int],
|
|
40
|
+
Field(
|
|
41
|
+
description="Cell index (0-based) for operations",
|
|
42
|
+
default=None,
|
|
43
|
+
),
|
|
44
|
+
]
|
|
45
|
+
|
|
46
|
+
CellType = Annotated[
|
|
47
|
+
Optional[str],
|
|
48
|
+
Field(
|
|
49
|
+
description="Cell type: code or markdown",
|
|
50
|
+
default=None,
|
|
51
|
+
),
|
|
52
|
+
]
|
|
53
|
+
|
|
54
|
+
Source = Annotated[
|
|
55
|
+
Optional[str],
|
|
56
|
+
Field(
|
|
57
|
+
description="New source content for cell",
|
|
58
|
+
default=None,
|
|
59
|
+
),
|
|
60
|
+
]
|
|
61
|
+
|
|
62
|
+
EditMode = Annotated[
|
|
63
|
+
str,
|
|
64
|
+
Field(
|
|
65
|
+
description="Edit mode: replace (default), insert, delete",
|
|
66
|
+
default="replace",
|
|
67
|
+
),
|
|
68
|
+
]
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
class NotebookParams(TypedDict, total=False):
|
|
72
|
+
"""Parameters for notebook tool."""
|
|
73
|
+
action: str
|
|
74
|
+
notebook_path: str
|
|
75
|
+
cell_id: Optional[str]
|
|
76
|
+
cell_index: Optional[int]
|
|
77
|
+
cell_type: Optional[str]
|
|
78
|
+
source: Optional[str]
|
|
79
|
+
edit_mode: str
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
@final
|
|
83
|
+
class JupyterTool(JupyterBaseTool):
|
|
84
|
+
"""Unified tool for Jupyter notebook operations."""
|
|
85
|
+
|
|
86
|
+
@property
|
|
87
|
+
@override
|
|
88
|
+
def name(self) -> str:
|
|
89
|
+
"""Get the tool name."""
|
|
90
|
+
return "jupyter"
|
|
91
|
+
|
|
92
|
+
@property
|
|
93
|
+
@override
|
|
94
|
+
def description(self) -> str:
|
|
95
|
+
"""Get the tool description."""
|
|
96
|
+
return """Jupyter notebooks. Actions: read (default), edit, create, delete, execute.
|
|
97
|
+
|
|
98
|
+
Usage:
|
|
99
|
+
jupyter "path/to/notebook.ipynb"
|
|
100
|
+
jupyter "notebook.ipynb" --cell-index 2
|
|
101
|
+
jupyter --action edit "notebook.ipynb" --cell-index 0 --source "print('Hello')"
|
|
102
|
+
jupyter --action create "new.ipynb"
|
|
103
|
+
"""
|
|
104
|
+
|
|
105
|
+
@override
|
|
106
|
+
async def call(
|
|
107
|
+
self,
|
|
108
|
+
ctx: MCPContext,
|
|
109
|
+
**params: Unpack[NotebookParams],
|
|
110
|
+
) -> str:
|
|
111
|
+
"""Execute notebook operation."""
|
|
112
|
+
tool_ctx = self.create_tool_context(ctx)
|
|
113
|
+
|
|
114
|
+
# Extract parameters
|
|
115
|
+
action = params.get("action", "read")
|
|
116
|
+
notebook_path = params.get("notebook_path")
|
|
117
|
+
|
|
118
|
+
if not notebook_path:
|
|
119
|
+
return "Error: notebook_path is required"
|
|
120
|
+
|
|
121
|
+
# Validate path
|
|
122
|
+
path_validation = self.validate_path(notebook_path)
|
|
123
|
+
if path_validation.is_error:
|
|
124
|
+
await tool_ctx.error(path_validation.error_message)
|
|
125
|
+
return f"Error: {path_validation.error_message}"
|
|
126
|
+
|
|
127
|
+
# Check permissions
|
|
128
|
+
allowed, error_msg = await self.check_path_allowed(notebook_path, tool_ctx)
|
|
129
|
+
if not allowed:
|
|
130
|
+
return error_msg
|
|
131
|
+
|
|
132
|
+
# Route to appropriate handler
|
|
133
|
+
if action == "read":
|
|
134
|
+
return await self._handle_read(notebook_path, params, tool_ctx)
|
|
135
|
+
elif action == "edit":
|
|
136
|
+
return await self._handle_edit(notebook_path, params, tool_ctx)
|
|
137
|
+
elif action == "create":
|
|
138
|
+
return await self._handle_create(notebook_path, tool_ctx)
|
|
139
|
+
elif action == "delete":
|
|
140
|
+
return await self._handle_delete(notebook_path, params, tool_ctx)
|
|
141
|
+
elif action == "execute":
|
|
142
|
+
return await self._handle_execute(notebook_path, params, tool_ctx)
|
|
143
|
+
else:
|
|
144
|
+
return f"Error: Unknown action '{action}'. Valid actions: read, edit, create, delete, execute"
|
|
145
|
+
|
|
146
|
+
async def _handle_read(self, notebook_path: str, params: Dict[str, Any], tool_ctx) -> str:
|
|
147
|
+
"""Read notebook or specific cell."""
|
|
148
|
+
exists, error_msg = await self.check_path_exists(notebook_path, tool_ctx)
|
|
149
|
+
if not exists:
|
|
150
|
+
return error_msg
|
|
151
|
+
|
|
152
|
+
try:
|
|
153
|
+
nb = self.read_notebook(notebook_path)
|
|
154
|
+
|
|
155
|
+
# Check if specific cell requested
|
|
156
|
+
cell_id = params.get("cell_id")
|
|
157
|
+
cell_index = params.get("cell_index")
|
|
158
|
+
|
|
159
|
+
if cell_id:
|
|
160
|
+
# Find cell by ID
|
|
161
|
+
for i, cell in enumerate(nb.cells):
|
|
162
|
+
if cell.get("id") == cell_id:
|
|
163
|
+
return self._format_cell(cell, i)
|
|
164
|
+
return f"Error: Cell with ID '{cell_id}' not found"
|
|
165
|
+
|
|
166
|
+
elif cell_index is not None:
|
|
167
|
+
# Get cell by index
|
|
168
|
+
if 0 <= cell_index < len(nb.cells):
|
|
169
|
+
return self._format_cell(nb.cells[cell_index], cell_index)
|
|
170
|
+
else:
|
|
171
|
+
return f"Error: Cell index {cell_index} out of range (notebook has {len(nb.cells)} cells)"
|
|
172
|
+
|
|
173
|
+
else:
|
|
174
|
+
# Return all cells
|
|
175
|
+
return self.format_notebook(nb)
|
|
176
|
+
|
|
177
|
+
except Exception as e:
|
|
178
|
+
await tool_ctx.error(f"Failed to read notebook: {str(e)}")
|
|
179
|
+
return f"Error reading notebook: {str(e)}"
|
|
180
|
+
|
|
181
|
+
async def _handle_edit(self, notebook_path: str, params: Dict[str, Any], tool_ctx) -> str:
|
|
182
|
+
"""Edit notebook cell."""
|
|
183
|
+
exists, error_msg = await self.check_path_exists(notebook_path, tool_ctx)
|
|
184
|
+
if not exists:
|
|
185
|
+
return error_msg
|
|
186
|
+
|
|
187
|
+
source = params.get("source")
|
|
188
|
+
if not source:
|
|
189
|
+
return "Error: source is required for edit action"
|
|
190
|
+
|
|
191
|
+
edit_mode = params.get("edit_mode", "replace")
|
|
192
|
+
cell_id = params.get("cell_id")
|
|
193
|
+
cell_index = params.get("cell_index")
|
|
194
|
+
cell_type = params.get("cell_type")
|
|
195
|
+
|
|
196
|
+
try:
|
|
197
|
+
nb = self.read_notebook(notebook_path)
|
|
198
|
+
|
|
199
|
+
if edit_mode == "insert":
|
|
200
|
+
# Insert new cell
|
|
201
|
+
new_cell = nbformat.v4.new_code_cell(source) if cell_type != "markdown" else nbformat.v4.new_markdown_cell(source)
|
|
202
|
+
|
|
203
|
+
if cell_index is not None:
|
|
204
|
+
nb.cells.insert(cell_index, new_cell)
|
|
205
|
+
else:
|
|
206
|
+
nb.cells.append(new_cell)
|
|
207
|
+
|
|
208
|
+
self.write_notebook(nb, notebook_path)
|
|
209
|
+
return f"Successfully inserted new cell at index {cell_index if cell_index is not None else len(nb.cells)-1}"
|
|
210
|
+
|
|
211
|
+
elif edit_mode == "delete":
|
|
212
|
+
# Delete cell
|
|
213
|
+
if cell_id:
|
|
214
|
+
for i, cell in enumerate(nb.cells):
|
|
215
|
+
if cell.get("id") == cell_id:
|
|
216
|
+
nb.cells.pop(i)
|
|
217
|
+
self.write_notebook(nb, notebook_path)
|
|
218
|
+
return f"Successfully deleted cell with ID '{cell_id}'"
|
|
219
|
+
return f"Error: Cell with ID '{cell_id}' not found"
|
|
220
|
+
|
|
221
|
+
elif cell_index is not None:
|
|
222
|
+
if 0 <= cell_index < len(nb.cells):
|
|
223
|
+
nb.cells.pop(cell_index)
|
|
224
|
+
self.write_notebook(nb, notebook_path)
|
|
225
|
+
return f"Successfully deleted cell at index {cell_index}"
|
|
226
|
+
else:
|
|
227
|
+
return f"Error: Cell index {cell_index} out of range"
|
|
228
|
+
else:
|
|
229
|
+
return "Error: cell_id or cell_index required for delete"
|
|
230
|
+
|
|
231
|
+
else: # replace
|
|
232
|
+
# Replace cell content
|
|
233
|
+
if cell_id:
|
|
234
|
+
for cell in nb.cells:
|
|
235
|
+
if cell.get("id") == cell_id:
|
|
236
|
+
cell.source = source
|
|
237
|
+
if cell_type:
|
|
238
|
+
cell.cell_type = cell_type
|
|
239
|
+
self.write_notebook(nb, notebook_path)
|
|
240
|
+
return f"Successfully updated cell with ID '{cell_id}'"
|
|
241
|
+
return f"Error: Cell with ID '{cell_id}' not found"
|
|
242
|
+
|
|
243
|
+
elif cell_index is not None:
|
|
244
|
+
if 0 <= cell_index < len(nb.cells):
|
|
245
|
+
nb.cells[cell_index].source = source
|
|
246
|
+
if cell_type:
|
|
247
|
+
nb.cells[cell_index].cell_type = cell_type
|
|
248
|
+
self.write_notebook(nb, notebook_path)
|
|
249
|
+
return f"Successfully updated cell at index {cell_index}"
|
|
250
|
+
else:
|
|
251
|
+
return f"Error: Cell index {cell_index} out of range"
|
|
252
|
+
else:
|
|
253
|
+
return "Error: cell_id or cell_index required for replace"
|
|
254
|
+
|
|
255
|
+
except Exception as e:
|
|
256
|
+
await tool_ctx.error(f"Failed to edit notebook: {str(e)}")
|
|
257
|
+
return f"Error editing notebook: {str(e)}"
|
|
258
|
+
|
|
259
|
+
async def _handle_create(self, notebook_path: str, tool_ctx) -> str:
|
|
260
|
+
"""Create new notebook."""
|
|
261
|
+
# Check if already exists
|
|
262
|
+
path = Path(notebook_path)
|
|
263
|
+
if path.exists():
|
|
264
|
+
return f"Error: Notebook already exists at {notebook_path}"
|
|
265
|
+
|
|
266
|
+
try:
|
|
267
|
+
# Create new notebook
|
|
268
|
+
nb = nbformat.v4.new_notebook()
|
|
269
|
+
|
|
270
|
+
# Ensure parent directory exists
|
|
271
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
272
|
+
|
|
273
|
+
# Write notebook
|
|
274
|
+
self.write_notebook(nb, notebook_path)
|
|
275
|
+
return f"Successfully created notebook at {notebook_path}"
|
|
276
|
+
|
|
277
|
+
except Exception as e:
|
|
278
|
+
await tool_ctx.error(f"Failed to create notebook: {str(e)}")
|
|
279
|
+
return f"Error creating notebook: {str(e)}"
|
|
280
|
+
|
|
281
|
+
async def _handle_delete(self, notebook_path: str, params: Dict[str, Any], tool_ctx) -> str:
|
|
282
|
+
"""Delete notebook or cell."""
|
|
283
|
+
# If cell specified, delegate to edit with delete mode
|
|
284
|
+
if params.get("cell_id") or params.get("cell_index") is not None:
|
|
285
|
+
params["edit_mode"] = "delete"
|
|
286
|
+
return await self._handle_edit(notebook_path, params, tool_ctx)
|
|
287
|
+
|
|
288
|
+
# Otherwise, delete entire notebook
|
|
289
|
+
exists, error_msg = await self.check_path_exists(notebook_path, tool_ctx)
|
|
290
|
+
if not exists:
|
|
291
|
+
return error_msg
|
|
292
|
+
|
|
293
|
+
try:
|
|
294
|
+
Path(notebook_path).unlink()
|
|
295
|
+
return f"Successfully deleted notebook {notebook_path}"
|
|
296
|
+
except Exception as e:
|
|
297
|
+
await tool_ctx.error(f"Failed to delete notebook: {str(e)}")
|
|
298
|
+
return f"Error deleting notebook: {str(e)}"
|
|
299
|
+
|
|
300
|
+
async def _handle_execute(self, notebook_path: str, params: Dict[str, Any], tool_ctx) -> str:
|
|
301
|
+
"""Execute notebook cells (placeholder for future implementation)."""
|
|
302
|
+
return "Error: Cell execution not yet implemented. Use a Jupyter kernel or server for execution."
|
|
303
|
+
|
|
304
|
+
def _format_cell(self, cell: dict, index: int) -> str:
|
|
305
|
+
"""Format a single cell for display."""
|
|
306
|
+
output = [f"Cell {index} ({cell.cell_type})"]
|
|
307
|
+
if cell.get("id"):
|
|
308
|
+
output.append(f"ID: {cell.id}")
|
|
309
|
+
output.append("-" * 40)
|
|
310
|
+
output.append(cell.source)
|
|
311
|
+
|
|
312
|
+
if cell.cell_type == "code" and cell.get("outputs"):
|
|
313
|
+
output.append("\nOutputs:")
|
|
314
|
+
for out in cell.outputs:
|
|
315
|
+
if out.output_type == "stream":
|
|
316
|
+
output.append(f"[{out.name}]: {out.text}")
|
|
317
|
+
elif out.output_type == "execute_result":
|
|
318
|
+
output.append(f"[Out {out.execution_count}]: {out.data}")
|
|
319
|
+
elif out.output_type == "error":
|
|
320
|
+
output.append(f"[Error]: {out.ename}: {out.evalue}")
|
|
321
|
+
|
|
322
|
+
return "\n".join(output)
|
|
323
|
+
|
|
324
|
+
def register(self, mcp_server) -> None:
|
|
325
|
+
"""Register this tool with the MCP server."""
|
|
326
|
+
pass
|