hanzo-mcp 0.1.21__py3-none-any.whl → 0.1.30__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 +81 -10
- hanzo_mcp/server.py +42 -11
- hanzo_mcp/tools/__init__.py +51 -32
- hanzo_mcp/tools/agent/__init__.py +59 -0
- hanzo_mcp/tools/agent/agent_tool.py +474 -0
- hanzo_mcp/tools/agent/prompt.py +137 -0
- hanzo_mcp/tools/agent/tool_adapter.py +75 -0
- hanzo_mcp/tools/common/__init__.py +18 -1
- hanzo_mcp/tools/common/base.py +216 -0
- hanzo_mcp/tools/common/context.py +9 -5
- hanzo_mcp/tools/common/permissions.py +7 -3
- hanzo_mcp/tools/common/session.py +91 -0
- hanzo_mcp/tools/common/thinking_tool.py +123 -0
- hanzo_mcp/tools/common/validation.py +1 -1
- hanzo_mcp/tools/filesystem/__init__.py +85 -5
- hanzo_mcp/tools/filesystem/base.py +113 -0
- hanzo_mcp/tools/filesystem/content_replace.py +287 -0
- hanzo_mcp/tools/filesystem/directory_tree.py +286 -0
- hanzo_mcp/tools/filesystem/edit_file.py +287 -0
- hanzo_mcp/tools/filesystem/get_file_info.py +170 -0
- hanzo_mcp/tools/filesystem/read_files.py +198 -0
- hanzo_mcp/tools/filesystem/search_content.py +275 -0
- hanzo_mcp/tools/filesystem/write_file.py +162 -0
- hanzo_mcp/tools/jupyter/__init__.py +67 -4
- hanzo_mcp/tools/jupyter/base.py +284 -0
- hanzo_mcp/tools/jupyter/edit_notebook.py +295 -0
- hanzo_mcp/tools/jupyter/notebook_operations.py +73 -113
- hanzo_mcp/tools/jupyter/read_notebook.py +165 -0
- hanzo_mcp/tools/project/__init__.py +64 -1
- hanzo_mcp/tools/project/analysis.py +8 -5
- hanzo_mcp/tools/project/base.py +66 -0
- hanzo_mcp/tools/project/project_analyze.py +173 -0
- hanzo_mcp/tools/shell/__init__.py +58 -1
- hanzo_mcp/tools/shell/base.py +148 -0
- hanzo_mcp/tools/shell/command_executor.py +198 -317
- hanzo_mcp/tools/shell/run_command.py +204 -0
- hanzo_mcp/tools/shell/run_script.py +215 -0
- hanzo_mcp/tools/shell/script_tool.py +244 -0
- {hanzo_mcp-0.1.21.dist-info → hanzo_mcp-0.1.30.dist-info}/METADATA +72 -77
- hanzo_mcp-0.1.30.dist-info/RECORD +45 -0
- {hanzo_mcp-0.1.21.dist-info → hanzo_mcp-0.1.30.dist-info}/WHEEL +1 -1
- {hanzo_mcp-0.1.21.dist-info → hanzo_mcp-0.1.30.dist-info}/licenses/LICENSE +2 -2
- hanzo_mcp/tools/common/thinking.py +0 -65
- hanzo_mcp/tools/filesystem/file_operations.py +0 -1050
- hanzo_mcp-0.1.21.dist-info/RECORD +0 -23
- {hanzo_mcp-0.1.21.dist-info → hanzo_mcp-0.1.30.dist-info}/entry_points.txt +0 -0
- {hanzo_mcp-0.1.21.dist-info → hanzo_mcp-0.1.30.dist-info}/top_level.txt +0 -0
|
@@ -0,0 +1,284 @@
|
|
|
1
|
+
"""Base functionality for Jupyter notebook tools.
|
|
2
|
+
|
|
3
|
+
This module provides common functionality for Jupyter notebook tools, including notebook parsing,
|
|
4
|
+
cell processing, and output formatting.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from abc import ABC
|
|
8
|
+
import json
|
|
9
|
+
import re
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from typing import Any, final
|
|
12
|
+
|
|
13
|
+
from mcp.server.fastmcp import Context as MCPContext
|
|
14
|
+
|
|
15
|
+
from hanzo_mcp.tools.common.base import FileSystemTool
|
|
16
|
+
from hanzo_mcp.tools.common.context import ToolContext, create_tool_context
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
# Pattern to match ANSI escape sequences
|
|
20
|
+
ANSI_ESCAPE_PATTERN = re.compile(r'\x1B\[[0-9;]*[a-zA-Z]')
|
|
21
|
+
|
|
22
|
+
# Function to clean ANSI escape codes from text
|
|
23
|
+
def clean_ansi_escapes(text: str) -> str:
|
|
24
|
+
"""Remove ANSI escape sequences from text.
|
|
25
|
+
|
|
26
|
+
Args:
|
|
27
|
+
text: Text containing ANSI escape sequences
|
|
28
|
+
|
|
29
|
+
Returns:
|
|
30
|
+
Text with ANSI escape sequences removed
|
|
31
|
+
"""
|
|
32
|
+
return ANSI_ESCAPE_PATTERN.sub('', text)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
@final
|
|
36
|
+
class NotebookOutputImage:
|
|
37
|
+
"""Representation of an image output in a notebook cell."""
|
|
38
|
+
|
|
39
|
+
def __init__(self, image_data: str, media_type: str):
|
|
40
|
+
"""Initialize a notebook output image.
|
|
41
|
+
|
|
42
|
+
Args:
|
|
43
|
+
image_data: Base64-encoded image data
|
|
44
|
+
media_type: Media type of the image (e.g., "image/png")
|
|
45
|
+
"""
|
|
46
|
+
self.image_data = image_data
|
|
47
|
+
self.media_type = media_type
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
@final
|
|
51
|
+
class NotebookCellOutput:
|
|
52
|
+
"""Representation of an output from a notebook cell."""
|
|
53
|
+
|
|
54
|
+
def __init__(
|
|
55
|
+
self,
|
|
56
|
+
output_type: str,
|
|
57
|
+
text: str | None = None,
|
|
58
|
+
image: NotebookOutputImage | None = None
|
|
59
|
+
):
|
|
60
|
+
"""Initialize a notebook cell output.
|
|
61
|
+
|
|
62
|
+
Args:
|
|
63
|
+
output_type: Type of output
|
|
64
|
+
text: Text output (if any)
|
|
65
|
+
image: Image output (if any)
|
|
66
|
+
"""
|
|
67
|
+
self.output_type = output_type
|
|
68
|
+
self.text = text
|
|
69
|
+
self.image = image
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
@final
|
|
73
|
+
class NotebookCellSource:
|
|
74
|
+
"""Representation of a source cell from a notebook."""
|
|
75
|
+
|
|
76
|
+
def __init__(
|
|
77
|
+
self,
|
|
78
|
+
cell_index: int,
|
|
79
|
+
cell_type: str,
|
|
80
|
+
source: str,
|
|
81
|
+
language: str,
|
|
82
|
+
execution_count: int | None = None,
|
|
83
|
+
outputs: list[NotebookCellOutput] | None = None
|
|
84
|
+
):
|
|
85
|
+
"""Initialize a notebook cell source.
|
|
86
|
+
|
|
87
|
+
Args:
|
|
88
|
+
cell_index: Index of the cell in the notebook
|
|
89
|
+
cell_type: Type of cell (code or markdown)
|
|
90
|
+
source: Source code or text of the cell
|
|
91
|
+
language: Programming language of the cell
|
|
92
|
+
execution_count: Execution count of the cell (if any)
|
|
93
|
+
outputs: Outputs from the cell (if any)
|
|
94
|
+
"""
|
|
95
|
+
self.cell_index = cell_index
|
|
96
|
+
self.cell_type = cell_type
|
|
97
|
+
self.source = source
|
|
98
|
+
self.language = language
|
|
99
|
+
self.execution_count = execution_count
|
|
100
|
+
self.outputs = outputs or []
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
class JupyterBaseTool(FileSystemTool,ABC):
|
|
104
|
+
"""Base class for Jupyter notebook tools.
|
|
105
|
+
|
|
106
|
+
Provides common functionality for working with Jupyter notebooks, including
|
|
107
|
+
parsing, cell extraction, and output formatting.
|
|
108
|
+
"""
|
|
109
|
+
|
|
110
|
+
def create_tool_context(self, ctx: MCPContext) -> ToolContext:
|
|
111
|
+
"""Create a tool context with the tool name.
|
|
112
|
+
|
|
113
|
+
Args:
|
|
114
|
+
ctx: MCP context
|
|
115
|
+
|
|
116
|
+
Returns:
|
|
117
|
+
Tool context
|
|
118
|
+
"""
|
|
119
|
+
tool_ctx = create_tool_context(ctx)
|
|
120
|
+
return tool_ctx
|
|
121
|
+
|
|
122
|
+
def set_tool_context_info(self, tool_ctx: ToolContext) -> None:
|
|
123
|
+
"""Set the tool info on the context.
|
|
124
|
+
|
|
125
|
+
Args:
|
|
126
|
+
tool_ctx: Tool context
|
|
127
|
+
"""
|
|
128
|
+
tool_ctx.set_tool_info(self.name)
|
|
129
|
+
|
|
130
|
+
async def parse_notebook(self, file_path: Path) -> tuple[dict[str, Any], list[NotebookCellSource]]:
|
|
131
|
+
"""Parse a Jupyter notebook file.
|
|
132
|
+
|
|
133
|
+
Args:
|
|
134
|
+
file_path: Path to the notebook file
|
|
135
|
+
|
|
136
|
+
Returns:
|
|
137
|
+
Tuple of (notebook_data, processed_cells)
|
|
138
|
+
"""
|
|
139
|
+
with open(file_path, "r", encoding="utf-8") as f:
|
|
140
|
+
content = f.read()
|
|
141
|
+
notebook = json.loads(content)
|
|
142
|
+
|
|
143
|
+
# Get notebook language
|
|
144
|
+
language = notebook.get("metadata", {}).get("language_info", {}).get("name", "python")
|
|
145
|
+
cells = notebook.get("cells", [])
|
|
146
|
+
processed_cells = []
|
|
147
|
+
|
|
148
|
+
for i, cell in enumerate(cells):
|
|
149
|
+
cell_type = cell.get("cell_type", "code")
|
|
150
|
+
|
|
151
|
+
# Skip if not code or markdown
|
|
152
|
+
if cell_type not in ["code", "markdown"]:
|
|
153
|
+
continue
|
|
154
|
+
|
|
155
|
+
# Get source
|
|
156
|
+
source = cell.get("source", "")
|
|
157
|
+
if isinstance(source, list):
|
|
158
|
+
source = "".join(source)
|
|
159
|
+
|
|
160
|
+
# Get execution count for code cells
|
|
161
|
+
execution_count = None
|
|
162
|
+
if cell_type == "code":
|
|
163
|
+
execution_count = cell.get("execution_count")
|
|
164
|
+
|
|
165
|
+
# Process outputs for code cells
|
|
166
|
+
outputs = []
|
|
167
|
+
if cell_type == "code" and "outputs" in cell:
|
|
168
|
+
for output in cell["outputs"]:
|
|
169
|
+
output_type = output.get("output_type", "")
|
|
170
|
+
|
|
171
|
+
# Process different output types
|
|
172
|
+
if output_type == "stream":
|
|
173
|
+
text = output.get("text", "")
|
|
174
|
+
if isinstance(text, list):
|
|
175
|
+
text = "".join(text)
|
|
176
|
+
outputs.append(NotebookCellOutput(output_type="stream", text=text))
|
|
177
|
+
|
|
178
|
+
elif output_type in ["execute_result", "display_data"]:
|
|
179
|
+
# Process text output
|
|
180
|
+
text = None
|
|
181
|
+
if "data" in output and "text/plain" in output["data"]:
|
|
182
|
+
text_data = output["data"]["text/plain"]
|
|
183
|
+
if isinstance(text_data, list):
|
|
184
|
+
text = "".join(text_data)
|
|
185
|
+
else:
|
|
186
|
+
text = text_data
|
|
187
|
+
|
|
188
|
+
# Process image output
|
|
189
|
+
image = None
|
|
190
|
+
if "data" in output:
|
|
191
|
+
if "image/png" in output["data"]:
|
|
192
|
+
image = NotebookOutputImage(
|
|
193
|
+
image_data=output["data"]["image/png"],
|
|
194
|
+
media_type="image/png"
|
|
195
|
+
)
|
|
196
|
+
elif "image/jpeg" in output["data"]:
|
|
197
|
+
image = NotebookOutputImage(
|
|
198
|
+
image_data=output["data"]["image/jpeg"],
|
|
199
|
+
media_type="image/jpeg"
|
|
200
|
+
)
|
|
201
|
+
|
|
202
|
+
outputs.append(
|
|
203
|
+
NotebookCellOutput(
|
|
204
|
+
output_type=output_type,
|
|
205
|
+
text=text,
|
|
206
|
+
image=image
|
|
207
|
+
)
|
|
208
|
+
)
|
|
209
|
+
|
|
210
|
+
elif output_type == "error":
|
|
211
|
+
# Format error traceback
|
|
212
|
+
ename = output.get("ename", "")
|
|
213
|
+
evalue = output.get("evalue", "")
|
|
214
|
+
traceback = output.get("traceback", [])
|
|
215
|
+
|
|
216
|
+
# Handle raw text strings and lists of strings
|
|
217
|
+
if isinstance(traceback, list):
|
|
218
|
+
# Clean ANSI escape codes and join the list but preserve the formatting
|
|
219
|
+
clean_traceback = [clean_ansi_escapes(line) for line in traceback]
|
|
220
|
+
traceback_text = "\n".join(clean_traceback)
|
|
221
|
+
else:
|
|
222
|
+
traceback_text = clean_ansi_escapes(str(traceback))
|
|
223
|
+
|
|
224
|
+
error_text = f"{ename}: {evalue}\n{traceback_text}"
|
|
225
|
+
outputs.append(NotebookCellOutput(output_type="error", text=error_text))
|
|
226
|
+
|
|
227
|
+
# Create cell object
|
|
228
|
+
processed_cell = NotebookCellSource(
|
|
229
|
+
cell_index=i,
|
|
230
|
+
cell_type=cell_type,
|
|
231
|
+
source=source,
|
|
232
|
+
language=language,
|
|
233
|
+
execution_count=execution_count,
|
|
234
|
+
outputs=outputs
|
|
235
|
+
)
|
|
236
|
+
|
|
237
|
+
processed_cells.append(processed_cell)
|
|
238
|
+
|
|
239
|
+
return notebook, processed_cells
|
|
240
|
+
|
|
241
|
+
def format_notebook_cells(self, cells: list[NotebookCellSource]) -> str:
|
|
242
|
+
"""Format notebook cells as a readable string.
|
|
243
|
+
|
|
244
|
+
Args:
|
|
245
|
+
cells: List of processed notebook cells
|
|
246
|
+
|
|
247
|
+
Returns:
|
|
248
|
+
Formatted string representation of the cells
|
|
249
|
+
"""
|
|
250
|
+
result = []
|
|
251
|
+
for cell in cells:
|
|
252
|
+
# Format the cell header
|
|
253
|
+
cell_header = f"Cell [{cell.cell_index}] {cell.cell_type}"
|
|
254
|
+
if cell.execution_count is not None:
|
|
255
|
+
cell_header += f" (execution_count: {cell.execution_count})"
|
|
256
|
+
if cell.cell_type == "code" and cell.language != "python":
|
|
257
|
+
cell_header += f" [{cell.language}]"
|
|
258
|
+
|
|
259
|
+
# Add cell to result
|
|
260
|
+
result.append(f"{cell_header}:")
|
|
261
|
+
result.append(f"```{cell.language if cell.cell_type == 'code' else ''}")
|
|
262
|
+
result.append(cell.source)
|
|
263
|
+
result.append("```")
|
|
264
|
+
|
|
265
|
+
# Add outputs if any
|
|
266
|
+
if cell.outputs:
|
|
267
|
+
result.append("Outputs:")
|
|
268
|
+
for output in cell.outputs:
|
|
269
|
+
if output.output_type == "error":
|
|
270
|
+
result.append("Error:")
|
|
271
|
+
result.append("```")
|
|
272
|
+
result.append(output.text)
|
|
273
|
+
result.append("```")
|
|
274
|
+
elif output.text:
|
|
275
|
+
result.append("Output:")
|
|
276
|
+
result.append("```")
|
|
277
|
+
result.append(output.text)
|
|
278
|
+
result.append("```")
|
|
279
|
+
if output.image:
|
|
280
|
+
result.append(f"[Image output: {output.image.media_type}]")
|
|
281
|
+
|
|
282
|
+
result.append("") # Empty line between cells
|
|
283
|
+
|
|
284
|
+
return "\n".join(result)
|
|
@@ -0,0 +1,295 @@
|
|
|
1
|
+
"""Edit notebook tool implementation.
|
|
2
|
+
|
|
3
|
+
This module provides the EditNotebookTool for editing Jupyter notebook files.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
import json
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Any, final, override
|
|
9
|
+
|
|
10
|
+
from mcp.server.fastmcp import Context as MCPContext
|
|
11
|
+
from mcp.server.fastmcp import FastMCP
|
|
12
|
+
|
|
13
|
+
from hanzo_mcp.tools.jupyter.base import JupyterBaseTool
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@final
|
|
17
|
+
class EditNotebookTool(JupyterBaseTool):
|
|
18
|
+
"""Tool for editing Jupyter notebook files."""
|
|
19
|
+
|
|
20
|
+
@property
|
|
21
|
+
@override
|
|
22
|
+
def name(self) -> str:
|
|
23
|
+
"""Get the tool name.
|
|
24
|
+
|
|
25
|
+
Returns:
|
|
26
|
+
Tool name
|
|
27
|
+
"""
|
|
28
|
+
return "edit_notebook"
|
|
29
|
+
|
|
30
|
+
@property
|
|
31
|
+
@override
|
|
32
|
+
def description(self) -> str:
|
|
33
|
+
"""Get the tool description.
|
|
34
|
+
|
|
35
|
+
Returns:
|
|
36
|
+
Tool description
|
|
37
|
+
"""
|
|
38
|
+
return """Edit a specific cell in a Jupyter notebook.
|
|
39
|
+
|
|
40
|
+
Enables editing, inserting, or deleting cells in a Jupyter notebook (.ipynb file).
|
|
41
|
+
In replace mode, the specified cell's source is updated with the new content.
|
|
42
|
+
In insert mode, a new cell is added at the specified index.
|
|
43
|
+
In delete mode, the specified cell is removed."""
|
|
44
|
+
|
|
45
|
+
@property
|
|
46
|
+
@override
|
|
47
|
+
def parameters(self) -> dict[str, Any]:
|
|
48
|
+
"""Get the parameter specifications for the tool.
|
|
49
|
+
|
|
50
|
+
Returns:
|
|
51
|
+
Parameter specifications
|
|
52
|
+
"""
|
|
53
|
+
return {
|
|
54
|
+
"properties": {
|
|
55
|
+
"path": {
|
|
56
|
+
"type": "string",
|
|
57
|
+
"description": "path to the Jupyter notebook file"
|
|
58
|
+
},
|
|
59
|
+
"cell_number": {
|
|
60
|
+
"type": "integer",
|
|
61
|
+
"description": "index of the cell to edit"
|
|
62
|
+
},
|
|
63
|
+
"new_source": {
|
|
64
|
+
"type": "string",
|
|
65
|
+
"description": "new source code or markdown content"
|
|
66
|
+
},
|
|
67
|
+
"cell_type": {
|
|
68
|
+
"anyOf": [
|
|
69
|
+
{"enum": ["code", "markdown"], "type": "string"},
|
|
70
|
+
{"type": "null"}
|
|
71
|
+
],
|
|
72
|
+
"default": None,
|
|
73
|
+
"description": "type of the new cell (code or markdown)"
|
|
74
|
+
},
|
|
75
|
+
"edit_mode": {
|
|
76
|
+
"default": "replace",
|
|
77
|
+
"enum": ["replace", "insert", "delete"],
|
|
78
|
+
"type": "string",
|
|
79
|
+
"description": "edit mode: replace, insert, or delete"
|
|
80
|
+
}
|
|
81
|
+
},
|
|
82
|
+
"required": ["path", "cell_number", "new_source"],
|
|
83
|
+
"title": "edit_notebookArguments",
|
|
84
|
+
"type": "object"
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
@property
|
|
88
|
+
@override
|
|
89
|
+
def required(self) -> list[str]:
|
|
90
|
+
"""Get the list of required parameter names.
|
|
91
|
+
|
|
92
|
+
Returns:
|
|
93
|
+
List of required parameter names
|
|
94
|
+
"""
|
|
95
|
+
return ["path", "cell_number", "new_source"]
|
|
96
|
+
|
|
97
|
+
@override
|
|
98
|
+
async def call(self, ctx: MCPContext, **params: Any) -> str:
|
|
99
|
+
"""Execute the tool with the given parameters.
|
|
100
|
+
|
|
101
|
+
Args:
|
|
102
|
+
ctx: MCP context
|
|
103
|
+
**params: Tool parameters
|
|
104
|
+
|
|
105
|
+
Returns:
|
|
106
|
+
Tool result
|
|
107
|
+
"""
|
|
108
|
+
tool_ctx = self.create_tool_context(ctx)
|
|
109
|
+
self.set_tool_context_info(tool_ctx)
|
|
110
|
+
|
|
111
|
+
# Extract parameters
|
|
112
|
+
path = params.get("path")
|
|
113
|
+
cell_number = params.get("cell_number")
|
|
114
|
+
new_source = params.get("new_source")
|
|
115
|
+
cell_type = params.get("cell_type")
|
|
116
|
+
edit_mode = params.get("edit_mode", "replace")
|
|
117
|
+
|
|
118
|
+
# Validate path parameter - ensure it's not None and convert to string
|
|
119
|
+
if path is None:
|
|
120
|
+
await tool_ctx.error("Path parameter is required")
|
|
121
|
+
return "Error: Path parameter is required"
|
|
122
|
+
|
|
123
|
+
path_str = str(path)
|
|
124
|
+
path_validation = self.validate_path(path_str)
|
|
125
|
+
if path_validation.is_error:
|
|
126
|
+
await tool_ctx.error(path_validation.error_message)
|
|
127
|
+
return f"Error: {path_validation.error_message}"
|
|
128
|
+
|
|
129
|
+
# Validate cell_number
|
|
130
|
+
if cell_number is None or cell_number < 0:
|
|
131
|
+
await tool_ctx.error("Cell number must be non-negative")
|
|
132
|
+
return "Error: Cell number must be non-negative"
|
|
133
|
+
|
|
134
|
+
# Validate edit_mode
|
|
135
|
+
if edit_mode not in ["replace", "insert", "delete"]:
|
|
136
|
+
await tool_ctx.error("Edit mode must be replace, insert, or delete")
|
|
137
|
+
return "Error: Edit mode must be replace, insert, or delete"
|
|
138
|
+
|
|
139
|
+
# In insert mode, cell_type is required
|
|
140
|
+
if edit_mode == "insert" and cell_type is None:
|
|
141
|
+
await tool_ctx.error("Cell type is required when using insert mode")
|
|
142
|
+
return "Error: Cell type is required when using insert mode"
|
|
143
|
+
|
|
144
|
+
# Don't validate new_source for delete mode
|
|
145
|
+
if edit_mode != "delete" and not new_source:
|
|
146
|
+
await tool_ctx.error("New source is required for replace or insert operations")
|
|
147
|
+
return "Error: New source is required for replace or insert operations"
|
|
148
|
+
|
|
149
|
+
await tool_ctx.info(f"Editing notebook: {path_str} (cell: {cell_number}, mode: {edit_mode})")
|
|
150
|
+
|
|
151
|
+
# Check if path is allowed
|
|
152
|
+
if not self.is_path_allowed(path_str):
|
|
153
|
+
await tool_ctx.error(
|
|
154
|
+
f"Access denied - path outside allowed directories: {path_str}"
|
|
155
|
+
)
|
|
156
|
+
return f"Error: Access denied - path outside allowed directories: {path_str}"
|
|
157
|
+
|
|
158
|
+
try:
|
|
159
|
+
file_path = Path(path_str)
|
|
160
|
+
|
|
161
|
+
if not file_path.exists():
|
|
162
|
+
await tool_ctx.error(f"File does not exist: {path_str}")
|
|
163
|
+
return f"Error: File does not exist: {path_str}"
|
|
164
|
+
|
|
165
|
+
if not file_path.is_file():
|
|
166
|
+
await tool_ctx.error(f"Path is not a file: {path_str}")
|
|
167
|
+
return f"Error: Path is not a file: {path_str}"
|
|
168
|
+
|
|
169
|
+
# Check file extension
|
|
170
|
+
if file_path.suffix.lower() != ".ipynb":
|
|
171
|
+
await tool_ctx.error(f"File is not a Jupyter notebook: {path_str}")
|
|
172
|
+
return f"Error: File is not a Jupyter notebook: {path_str}"
|
|
173
|
+
|
|
174
|
+
# Read and parse the notebook
|
|
175
|
+
try:
|
|
176
|
+
with open(file_path, "r", encoding="utf-8") as f:
|
|
177
|
+
content = f.read()
|
|
178
|
+
notebook = json.loads(content)
|
|
179
|
+
except json.JSONDecodeError:
|
|
180
|
+
await tool_ctx.error(f"Invalid notebook format: {path_str}")
|
|
181
|
+
return f"Error: Invalid notebook format: {path_str}"
|
|
182
|
+
except UnicodeDecodeError:
|
|
183
|
+
await tool_ctx.error(f"Cannot read notebook file: {path_str}")
|
|
184
|
+
return f"Error: Cannot read notebook file: {path_str}"
|
|
185
|
+
|
|
186
|
+
# Check cell_number is valid
|
|
187
|
+
cells = notebook.get("cells", [])
|
|
188
|
+
|
|
189
|
+
if edit_mode == "insert":
|
|
190
|
+
if cell_number > len(cells):
|
|
191
|
+
await tool_ctx.error(f"Cell number {cell_number} is out of bounds for insert (max: {len(cells)})")
|
|
192
|
+
return f"Error: Cell number {cell_number} is out of bounds for insert (max: {len(cells)})"
|
|
193
|
+
else: # replace or delete
|
|
194
|
+
if cell_number >= len(cells):
|
|
195
|
+
await tool_ctx.error(f"Cell number {cell_number} is out of bounds (max: {len(cells) - 1})")
|
|
196
|
+
return f"Error: Cell number {cell_number} is out of bounds (max: {len(cells) - 1})"
|
|
197
|
+
|
|
198
|
+
# Get notebook language (needed for context but not directly used in this block)
|
|
199
|
+
_ = notebook.get("metadata", {}).get("language_info", {}).get("name", "python")
|
|
200
|
+
|
|
201
|
+
# Perform the requested operation
|
|
202
|
+
if edit_mode == "replace":
|
|
203
|
+
# Get the target cell
|
|
204
|
+
target_cell = cells[cell_number]
|
|
205
|
+
|
|
206
|
+
# Store previous contents for reporting
|
|
207
|
+
old_type = target_cell.get("cell_type", "code")
|
|
208
|
+
old_source = target_cell.get("source", "")
|
|
209
|
+
|
|
210
|
+
# Fix for old_source which might be a list of strings
|
|
211
|
+
if isinstance(old_source, list):
|
|
212
|
+
old_source = "".join([str(item) for item in old_source])
|
|
213
|
+
|
|
214
|
+
# Update source
|
|
215
|
+
target_cell["source"] = new_source
|
|
216
|
+
|
|
217
|
+
# Update type if specified
|
|
218
|
+
if cell_type is not None:
|
|
219
|
+
target_cell["cell_type"] = cell_type
|
|
220
|
+
|
|
221
|
+
# If changing to markdown, remove code-specific fields
|
|
222
|
+
if cell_type == "markdown":
|
|
223
|
+
if "outputs" in target_cell:
|
|
224
|
+
del target_cell["outputs"]
|
|
225
|
+
if "execution_count" in target_cell:
|
|
226
|
+
del target_cell["execution_count"]
|
|
227
|
+
|
|
228
|
+
# If code cell, reset execution
|
|
229
|
+
if target_cell["cell_type"] == "code":
|
|
230
|
+
target_cell["outputs"] = []
|
|
231
|
+
target_cell["execution_count"] = None
|
|
232
|
+
|
|
233
|
+
change_description = f"Replaced cell {cell_number}"
|
|
234
|
+
if cell_type is not None and cell_type != old_type:
|
|
235
|
+
change_description += f" (changed type from {old_type} to {cell_type})"
|
|
236
|
+
|
|
237
|
+
elif edit_mode == "insert":
|
|
238
|
+
# Create new cell
|
|
239
|
+
new_cell: dict[str, Any] = {
|
|
240
|
+
"cell_type": cell_type,
|
|
241
|
+
"source": new_source,
|
|
242
|
+
"metadata": {}
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
# Add code-specific fields
|
|
246
|
+
if cell_type == "code":
|
|
247
|
+
new_cell["outputs"] = []
|
|
248
|
+
new_cell["execution_count"] = None
|
|
249
|
+
|
|
250
|
+
# Insert the cell
|
|
251
|
+
cells.insert(cell_number, new_cell)
|
|
252
|
+
change_description = f"Inserted new {cell_type} cell at position {cell_number}"
|
|
253
|
+
|
|
254
|
+
else: # delete
|
|
255
|
+
# Store deleted cell info for reporting
|
|
256
|
+
deleted_cell = cells[cell_number]
|
|
257
|
+
deleted_type = deleted_cell.get("cell_type", "code")
|
|
258
|
+
|
|
259
|
+
# Remove the cell
|
|
260
|
+
del cells[cell_number]
|
|
261
|
+
change_description = f"Deleted {deleted_type} cell at position {cell_number}"
|
|
262
|
+
|
|
263
|
+
# Write the updated notebook back to file
|
|
264
|
+
with open(file_path, "w", encoding="utf-8") as f:
|
|
265
|
+
json.dump(notebook, f, indent=1)
|
|
266
|
+
|
|
267
|
+
# Update document context
|
|
268
|
+
updated_content = json.dumps(notebook, indent=1)
|
|
269
|
+
self.document_context.update_document(path_str, updated_content)
|
|
270
|
+
|
|
271
|
+
await tool_ctx.info(f"Successfully edited notebook: {path_str} - {change_description}")
|
|
272
|
+
return f"Successfully edited notebook: {path_str} - {change_description}"
|
|
273
|
+
except Exception as e:
|
|
274
|
+
await tool_ctx.error(f"Error editing notebook: {str(e)}")
|
|
275
|
+
return f"Error editing notebook: {str(e)}"
|
|
276
|
+
|
|
277
|
+
@override
|
|
278
|
+
def register(self, mcp_server: FastMCP) -> None:
|
|
279
|
+
"""Register this edit notebook tool with the MCP server.
|
|
280
|
+
|
|
281
|
+
Creates a wrapper function with explicitly defined parameters that match
|
|
282
|
+
the tool's parameter schema and registers it with the MCP server.
|
|
283
|
+
|
|
284
|
+
Args:
|
|
285
|
+
mcp_server: The FastMCP server instance
|
|
286
|
+
"""
|
|
287
|
+
tool_self = self # Create a reference to self for use in the closure
|
|
288
|
+
|
|
289
|
+
@mcp_server.tool(name=self.name, description=self.mcp_description)
|
|
290
|
+
async def edit_notebook(ctx: MCPContext, path: str, cell_number: int, new_source: str,
|
|
291
|
+
cell_type: str | None = None,
|
|
292
|
+
edit_mode: str = "replace") -> str:
|
|
293
|
+
return await tool_self.call(ctx, path=path, cell_number=cell_number,
|
|
294
|
+
new_source=new_source, cell_type=cell_type,
|
|
295
|
+
edit_mode=edit_mode)
|