hanzo-mcp 0.5.1__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.

Files changed (118) hide show
  1. hanzo_mcp/__init__.py +1 -1
  2. hanzo_mcp/cli.py +32 -0
  3. hanzo_mcp/dev_server.py +246 -0
  4. hanzo_mcp/prompts/__init__.py +1 -1
  5. hanzo_mcp/prompts/project_system.py +43 -7
  6. hanzo_mcp/server.py +5 -1
  7. hanzo_mcp/tools/__init__.py +168 -6
  8. hanzo_mcp/tools/agent/__init__.py +1 -1
  9. hanzo_mcp/tools/agent/agent.py +401 -0
  10. hanzo_mcp/tools/agent/agent_tool.py +3 -4
  11. hanzo_mcp/tools/common/__init__.py +1 -1
  12. hanzo_mcp/tools/common/base.py +9 -4
  13. hanzo_mcp/tools/common/batch_tool.py +3 -5
  14. hanzo_mcp/tools/common/config_tool.py +1 -1
  15. hanzo_mcp/tools/common/context.py +1 -1
  16. hanzo_mcp/tools/common/palette.py +344 -0
  17. hanzo_mcp/tools/common/palette_loader.py +108 -0
  18. hanzo_mcp/tools/common/stats.py +261 -0
  19. hanzo_mcp/tools/common/thinking_tool.py +3 -5
  20. hanzo_mcp/tools/common/tool_disable.py +144 -0
  21. hanzo_mcp/tools/common/tool_enable.py +182 -0
  22. hanzo_mcp/tools/common/tool_list.py +260 -0
  23. hanzo_mcp/tools/config/__init__.py +10 -0
  24. hanzo_mcp/tools/config/config_tool.py +212 -0
  25. hanzo_mcp/tools/config/index_config.py +176 -0
  26. hanzo_mcp/tools/config/palette_tool.py +166 -0
  27. hanzo_mcp/tools/database/__init__.py +71 -0
  28. hanzo_mcp/tools/database/database_manager.py +246 -0
  29. hanzo_mcp/tools/database/graph.py +482 -0
  30. hanzo_mcp/tools/database/graph_add.py +257 -0
  31. hanzo_mcp/tools/database/graph_query.py +536 -0
  32. hanzo_mcp/tools/database/graph_remove.py +267 -0
  33. hanzo_mcp/tools/database/graph_search.py +348 -0
  34. hanzo_mcp/tools/database/graph_stats.py +345 -0
  35. hanzo_mcp/tools/database/sql.py +411 -0
  36. hanzo_mcp/tools/database/sql_query.py +229 -0
  37. hanzo_mcp/tools/database/sql_search.py +296 -0
  38. hanzo_mcp/tools/database/sql_stats.py +254 -0
  39. hanzo_mcp/tools/editor/__init__.py +11 -0
  40. hanzo_mcp/tools/editor/neovim_command.py +272 -0
  41. hanzo_mcp/tools/editor/neovim_edit.py +290 -0
  42. hanzo_mcp/tools/editor/neovim_session.py +356 -0
  43. hanzo_mcp/tools/filesystem/__init__.py +52 -13
  44. hanzo_mcp/tools/filesystem/base.py +1 -1
  45. hanzo_mcp/tools/filesystem/batch_search.py +812 -0
  46. hanzo_mcp/tools/filesystem/content_replace.py +3 -5
  47. hanzo_mcp/tools/filesystem/diff.py +193 -0
  48. hanzo_mcp/tools/filesystem/directory_tree.py +3 -5
  49. hanzo_mcp/tools/filesystem/edit.py +3 -5
  50. hanzo_mcp/tools/filesystem/find.py +443 -0
  51. hanzo_mcp/tools/filesystem/find_files.py +348 -0
  52. hanzo_mcp/tools/filesystem/git_search.py +505 -0
  53. hanzo_mcp/tools/filesystem/grep.py +2 -2
  54. hanzo_mcp/tools/filesystem/multi_edit.py +3 -5
  55. hanzo_mcp/tools/filesystem/read.py +17 -5
  56. hanzo_mcp/tools/filesystem/{grep_ast_tool.py → symbols.py} +17 -27
  57. hanzo_mcp/tools/filesystem/symbols_unified.py +376 -0
  58. hanzo_mcp/tools/filesystem/tree.py +268 -0
  59. hanzo_mcp/tools/filesystem/unified_search.py +465 -443
  60. hanzo_mcp/tools/filesystem/unix_aliases.py +99 -0
  61. hanzo_mcp/tools/filesystem/watch.py +174 -0
  62. hanzo_mcp/tools/filesystem/write.py +3 -5
  63. hanzo_mcp/tools/jupyter/__init__.py +9 -12
  64. hanzo_mcp/tools/jupyter/base.py +1 -1
  65. hanzo_mcp/tools/jupyter/jupyter.py +326 -0
  66. hanzo_mcp/tools/jupyter/notebook_edit.py +3 -4
  67. hanzo_mcp/tools/jupyter/notebook_read.py +3 -5
  68. hanzo_mcp/tools/llm/__init__.py +31 -0
  69. hanzo_mcp/tools/llm/consensus_tool.py +351 -0
  70. hanzo_mcp/tools/llm/llm_manage.py +413 -0
  71. hanzo_mcp/tools/llm/llm_tool.py +346 -0
  72. hanzo_mcp/tools/llm/llm_unified.py +851 -0
  73. hanzo_mcp/tools/llm/provider_tools.py +412 -0
  74. hanzo_mcp/tools/mcp/__init__.py +15 -0
  75. hanzo_mcp/tools/mcp/mcp_add.py +263 -0
  76. hanzo_mcp/tools/mcp/mcp_remove.py +127 -0
  77. hanzo_mcp/tools/mcp/mcp_stats.py +165 -0
  78. hanzo_mcp/tools/mcp/mcp_unified.py +503 -0
  79. hanzo_mcp/tools/shell/__init__.py +21 -23
  80. hanzo_mcp/tools/shell/base.py +1 -1
  81. hanzo_mcp/tools/shell/base_process.py +303 -0
  82. hanzo_mcp/tools/shell/bash_unified.py +134 -0
  83. hanzo_mcp/tools/shell/logs.py +265 -0
  84. hanzo_mcp/tools/shell/npx.py +194 -0
  85. hanzo_mcp/tools/shell/npx_background.py +254 -0
  86. hanzo_mcp/tools/shell/npx_unified.py +101 -0
  87. hanzo_mcp/tools/shell/open.py +107 -0
  88. hanzo_mcp/tools/shell/pkill.py +262 -0
  89. hanzo_mcp/tools/shell/process_unified.py +131 -0
  90. hanzo_mcp/tools/shell/processes.py +279 -0
  91. hanzo_mcp/tools/shell/run_background.py +326 -0
  92. hanzo_mcp/tools/shell/run_command.py +3 -4
  93. hanzo_mcp/tools/shell/run_command_windows.py +3 -4
  94. hanzo_mcp/tools/shell/uvx.py +187 -0
  95. hanzo_mcp/tools/shell/uvx_background.py +249 -0
  96. hanzo_mcp/tools/shell/uvx_unified.py +101 -0
  97. hanzo_mcp/tools/todo/__init__.py +1 -1
  98. hanzo_mcp/tools/todo/base.py +1 -1
  99. hanzo_mcp/tools/todo/todo.py +265 -0
  100. hanzo_mcp/tools/todo/todo_read.py +3 -5
  101. hanzo_mcp/tools/todo/todo_write.py +3 -5
  102. hanzo_mcp/tools/vector/__init__.py +6 -1
  103. hanzo_mcp/tools/vector/git_ingester.py +3 -0
  104. hanzo_mcp/tools/vector/index_tool.py +358 -0
  105. hanzo_mcp/tools/vector/infinity_store.py +98 -0
  106. hanzo_mcp/tools/vector/project_manager.py +27 -5
  107. hanzo_mcp/tools/vector/vector.py +311 -0
  108. hanzo_mcp/tools/vector/vector_index.py +1 -1
  109. hanzo_mcp/tools/vector/vector_search.py +12 -7
  110. hanzo_mcp-0.6.1.dist-info/METADATA +336 -0
  111. hanzo_mcp-0.6.1.dist-info/RECORD +134 -0
  112. hanzo_mcp-0.6.1.dist-info/entry_points.txt +3 -0
  113. hanzo_mcp-0.5.1.dist-info/METADATA +0 -276
  114. hanzo_mcp-0.5.1.dist-info/RECORD +0 -68
  115. hanzo_mcp-0.5.1.dist-info/entry_points.txt +0 -2
  116. {hanzo_mcp-0.5.1.dist-info → hanzo_mcp-0.6.1.dist-info}/WHEEL +0 -0
  117. {hanzo_mcp-0.5.1.dist-info → hanzo_mcp-0.6.1.dist-info}/licenses/LICENSE +0 -0
  118. {hanzo_mcp-0.5.1.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 fastmcp import FastMCP
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 fastmcp import FastMCP
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.notebook_edit import NoteBookEditTool
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
- "NotebookReadTool",
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
- NotebookReadTool(permission_manager),
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
- "notebook_read": NotebookReadTool,
72
- "notebook_edit": NoteBookEditTool,
66
+ "jupyter": JupyterTool,
67
+ # Legacy names for backward compatibility
68
+ "notebook_read": JupyterTool,
69
+ "notebook_edit": JupyterTool,
73
70
  }
74
71
 
75
72
  tools = []
@@ -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