hanzo-mcp 0.9.0__py3-none-any.whl → 0.9.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 (135) hide show
  1. hanzo_mcp/__init__.py +1 -1
  2. hanzo_mcp/analytics/posthog_analytics.py +14 -1
  3. hanzo_mcp/cli.py +108 -4
  4. hanzo_mcp/server.py +11 -0
  5. hanzo_mcp/tools/__init__.py +3 -16
  6. hanzo_mcp/tools/agent/__init__.py +5 -0
  7. hanzo_mcp/tools/agent/agent.py +5 -0
  8. hanzo_mcp/tools/agent/agent_tool.py +3 -17
  9. hanzo_mcp/tools/agent/agent_tool_v1_deprecated.py +623 -0
  10. hanzo_mcp/tools/agent/clarification_tool.py +7 -1
  11. hanzo_mcp/tools/agent/claude_desktop_auth.py +16 -6
  12. hanzo_mcp/tools/agent/cli_agent_base.py +5 -0
  13. hanzo_mcp/tools/agent/cli_tools.py +26 -0
  14. hanzo_mcp/tools/agent/code_auth_tool.py +5 -0
  15. hanzo_mcp/tools/agent/critic_tool.py +7 -1
  16. hanzo_mcp/tools/agent/iching_tool.py +5 -0
  17. hanzo_mcp/tools/agent/network_tool.py +5 -0
  18. hanzo_mcp/tools/agent/review_tool.py +7 -1
  19. hanzo_mcp/tools/agent/swarm_alias.py +5 -0
  20. hanzo_mcp/tools/agent/swarm_tool.py +701 -0
  21. hanzo_mcp/tools/agent/swarm_tool_v1_deprecated.py +554 -0
  22. hanzo_mcp/tools/agent/unified_cli_tools.py +5 -0
  23. hanzo_mcp/tools/common/auto_timeout.py +234 -0
  24. hanzo_mcp/tools/common/base.py +4 -0
  25. hanzo_mcp/tools/common/batch_tool.py +5 -0
  26. hanzo_mcp/tools/common/config_tool.py +5 -0
  27. hanzo_mcp/tools/common/critic_tool.py +5 -0
  28. hanzo_mcp/tools/common/paginated_base.py +4 -0
  29. hanzo_mcp/tools/common/permissions.py +38 -12
  30. hanzo_mcp/tools/common/personality.py +673 -980
  31. hanzo_mcp/tools/common/stats.py +5 -0
  32. hanzo_mcp/tools/common/thinking_tool.py +5 -0
  33. hanzo_mcp/tools/common/timeout_parser.py +103 -0
  34. hanzo_mcp/tools/common/tool_disable.py +5 -0
  35. hanzo_mcp/tools/common/tool_enable.py +5 -0
  36. hanzo_mcp/tools/common/tool_list.py +5 -0
  37. hanzo_mcp/tools/config/config_tool.py +5 -0
  38. hanzo_mcp/tools/config/mode_tool.py +5 -0
  39. hanzo_mcp/tools/database/graph.py +5 -0
  40. hanzo_mcp/tools/database/graph_add.py +5 -0
  41. hanzo_mcp/tools/database/graph_query.py +5 -0
  42. hanzo_mcp/tools/database/graph_remove.py +5 -0
  43. hanzo_mcp/tools/database/graph_search.py +5 -0
  44. hanzo_mcp/tools/database/graph_stats.py +5 -0
  45. hanzo_mcp/tools/database/sql.py +5 -0
  46. hanzo_mcp/tools/database/sql_query.py +2 -0
  47. hanzo_mcp/tools/database/sql_search.py +5 -0
  48. hanzo_mcp/tools/database/sql_stats.py +5 -0
  49. hanzo_mcp/tools/editor/neovim_command.py +5 -0
  50. hanzo_mcp/tools/editor/neovim_edit.py +7 -2
  51. hanzo_mcp/tools/editor/neovim_session.py +5 -0
  52. hanzo_mcp/tools/filesystem/__init__.py +23 -26
  53. hanzo_mcp/tools/filesystem/ast_tool.py +2 -3
  54. hanzo_mcp/tools/filesystem/base.py +0 -16
  55. hanzo_mcp/tools/filesystem/batch_search.py +825 -0
  56. hanzo_mcp/tools/filesystem/content_replace.py +5 -3
  57. hanzo_mcp/tools/filesystem/diff.py +5 -0
  58. hanzo_mcp/tools/filesystem/directory_tree.py +34 -281
  59. hanzo_mcp/tools/filesystem/directory_tree_paginated.py +345 -0
  60. hanzo_mcp/tools/filesystem/edit.py +5 -4
  61. hanzo_mcp/tools/filesystem/find.py +177 -311
  62. hanzo_mcp/tools/filesystem/find_files.py +370 -0
  63. hanzo_mcp/tools/filesystem/git_search.py +5 -3
  64. hanzo_mcp/tools/filesystem/grep.py +454 -0
  65. hanzo_mcp/tools/filesystem/multi_edit.py +5 -4
  66. hanzo_mcp/tools/filesystem/read.py +11 -8
  67. hanzo_mcp/tools/filesystem/rules_tool.py +5 -3
  68. hanzo_mcp/tools/filesystem/search_tool.py +728 -0
  69. hanzo_mcp/tools/filesystem/symbols_tool.py +510 -0
  70. hanzo_mcp/tools/filesystem/tree.py +273 -0
  71. hanzo_mcp/tools/filesystem/watch.py +6 -1
  72. hanzo_mcp/tools/filesystem/write.py +12 -6
  73. hanzo_mcp/tools/jupyter/jupyter.py +30 -2
  74. hanzo_mcp/tools/jupyter/notebook_edit.py +298 -0
  75. hanzo_mcp/tools/jupyter/notebook_read.py +148 -0
  76. hanzo_mcp/tools/llm/consensus_tool.py +8 -6
  77. hanzo_mcp/tools/llm/llm_manage.py +5 -0
  78. hanzo_mcp/tools/llm/llm_tool.py +2 -0
  79. hanzo_mcp/tools/llm/llm_unified.py +5 -0
  80. hanzo_mcp/tools/llm/provider_tools.py +5 -0
  81. hanzo_mcp/tools/lsp/lsp_tool.py +475 -622
  82. hanzo_mcp/tools/mcp/mcp_add.py +7 -2
  83. hanzo_mcp/tools/mcp/mcp_remove.py +15 -2
  84. hanzo_mcp/tools/mcp/mcp_stats.py +5 -0
  85. hanzo_mcp/tools/mcp/mcp_tool.py +5 -0
  86. hanzo_mcp/tools/memory/knowledge_tools.py +14 -0
  87. hanzo_mcp/tools/memory/memory_tools.py +17 -0
  88. hanzo_mcp/tools/search/find_tool.py +5 -3
  89. hanzo_mcp/tools/search/unified_search.py +3 -1
  90. hanzo_mcp/tools/shell/__init__.py +2 -14
  91. hanzo_mcp/tools/shell/base_process.py +4 -2
  92. hanzo_mcp/tools/shell/bash_tool.py +2 -0
  93. hanzo_mcp/tools/shell/command_executor.py +7 -7
  94. hanzo_mcp/tools/shell/logs.py +5 -0
  95. hanzo_mcp/tools/shell/npx.py +5 -0
  96. hanzo_mcp/tools/shell/npx_background.py +5 -0
  97. hanzo_mcp/tools/shell/npx_tool.py +5 -0
  98. hanzo_mcp/tools/shell/open.py +5 -0
  99. hanzo_mcp/tools/shell/pkill.py +5 -0
  100. hanzo_mcp/tools/shell/process_tool.py +5 -0
  101. hanzo_mcp/tools/shell/processes.py +5 -0
  102. hanzo_mcp/tools/shell/run_background.py +5 -0
  103. hanzo_mcp/tools/shell/run_command.py +2 -0
  104. hanzo_mcp/tools/shell/run_command_windows.py +5 -0
  105. hanzo_mcp/tools/shell/streaming_command.py +5 -0
  106. hanzo_mcp/tools/shell/uvx.py +5 -0
  107. hanzo_mcp/tools/shell/uvx_background.py +5 -0
  108. hanzo_mcp/tools/shell/uvx_tool.py +5 -0
  109. hanzo_mcp/tools/shell/zsh_tool.py +3 -0
  110. hanzo_mcp/tools/todo/todo.py +5 -0
  111. hanzo_mcp/tools/todo/todo_read.py +142 -0
  112. hanzo_mcp/tools/todo/todo_write.py +367 -0
  113. hanzo_mcp/tools/vector/__init__.py +42 -95
  114. hanzo_mcp/tools/vector/index_tool.py +5 -0
  115. hanzo_mcp/tools/vector/vector.py +5 -0
  116. hanzo_mcp/tools/vector/vector_index.py +5 -0
  117. hanzo_mcp/tools/vector/vector_search.py +5 -0
  118. {hanzo_mcp-0.9.0.dist-info → hanzo_mcp-0.9.1.dist-info}/METADATA +1 -1
  119. hanzo_mcp-0.9.1.dist-info/RECORD +195 -0
  120. hanzo_mcp/tools/common/path_utils.py +0 -34
  121. hanzo_mcp/tools/compiler/__init__.py +0 -8
  122. hanzo_mcp/tools/compiler/sandboxed_compiler.py +0 -681
  123. hanzo_mcp/tools/environment/__init__.py +0 -8
  124. hanzo_mcp/tools/environment/environment_detector.py +0 -594
  125. hanzo_mcp/tools/filesystem/search.py +0 -1160
  126. hanzo_mcp/tools/framework/__init__.py +0 -8
  127. hanzo_mcp/tools/framework/framework_modes.py +0 -714
  128. hanzo_mcp/tools/memory/conversation_memory.py +0 -636
  129. hanzo_mcp/tools/shell/run_tool.py +0 -56
  130. hanzo_mcp/tools/vector/node_tool.py +0 -538
  131. hanzo_mcp/tools/vector/unified_vector.py +0 -384
  132. hanzo_mcp-0.9.0.dist-info/RECORD +0 -191
  133. {hanzo_mcp-0.9.0.dist-info → hanzo_mcp-0.9.1.dist-info}/WHEEL +0 -0
  134. {hanzo_mcp-0.9.0.dist-info → hanzo_mcp-0.9.1.dist-info}/entry_points.txt +0 -0
  135. {hanzo_mcp-0.9.0.dist-info → hanzo_mcp-0.9.1.dist-info}/top_level.txt +0 -0
@@ -0,0 +1,273 @@
1
+ """Tree tool implementation.
2
+
3
+ Unix-style tree command for directory visualization.
4
+ """
5
+
6
+ from typing import List, Unpack, Optional, Annotated, TypedDict, final, override
7
+ from pathlib import Path
8
+
9
+ from pydantic import Field
10
+ from mcp.server.fastmcp import Context as MCPContext
11
+
12
+ from hanzo_mcp.tools.common.auto_timeout import auto_timeout
13
+
14
+ from hanzo_mcp.tools.filesystem.base import FilesystemBaseTool
15
+
16
+ # Parameter types
17
+ TreePath = Annotated[
18
+ str,
19
+ Field(
20
+ description="Directory path to display",
21
+ default=".",
22
+ ),
23
+ ]
24
+
25
+ Depth = Annotated[
26
+ Optional[int],
27
+ Field(
28
+ description="Maximum depth to display",
29
+ default=None,
30
+ ),
31
+ ]
32
+
33
+ ShowHidden = Annotated[
34
+ bool,
35
+ Field(
36
+ description="Show hidden files (starting with .)",
37
+ default=False,
38
+ ),
39
+ ]
40
+
41
+ DirsOnly = Annotated[
42
+ bool,
43
+ Field(
44
+ description="Show only directories",
45
+ default=False,
46
+ ),
47
+ ]
48
+
49
+ ShowSize = Annotated[
50
+ bool,
51
+ Field(
52
+ description="Show file sizes",
53
+ default=False,
54
+ ),
55
+ ]
56
+
57
+ Pattern = Annotated[
58
+ Optional[str],
59
+ Field(
60
+ description="Only show files matching pattern",
61
+ default=None,
62
+ ),
63
+ ]
64
+
65
+
66
+ class TreeParams(TypedDict, total=False):
67
+ """Parameters for tree tool."""
68
+
69
+ path: str
70
+ depth: Optional[int]
71
+ show_hidden: bool
72
+ dirs_only: bool
73
+ show_size: bool
74
+ pattern: Optional[str]
75
+
76
+
77
+ @final
78
+ class TreeTool(FilesystemBaseTool):
79
+ """Unix-style tree command for directory visualization."""
80
+
81
+ @property
82
+ @override
83
+ def name(self) -> str:
84
+ """Get the tool name."""
85
+ return "tree"
86
+
87
+ @property
88
+ @override
89
+ def description(self) -> str:
90
+ """Get the tool description."""
91
+ return """Directory tree visualization.
92
+
93
+ Usage:
94
+ tree
95
+ tree ./src --depth 2
96
+ tree --dirs-only
97
+ tree --pattern "*.py" --show-size"""
98
+
99
+ @override
100
+ @auto_timeout("tree")
101
+
102
+
103
+ async def call(
104
+ self,
105
+ ctx: MCPContext,
106
+ **params: Unpack[TreeParams],
107
+ ) -> str:
108
+ """Execute tree command."""
109
+ tool_ctx = self.create_tool_context(ctx)
110
+
111
+ # Extract parameters
112
+ path = params.get("path", ".")
113
+ max_depth = params.get("depth")
114
+ show_hidden = params.get("show_hidden", False)
115
+ dirs_only = params.get("dirs_only", False)
116
+ show_size = params.get("show_size", False)
117
+ pattern = params.get("pattern")
118
+
119
+ # Validate path
120
+ path_validation = self.validate_path(path)
121
+ if path_validation.is_error:
122
+ return f"Error: {path_validation.error_message}"
123
+
124
+ # Check permissions
125
+ allowed, error_msg = await self.check_path_allowed(path, tool_ctx)
126
+ if not allowed:
127
+ return error_msg
128
+
129
+ # Check existence
130
+ exists, error_msg = await self.check_path_exists(path, tool_ctx)
131
+ if not exists:
132
+ return error_msg
133
+
134
+ path_obj = Path(path)
135
+ if not path_obj.is_dir():
136
+ return f"Error: {path} is not a directory"
137
+
138
+ # Build tree
139
+ output = [str(path_obj)]
140
+ stats = {"dirs": 0, "files": 0}
141
+
142
+ self._build_tree(
143
+ path_obj,
144
+ output,
145
+ stats,
146
+ prefix="",
147
+ is_last=True,
148
+ current_depth=0,
149
+ max_depth=max_depth,
150
+ show_hidden=show_hidden,
151
+ dirs_only=dirs_only,
152
+ show_size=show_size,
153
+ pattern=pattern,
154
+ )
155
+
156
+ # Add summary
157
+ output.append("")
158
+ if dirs_only:
159
+ output.append(f"{stats['dirs']} directories")
160
+ else:
161
+ output.append(f"{stats['dirs']} directories, {stats['files']} files")
162
+
163
+ return "\n".join(output)
164
+
165
+ def _build_tree(
166
+ self,
167
+ path: Path,
168
+ output: List[str],
169
+ stats: dict,
170
+ prefix: str,
171
+ is_last: bool,
172
+ current_depth: int,
173
+ max_depth: Optional[int],
174
+ show_hidden: bool,
175
+ dirs_only: bool,
176
+ show_size: bool,
177
+ pattern: Optional[str],
178
+ ) -> None:
179
+ """Recursively build tree structure."""
180
+ # Check depth limit
181
+ if max_depth is not None and current_depth >= max_depth:
182
+ return
183
+
184
+ try:
185
+ # Get entries
186
+ entries = list(path.iterdir())
187
+
188
+ # Filter hidden files
189
+ if not show_hidden:
190
+ entries = [e for e in entries if not e.name.startswith(".")]
191
+
192
+ # Filter by pattern
193
+ if pattern:
194
+ import fnmatch
195
+
196
+ entries = [e for e in entries if fnmatch.fnmatch(e.name, pattern) or e.is_dir()]
197
+
198
+ # Filter dirs only
199
+ if dirs_only:
200
+ entries = [e for e in entries if e.is_dir()]
201
+
202
+ # Sort entries (dirs first, then alphabetically)
203
+ entries.sort(key=lambda e: (not e.is_dir(), e.name.lower()))
204
+
205
+ # Process each entry
206
+ for i, entry in enumerate(entries):
207
+ is_last_entry = i == len(entries) - 1
208
+
209
+ # Skip if not allowed
210
+ if not self.is_path_allowed(str(entry)):
211
+ continue
212
+
213
+ # Build the tree branch
214
+ if prefix:
215
+ if is_last_entry:
216
+ branch = prefix + "└── "
217
+ extension = prefix + " "
218
+ else:
219
+ branch = prefix + "├── "
220
+ extension = prefix + "│ "
221
+ else:
222
+ branch = ""
223
+ extension = ""
224
+
225
+ # Build entry line
226
+ line = branch + entry.name
227
+
228
+ # Add size if requested
229
+ if show_size and entry.is_file():
230
+ try:
231
+ size = entry.stat().st_size
232
+ line += f" ({self._format_size(size)})"
233
+ except Exception:
234
+ pass
235
+
236
+ output.append(line)
237
+
238
+ # Update stats
239
+ if entry.is_dir():
240
+ stats["dirs"] += 1
241
+ # Recurse into directory
242
+ self._build_tree(
243
+ entry,
244
+ output,
245
+ stats,
246
+ prefix=extension,
247
+ is_last=is_last_entry,
248
+ current_depth=current_depth + 1,
249
+ max_depth=max_depth,
250
+ show_hidden=show_hidden,
251
+ dirs_only=dirs_only,
252
+ show_size=show_size,
253
+ pattern=pattern,
254
+ )
255
+ else:
256
+ stats["files"] += 1
257
+
258
+ except PermissionError:
259
+ output.append(prefix + "[Permission Denied]")
260
+ except Exception as e:
261
+ output.append(prefix + f"[Error: {str(e)}]")
262
+
263
+ def _format_size(self, size: int) -> str:
264
+ """Format file size in human-readable format."""
265
+ for unit in ["B", "KB", "MB", "GB", "TB"]:
266
+ if size < 1024.0:
267
+ return f"{size:.1f}{unit}"
268
+ size /= 1024.0
269
+ return f"{size:.1f}PB"
270
+
271
+ def register(self, mcp_server) -> None:
272
+ """Register this tool with the MCP server."""
273
+ pass
@@ -7,6 +7,8 @@ from pathlib import Path
7
7
  from datetime import datetime
8
8
 
9
9
  from mcp.server import FastMCP
10
+
11
+ from hanzo_mcp.tools.common.auto_timeout import auto_timeout
10
12
  from mcp.server.fastmcp import Context as MCPContext
11
13
 
12
14
  from hanzo_mcp.tools.common.base import BaseTool
@@ -41,11 +43,14 @@ class WatchTool(BaseTool):
41
43
  duration=duration,
42
44
  )
43
45
 
46
+ @auto_timeout("watch")
47
+
48
+
44
49
  async def call(self, ctx: MCPContext, **params) -> str:
45
50
  """Call the tool with arguments."""
46
51
  return await self.run(
47
52
  ctx,
48
- path=self.expand_path(params["path"]),
53
+ path=params["path"],
49
54
  pattern=params.get("pattern", "*"),
50
55
  interval=params.get("interval", 1),
51
56
  recursive=params.get("recursive", True),
@@ -8,6 +8,8 @@ from pathlib import Path
8
8
 
9
9
  from pydantic import Field
10
10
  from mcp.server import FastMCP
11
+
12
+ from hanzo_mcp.tools.common.auto_timeout import auto_timeout
11
13
  from mcp.server.fastmcp import Context as MCPContext
12
14
 
13
15
  from hanzo_mcp.tools.filesystem.base import FilesystemBaseTool
@@ -72,6 +74,9 @@ Usage:
72
74
  - NEVER proactively create documentation files (*.md) or README files. Only create documentation files if explicitly requested by the User."""
73
75
 
74
76
  @override
77
+ @auto_timeout("write")
78
+
79
+
75
80
  async def call(
76
81
  self,
77
82
  ctx: MCPContext,
@@ -99,17 +104,18 @@ Usage:
99
104
  await tool_ctx.error(path_validation.error_message)
100
105
  return f"Error: {path_validation.error_message}"
101
106
 
102
- # Expand path first (handles ~, $HOME, etc.)
103
- expanded_path = self.expand_path(file_path)
104
- await tool_ctx.info(f"Writing file: {expanded_path}")
107
+ await tool_ctx.info(f"Writing file: {file_path}")
105
108
 
106
- # Check if file is allowed to be written (using expanded path)
107
- allowed, error_msg = await self.check_path_allowed(expanded_path, tool_ctx)
109
+ # Check if file is allowed to be written
110
+ allowed, error_msg = await self.check_path_allowed(file_path, tool_ctx)
108
111
  if not allowed:
109
112
  return error_msg
110
113
 
114
+ # Additional check already verified by is_path_allowed above
115
+ await tool_ctx.info(f"Writing file: {file_path}")
116
+
111
117
  try:
112
- path_obj = Path(expanded_path)
118
+ path_obj = Path(file_path)
113
119
 
114
120
  # Check if parent directory is allowed
115
121
  parent_dir = str(path_obj.parent)
@@ -16,6 +16,8 @@ import nbformat
16
16
  from pydantic import Field
17
17
  from mcp.server.fastmcp import Context as MCPContext
18
18
 
19
+ from hanzo_mcp.tools.common.auto_timeout import auto_timeout
20
+
19
21
  from hanzo_mcp.tools.jupyter.base import JupyterBaseTool
20
22
 
21
23
  # Parameter types
@@ -111,6 +113,9 @@ jupyter --action create "new.ipynb"
111
113
  """
112
114
 
113
115
  @override
116
+ @auto_timeout("jupyter")
117
+
118
+
114
119
  async def call(
115
120
  self,
116
121
  ctx: MCPContext,
@@ -311,8 +316,31 @@ jupyter --action create "new.ipynb"
311
316
  return f"Error deleting notebook: {str(e)}"
312
317
 
313
318
  async def _handle_execute(self, notebook_path: str, params: Dict[str, Any], tool_ctx) -> str:
314
- """Execute notebook cells (placeholder for future implementation)."""
315
- return "Error: Cell execution not yet implemented. Use a Jupyter kernel or server for execution."
319
+ """Execute notebook cells using nbclient."""
320
+ try:
321
+ import nbclient
322
+ from nbclient import NotebookClient
323
+
324
+ nb = nbformat.read(notebook_path, as_version=4)
325
+
326
+ # Create a notebook client with default kernel
327
+ client = NotebookClient(
328
+ nb,
329
+ timeout=params.get('timeout', 600),
330
+ kernel_name=params.get('kernel_name', 'python3')
331
+ )
332
+
333
+ # Execute the notebook
334
+ await client.async_execute()
335
+
336
+ # Save the executed notebook
337
+ nbformat.write(nb, notebook_path)
338
+
339
+ return f"Successfully executed all cells in {notebook_path}"
340
+ except ImportError:
341
+ return "Error: nbclient not installed. Install with: pip install nbclient"
342
+ except Exception as e:
343
+ return f"Error executing notebook: {str(e)}"
316
344
 
317
345
  def _format_cell(self, cell: dict, index: int) -> str:
318
346
  """Format a single cell for display."""
@@ -0,0 +1,298 @@
1
+ """Edit notebook tool implementation.
2
+
3
+ This module provides the NoteBookEditTool for editing Jupyter notebook files.
4
+ """
5
+
6
+ import json
7
+ from typing import Any, Unpack, Literal, Annotated, TypedDict, final, override
8
+ from pathlib import Path
9
+
10
+ from pydantic import Field
11
+ from mcp.server import FastMCP
12
+
13
+ from hanzo_mcp.tools.common.auto_timeout import auto_timeout
14
+ from mcp.server.fastmcp import Context as MCPContext
15
+
16
+ from hanzo_mcp.tools.jupyter.base import JupyterBaseTool
17
+
18
+ NotebookPath = Annotated[
19
+ str,
20
+ Field(
21
+ description="The absolute path to the Jupyter notebook file to edit (must be absolute, not relative)",
22
+ ),
23
+ ]
24
+
25
+ CellNumber = Annotated[
26
+ int,
27
+ Field(
28
+ description="The index of the cell to edit (0-based)",
29
+ ge=0,
30
+ ),
31
+ ]
32
+
33
+ NewSource = Annotated[
34
+ str,
35
+ Field(
36
+ description="The new source for the cell",
37
+ default="",
38
+ ),
39
+ ]
40
+
41
+ CellType = Annotated[
42
+ Literal["code", "markdown"],
43
+ Field(
44
+ description="The of the cell (code or markdown). If not specified, it defaults to the current cell type. If using edit_mode=insert, this is required.",
45
+ default="code",
46
+ ),
47
+ ]
48
+
49
+ EditMode = Annotated[
50
+ Literal["replace", "insert", "delete"],
51
+ Field(
52
+ description="The of edit to make (replace, insert, delete). Defaults to replace.",
53
+ default="replace",
54
+ ),
55
+ ]
56
+
57
+
58
+ class NotebookEditToolParams(TypedDict):
59
+ """Parameters for the NotebookEditTool.
60
+
61
+ Attributes:
62
+ notebook_path: The absolute path to the Jupyter notebook file to edit (must be absolute, not relative)
63
+ cell_number: The index of the cell to edit (0-based)
64
+ new_source: The new source for the cell
65
+ cell_type: The of the cell (code or markdown). If not specified, it defaults to the current cell type. If using edit_mode=insert, this is required.
66
+ edit_mode: The of edit to make (replace, insert, delete). Defaults to replace.
67
+ """
68
+
69
+ notebook_path: NotebookPath
70
+ cell_number: CellNumber
71
+ new_source: NewSource
72
+ cell_type: CellType
73
+ edit_mode: EditMode
74
+
75
+
76
+ @final
77
+ class NoteBookEditTool(JupyterBaseTool):
78
+ """Tool for editing Jupyter notebook files."""
79
+
80
+ @property
81
+ @override
82
+ def name(self) -> str:
83
+ """Get the tool name.
84
+
85
+ Returns:
86
+ Tool name
87
+ """
88
+ return "notebook_edit"
89
+
90
+ @property
91
+ @override
92
+ def description(self) -> str:
93
+ """Get the tool description.
94
+
95
+ Returns:
96
+ Tool description
97
+ """
98
+ return "Completely replaces the contents of a specific cell in a Jupyter notebook (.ipynb file) with new source. Jupyter notebooks are interactive documents that combine code, text, and visualizations, commonly used for data analysis and scientific computing. The notebook_path parameter must be an absolute path, not a relative path. The cell_number is 0-indexed. Use edit_mode=insert to add a new cell at the index specified by cell_number. Use edit_mode=delete to delete the cell at the index specified by cell_number."
99
+
100
+ @override
101
+ @auto_timeout("notebook_edit")
102
+
103
+
104
+ async def call(
105
+ self,
106
+ ctx: MCPContext,
107
+ **params: Unpack[NotebookEditToolParams],
108
+ ) -> str:
109
+ """Execute the tool with the given parameters.
110
+
111
+ Args:
112
+ ctx: MCP context
113
+ **params: Tool parameters
114
+
115
+ Returns:
116
+ Tool result
117
+ """
118
+ tool_ctx = self.create_tool_context(ctx)
119
+ self.set_tool_context_info(tool_ctx)
120
+
121
+ # Extract parameters
122
+ notebook_path = params.get("notebook_path")
123
+ cell_number = params.get("cell_number")
124
+ new_source = params.get("new_source")
125
+ cell_type = params.get("cell_type")
126
+ edit_mode = params.get("edit_mode", "replace")
127
+
128
+ path_validation = self.validate_path(notebook_path)
129
+ if path_validation.is_error:
130
+ await tool_ctx.error(path_validation.error_message)
131
+ return f"Error: {path_validation.error_message}"
132
+
133
+ # Validate edit_mode
134
+ if edit_mode not in ["replace", "insert", "delete"]:
135
+ await tool_ctx.error("Edit mode must be replace, insert, or delete")
136
+ return "Error: Edit mode must be replace, insert, or delete"
137
+
138
+ # In insert mode, cell_type is required
139
+ if edit_mode == "insert" and cell_type is None:
140
+ await tool_ctx.error("Cell type is required when using insert mode")
141
+ return "Error: Cell type is required when using insert mode"
142
+
143
+ # Don't validate new_source for delete mode
144
+ if edit_mode != "delete" and not new_source:
145
+ await tool_ctx.error("New source is required for replace or insert operations")
146
+ return "Error: New source is required for replace or insert operations"
147
+
148
+ await tool_ctx.info(f"Editing notebook: {notebook_path} (cell: {cell_number}, mode: {edit_mode})")
149
+
150
+ # Check if path is allowed
151
+ if not self.is_path_allowed(notebook_path):
152
+ await tool_ctx.error(f"Access denied - path outside allowed directories: {notebook_path}")
153
+ return f"Error: Access denied - path outside allowed directories: {notebook_path}"
154
+
155
+ try:
156
+ file_path = Path(notebook_path)
157
+
158
+ if not file_path.exists():
159
+ await tool_ctx.error(f"File does not exist: {notebook_path}")
160
+ return f"Error: File does not exist: {notebook_path}"
161
+
162
+ if not file_path.is_file():
163
+ await tool_ctx.error(f"Path is not a file: {notebook_path}")
164
+ return f"Error: Path is not a file: {notebook_path}"
165
+
166
+ # Check file extension
167
+ if file_path.suffix.lower() != ".ipynb":
168
+ await tool_ctx.error(f"File is not a Jupyter notebook: {notebook_path}")
169
+ return f"Error: File is not a Jupyter notebook: {notebook_path}"
170
+
171
+ # Read and parse the notebook
172
+ try:
173
+ with open(file_path, "r", encoding="utf-8") as f:
174
+ content = f.read()
175
+ notebook = json.loads(content)
176
+ except json.JSONDecodeError:
177
+ await tool_ctx.error(f"Invalid notebook format: {notebook_path}")
178
+ return f"Error: Invalid notebook format: {notebook_path}"
179
+ except UnicodeDecodeError:
180
+ await tool_ctx.error(f"Cannot read notebook file: {notebook_path}")
181
+ return f"Error: Cannot read notebook file: {notebook_path}"
182
+
183
+ # Check cell_number is valid
184
+ cells = notebook.get("cells", [])
185
+
186
+ if edit_mode == "insert":
187
+ if cell_number > len(cells):
188
+ await tool_ctx.error(f"Cell number {cell_number} is out of bounds for insert (max: {len(cells)})")
189
+ return f"Error: Cell number {cell_number} is out of bounds for insert (max: {len(cells)})"
190
+ else: # replace or delete
191
+ if cell_number >= len(cells):
192
+ await tool_ctx.error(f"Cell number {cell_number} is out of bounds (max: {len(cells) - 1})")
193
+ return f"Error: Cell number {cell_number} is out of bounds (max: {len(cells) - 1})"
194
+
195
+ # Get notebook language (needed for context but not directly used in this block)
196
+ _ = notebook.get("metadata", {}).get("language_info", {}).get("name", "python")
197
+
198
+ # Perform the requested operation
199
+ if edit_mode == "replace":
200
+ # Get the target cell
201
+ target_cell = cells[cell_number]
202
+
203
+ # Store previous contents for reporting
204
+ old_type = target_cell.get("cell_type", "code")
205
+ old_source = target_cell.get("source", "")
206
+
207
+ # Fix for old_source which might be a list of strings
208
+ if isinstance(old_source, list):
209
+ old_source = "".join([str(item) for item in old_source])
210
+
211
+ # Update source
212
+ target_cell["source"] = new_source
213
+
214
+ # Update type if specified
215
+ if cell_type is not None:
216
+ target_cell["cell_type"] = cell_type
217
+
218
+ # If changing to markdown, remove code-specific fields
219
+ if cell_type == "markdown":
220
+ if "outputs" in target_cell:
221
+ del target_cell["outputs"]
222
+ if "execution_count" in target_cell:
223
+ del target_cell["execution_count"]
224
+
225
+ # If code cell, reset execution
226
+ if target_cell["cell_type"] == "code":
227
+ target_cell["outputs"] = []
228
+ target_cell["execution_count"] = None
229
+
230
+ change_description = f"Replaced cell {cell_number}"
231
+ if cell_type is not None and cell_type != old_type:
232
+ change_description += f" (changed type from {old_type} to {cell_type})"
233
+
234
+ elif edit_mode == "insert":
235
+ # Create new cell
236
+ new_cell: dict[str, Any] = {
237
+ "cell_type": cell_type,
238
+ "source": new_source,
239
+ "metadata": {},
240
+ }
241
+
242
+ # Add code-specific fields
243
+ if cell_type == "code":
244
+ new_cell["outputs"] = []
245
+ new_cell["execution_count"] = None
246
+
247
+ # Insert the cell
248
+ cells.insert(cell_number, new_cell)
249
+ change_description = f"Inserted new {cell_type} cell at position {cell_number}"
250
+
251
+ else: # delete
252
+ # Store deleted cell info for reporting
253
+ deleted_cell = cells[cell_number]
254
+ deleted_type = deleted_cell.get("cell_type", "code")
255
+
256
+ # Remove the cell
257
+ del cells[cell_number]
258
+ change_description = f"Deleted {deleted_type} cell at position {cell_number}"
259
+
260
+ # Write the updated notebook back to file
261
+ with open(file_path, "w", encoding="utf-8") as f:
262
+ json.dump(notebook, f, indent=1)
263
+
264
+ await tool_ctx.info(f"Successfully edited notebook: {notebook_path} - {change_description}")
265
+ return f"Successfully edited notebook: {notebook_path} - {change_description}"
266
+ except Exception as e:
267
+ await tool_ctx.error(f"Error editing notebook: {str(e)}")
268
+ return f"Error editing notebook: {str(e)}"
269
+
270
+ @override
271
+ def register(self, mcp_server: FastMCP) -> None:
272
+ """Register this edit notebook tool with the MCP server.
273
+
274
+ Creates a wrapper function with explicitly defined parameters that match
275
+ the tool's parameter schema and registers it with the MCP server.
276
+
277
+ Args:
278
+ mcp_server: The FastMCP server instance
279
+ """
280
+ tool_self = self # Create a reference to self for use in the closure
281
+
282
+ @mcp_server.tool(name=self.name, description=self.description)
283
+ async def notebook_edit(
284
+ notebook_path: NotebookPath,
285
+ cell_number: CellNumber,
286
+ new_source: NewSource,
287
+ cell_type: CellType,
288
+ edit_mode: EditMode,
289
+ ctx: MCPContext,
290
+ ) -> str:
291
+ return await tool_self.call(
292
+ ctx,
293
+ notebook_path=notebook_path,
294
+ cell_number=cell_number,
295
+ new_source=new_source,
296
+ cell_type=cell_type,
297
+ edit_mode=edit_mode,
298
+ )