hanzo-mcp 0.1.21__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.
@@ -0,0 +1,8 @@
1
+ """Jupyter notebook tools for Hanzo Dev MCP.
2
+
3
+ This module provides tools for reading and editing Jupyter notebooks (.ipynb files).
4
+ """
5
+
6
+ from hanzo_mcp.tools.jupyter.notebook_operations import JupyterNotebookTools
7
+
8
+ __all__ = ["JupyterNotebookTools"]
@@ -0,0 +1,554 @@
1
+ """Jupyter notebook operations tools for Hanzo Dev MCP.
2
+
3
+ This module provides tools for reading and editing Jupyter notebook (.ipynb) files.
4
+ It supports reading notebook cells with their outputs and modifying notebook contents.
5
+ """
6
+
7
+ import json
8
+ import re
9
+ from pathlib import Path
10
+ from typing import Any, final, Literal
11
+
12
+ from mcp.server.fastmcp import Context as MCPContext
13
+ from mcp.server.fastmcp import FastMCP
14
+
15
+ from hanzo_mcp.tools.common.context import DocumentContext, create_tool_context
16
+ from hanzo_mcp.tools.common.permissions import PermissionManager
17
+ from hanzo_mcp.tools.common.validation import validate_path_parameter
18
+
19
+
20
+ # Pattern to match ANSI escape sequences
21
+ ANSI_ESCAPE_PATTERN = re.compile(r"\x1B\[[0-9;]*[a-zA-Z]")
22
+
23
+
24
+ # Function to clean ANSI escape codes from text
25
+ def clean_ansi_escapes(text: str) -> str:
26
+ """Remove ANSI escape sequences from text.
27
+
28
+ Args:
29
+ text: Text containing ANSI escape sequences
30
+
31
+ Returns:
32
+ Text with ANSI escape sequences removed
33
+ """
34
+ return ANSI_ESCAPE_PATTERN.sub("", text)
35
+
36
+
37
+ # Type definitions for Jupyter notebooks based on the nbformat spec
38
+ CellType = Literal["code", "markdown"]
39
+ OutputType = Literal["stream", "display_data", "execute_result", "error"]
40
+ EditMode = Literal["replace", "insert", "delete"]
41
+
42
+
43
+ # Define a structure for notebook cell outputs
44
+ @final
45
+ class NotebookOutputImage:
46
+ """Representation of an image output in a notebook cell."""
47
+
48
+ def __init__(self, image_data: str, media_type: str):
49
+ """Initialize a notebook output image.
50
+
51
+ Args:
52
+ image_data: Base64-encoded image data
53
+ media_type: Media type of the image (e.g., "image/png")
54
+ """
55
+ self.image_data = image_data
56
+ self.media_type = media_type
57
+
58
+
59
+ @final
60
+ class NotebookCellOutput:
61
+ """Representation of an output from a notebook cell."""
62
+
63
+ def __init__(
64
+ self,
65
+ output_type: OutputType,
66
+ text: str | None = None,
67
+ image: NotebookOutputImage | None = None,
68
+ ):
69
+ """Initialize a notebook cell output.
70
+
71
+ Args:
72
+ output_type: Type of output
73
+ text: Text output (if any)
74
+ image: Image output (if any)
75
+ """
76
+ self.output_type = output_type
77
+ self.text = text
78
+ self.image = image
79
+
80
+
81
+ @final
82
+ class NotebookCellSource:
83
+ """Representation of a source cell from a notebook."""
84
+
85
+ def __init__(
86
+ self,
87
+ cell_index: int,
88
+ cell_type: CellType,
89
+ source: str,
90
+ language: str,
91
+ execution_count: int | None = None,
92
+ outputs: list[NotebookCellOutput] | None = None,
93
+ ):
94
+ """Initialize a notebook cell source.
95
+
96
+ Args:
97
+ cell_index: Index of the cell in the notebook
98
+ cell_type: Type of cell (code or markdown)
99
+ source: Source code or text of the cell
100
+ language: Programming language of the cell
101
+ execution_count: Execution count of the cell (if any)
102
+ outputs: Outputs from the cell (if any)
103
+ """
104
+ self.cell_index = cell_index
105
+ self.cell_type = cell_type
106
+ self.source = source
107
+ self.language = language
108
+ self.execution_count = execution_count
109
+ self.outputs = outputs or []
110
+
111
+
112
+ @final
113
+ class JupyterNotebookTools:
114
+ """Tools for working with Jupyter notebooks."""
115
+
116
+ def __init__(
117
+ self, document_context: DocumentContext, permission_manager: PermissionManager
118
+ ) -> None:
119
+ """Initialize notebook tools.
120
+
121
+ Args:
122
+ document_context: Document context for tracking file contents
123
+ permission_manager: Permission manager for access control
124
+ """
125
+ self.document_context: DocumentContext = document_context
126
+ self.permission_manager: PermissionManager = permission_manager
127
+
128
+ def register_tools(self, mcp_server: FastMCP) -> None:
129
+ """Register jupyter notebook tools with the MCP server.
130
+
131
+ Args:
132
+ mcp_server: The FastMCP server instance
133
+ """
134
+
135
+ @mcp_server.tool()
136
+ async def read_notebook(path: str, ctx: MCPContext) -> str:
137
+ """Extract and read source code from all cells in a Jupyter notebook.
138
+
139
+ Reads a Jupyter notebook (.ipynb file) and returns all of the cells with
140
+ their outputs. Jupyter notebooks are interactive documents that combine
141
+ code, text, and visualizations, commonly used for data analysis and
142
+ scientific computing.
143
+
144
+ Args:
145
+ path: Absolute path to the notebook file (must be absolute, not relative)
146
+
147
+ Returns:
148
+ Formatted content of all notebook cells with their outputs
149
+ """
150
+ tool_ctx = create_tool_context(ctx)
151
+ tool_ctx.set_tool_info("read_notebook")
152
+
153
+ # Validate path parameter
154
+ path_validation = validate_path_parameter(path)
155
+ if path_validation.is_error:
156
+ await tool_ctx.error(path_validation.error_message)
157
+ return f"Error: {path_validation.error_message}"
158
+
159
+ await tool_ctx.info(f"Reading notebook: {path}")
160
+
161
+ # Check if path is allowed to be read
162
+ if not self.permission_manager.is_path_allowed(path):
163
+ await tool_ctx.error(
164
+ f"Access denied - path outside allowed directories: {path}"
165
+ )
166
+ return (
167
+ f"Error: Access denied - path outside allowed directories: {path}"
168
+ )
169
+
170
+ try:
171
+ file_path = Path(path)
172
+
173
+ if not file_path.exists():
174
+ await tool_ctx.error(f"File does not exist: {path}")
175
+ return f"Error: File does not exist: {path}"
176
+
177
+ if not file_path.is_file():
178
+ await tool_ctx.error(f"Path is not a file: {path}")
179
+ return f"Error: Path is not a file: {path}"
180
+
181
+ # Check file extension
182
+ if file_path.suffix.lower() != ".ipynb":
183
+ await tool_ctx.error(f"File is not a Jupyter notebook: {path}")
184
+ return f"Error: File is not a Jupyter notebook: {path}"
185
+
186
+ # Read and parse the notebook
187
+ try:
188
+ with open(file_path, "r", encoding="utf-8") as f:
189
+ content = f.read()
190
+ notebook = json.loads(content)
191
+ except json.JSONDecodeError:
192
+ await tool_ctx.error(f"Invalid notebook format: {path}")
193
+ return f"Error: Invalid notebook format: {path}"
194
+ except UnicodeDecodeError:
195
+ await tool_ctx.error(f"Cannot read notebook file: {path}")
196
+ return f"Error: Cannot read notebook file: {path}"
197
+
198
+ # Add to document context
199
+ self.document_context.add_document(path, content)
200
+
201
+ # Process notebook cells
202
+ language = (
203
+ notebook.get("metadata", {})
204
+ .get("language_info", {})
205
+ .get("name", "python")
206
+ )
207
+ cells = notebook.get("cells", [])
208
+ processed_cells = []
209
+
210
+ for i, cell in enumerate(cells):
211
+ cell_type = cell.get("cell_type", "code")
212
+
213
+ # Skip if not code or markdown
214
+ if cell_type not in ["code", "markdown"]:
215
+ continue
216
+
217
+ # Get source
218
+ source = cell.get("source", "")
219
+ if isinstance(source, list):
220
+ source = "".join(source)
221
+
222
+ # Get execution count for code cells
223
+ execution_count = None
224
+ if cell_type == "code":
225
+ execution_count = cell.get("execution_count")
226
+
227
+ # Process outputs for code cells
228
+ outputs = []
229
+ if cell_type == "code" and "outputs" in cell:
230
+ for output in cell["outputs"]:
231
+ output_type = output.get("output_type", "")
232
+
233
+ # Process different output types
234
+ if output_type == "stream":
235
+ text = output.get("text", "")
236
+ if isinstance(text, list):
237
+ text = "".join(text)
238
+ outputs.append(
239
+ NotebookCellOutput(output_type="stream", text=text)
240
+ )
241
+
242
+ elif output_type in ["execute_result", "display_data"]:
243
+ # Process text output
244
+ text = None
245
+ if "data" in output and "text/plain" in output["data"]:
246
+ text_data = output["data"]["text/plain"]
247
+ if isinstance(text_data, list):
248
+ text = "".join(text_data)
249
+ else:
250
+ text = text_data
251
+
252
+ # Process image output
253
+ image = None
254
+ if "data" in output:
255
+ if "image/png" in output["data"]:
256
+ image = NotebookOutputImage(
257
+ image_data=output["data"]["image/png"],
258
+ media_type="image/png",
259
+ )
260
+ elif "image/jpeg" in output["data"]:
261
+ image = NotebookOutputImage(
262
+ image_data=output["data"]["image/jpeg"],
263
+ media_type="image/jpeg",
264
+ )
265
+
266
+ outputs.append(
267
+ NotebookCellOutput(
268
+ output_type=output_type, text=text, image=image
269
+ )
270
+ )
271
+
272
+ elif output_type == "error":
273
+ # Format error traceback
274
+ ename = output.get("ename", "")
275
+ evalue = output.get("evalue", "")
276
+ traceback = output.get("traceback", [])
277
+
278
+ # Handle raw text strings and lists of strings
279
+ if isinstance(traceback, list):
280
+ # Clean ANSI escape codes and join the list but preserve the formatting
281
+ clean_traceback = [
282
+ clean_ansi_escapes(line) for line in traceback
283
+ ]
284
+ traceback_text = "\n".join(clean_traceback)
285
+ else:
286
+ traceback_text = clean_ansi_escapes(str(traceback))
287
+
288
+ error_text = f"{ename}: {evalue}\n{traceback_text}"
289
+ outputs.append(
290
+ NotebookCellOutput(
291
+ output_type="error", text=error_text
292
+ )
293
+ )
294
+
295
+ # Create cell object
296
+ processed_cell = NotebookCellSource(
297
+ cell_index=i,
298
+ cell_type=cell_type,
299
+ source=source,
300
+ language=language,
301
+ execution_count=execution_count,
302
+ outputs=outputs,
303
+ )
304
+
305
+ processed_cells.append(processed_cell)
306
+
307
+ # Format the notebook content as a readable string
308
+ result = []
309
+ for cell in processed_cells:
310
+ # Format the cell header
311
+ cell_header = f"Cell [{cell.cell_index}] {cell.cell_type}"
312
+ if cell.execution_count is not None:
313
+ cell_header += f" (execution_count: {cell.execution_count})"
314
+ if cell.cell_type == "code" and cell.language != "python":
315
+ cell_header += f" [{cell.language}]"
316
+
317
+ # Add cell to result
318
+ result.append(f"{cell_header}:")
319
+ result.append(
320
+ f"```{cell.language if cell.cell_type == 'code' else ''}"
321
+ )
322
+ result.append(cell.source)
323
+ result.append("```")
324
+
325
+ # Add outputs if any
326
+ if cell.outputs:
327
+ result.append("Outputs:")
328
+ for output in cell.outputs:
329
+ if output.output_type == "error":
330
+ result.append("Error:")
331
+ result.append("```")
332
+ result.append(output.text)
333
+ result.append("```")
334
+ elif output.text:
335
+ result.append("Output:")
336
+ result.append("```")
337
+ result.append(output.text)
338
+ result.append("```")
339
+ if output.image:
340
+ result.append(
341
+ f"[Image output: {output.image.media_type}]"
342
+ )
343
+
344
+ result.append("") # Empty line between cells
345
+
346
+ await tool_ctx.info(
347
+ f"Successfully read notebook: {path} ({len(processed_cells)} cells)"
348
+ )
349
+ return "\n".join(result)
350
+ except Exception as e:
351
+ await tool_ctx.error(f"Error reading notebook: {str(e)}")
352
+ return f"Error reading notebook: {str(e)}"
353
+
354
+ @mcp_server.tool()
355
+ async def edit_notebook(
356
+ path: str,
357
+ cell_number: int,
358
+ new_source: str,
359
+ ctx: MCPContext,
360
+ cell_type: CellType | None = None,
361
+ edit_mode: EditMode = "replace",
362
+ ) -> str:
363
+ """Edit a specific cell in a Jupyter notebook.
364
+
365
+ Enables editing, inserting, or deleting cells in a Jupyter notebook (.ipynb file).
366
+ In replace mode, the specified cell's source is updated with the new content.
367
+ In insert mode, a new cell is added at the specified index.
368
+ In delete mode, the specified cell is removed.
369
+
370
+ Args:
371
+ path: Absolute path to the notebook file (must be absolute, not relative)
372
+ cell_number: Zero-based index of the cell to edit
373
+ new_source: New source code or text for the cell (ignored in delete mode)
374
+ cell_type: Type of cell (code or markdown), default is to keep existing type
375
+ edit_mode: Type of edit operation (replace, insert, delete), default is replace
376
+
377
+ Returns:
378
+ Result of the edit operation with details on changes made
379
+ """
380
+ tool_ctx = create_tool_context(ctx)
381
+ tool_ctx.set_tool_info("edit_notebook")
382
+
383
+ # Validate path parameter
384
+ path_validation = validate_path_parameter(path)
385
+ if path_validation.is_error:
386
+ await tool_ctx.error(path_validation.error_message)
387
+ return f"Error: {path_validation.error_message}"
388
+
389
+ # Validate cell_number
390
+ if cell_number < 0:
391
+ await tool_ctx.error("Cell number must be non-negative")
392
+ return "Error: Cell number must be non-negative"
393
+
394
+ # Validate edit_mode
395
+ if edit_mode not in ["replace", "insert", "delete"]:
396
+ await tool_ctx.error("Edit mode must be replace, insert, or delete")
397
+ return "Error: Edit mode must be replace, insert, or delete"
398
+
399
+ # In insert mode, cell_type is required
400
+ if edit_mode == "insert" and cell_type is None:
401
+ await tool_ctx.error("Cell type is required when using insert mode")
402
+ return "Error: Cell type is required when using insert mode"
403
+
404
+ # Don't validate new_source for delete mode
405
+ if edit_mode != "delete" and not new_source:
406
+ await tool_ctx.error(
407
+ "New source is required for replace or insert operations"
408
+ )
409
+ return "Error: New source is required for replace or insert operations"
410
+
411
+ await tool_ctx.info(
412
+ f"Editing notebook: {path} (cell: {cell_number}, mode: {edit_mode})"
413
+ )
414
+
415
+ # Check if path is allowed
416
+ if not self.permission_manager.is_path_allowed(path):
417
+ await tool_ctx.error(
418
+ f"Access denied - path outside allowed directories: {path}"
419
+ )
420
+ return (
421
+ f"Error: Access denied - path outside allowed directories: {path}"
422
+ )
423
+
424
+ try:
425
+ file_path = Path(path)
426
+
427
+ if not file_path.exists():
428
+ await tool_ctx.error(f"File does not exist: {path}")
429
+ return f"Error: File does not exist: {path}"
430
+
431
+ if not file_path.is_file():
432
+ await tool_ctx.error(f"Path is not a file: {path}")
433
+ return f"Error: Path is not a file: {path}"
434
+
435
+ # Check file extension
436
+ if file_path.suffix.lower() != ".ipynb":
437
+ await tool_ctx.error(f"File is not a Jupyter notebook: {path}")
438
+ return f"Error: File is not a Jupyter notebook: {path}"
439
+
440
+ # Read and parse the notebook
441
+ try:
442
+ with open(file_path, "r", encoding="utf-8") as f:
443
+ content = f.read()
444
+ notebook = json.loads(content)
445
+ except json.JSONDecodeError:
446
+ await tool_ctx.error(f"Invalid notebook format: {path}")
447
+ return f"Error: Invalid notebook format: {path}"
448
+ except UnicodeDecodeError:
449
+ await tool_ctx.error(f"Cannot read notebook file: {path}")
450
+ return f"Error: Cannot read notebook file: {path}"
451
+
452
+ # Check cell_number is valid
453
+ cells = notebook.get("cells", [])
454
+
455
+ if edit_mode == "insert":
456
+ if cell_number > len(cells):
457
+ await tool_ctx.error(
458
+ f"Cell number {cell_number} is out of bounds for insert (max: {len(cells)})"
459
+ )
460
+ return f"Error: Cell number {cell_number} is out of bounds for insert (max: {len(cells)})"
461
+ else: # replace or delete
462
+ if cell_number >= len(cells):
463
+ await tool_ctx.error(
464
+ f"Cell number {cell_number} is out of bounds (max: {len(cells) - 1})"
465
+ )
466
+ return f"Error: Cell number {cell_number} is out of bounds (max: {len(cells) - 1})"
467
+
468
+ language = (
469
+ notebook.get("metadata", {})
470
+ .get("language_info", {})
471
+ .get("name", "python")
472
+ )
473
+
474
+ # Perform the requested operation
475
+ if edit_mode == "replace":
476
+ # Get the target cell
477
+ target_cell = cells[cell_number]
478
+
479
+ # Store previous contents for reporting
480
+ old_type = target_cell.get("cell_type", "code")
481
+ old_source = target_cell.get("source", "")
482
+ if isinstance(old_source, list):
483
+ old_source = "".join(old_source)
484
+
485
+ # Update source
486
+ target_cell["source"] = new_source
487
+
488
+ # Update type if specified
489
+ if cell_type is not None:
490
+ target_cell["cell_type"] = cell_type
491
+
492
+ # If changing to markdown, remove code-specific fields
493
+ if cell_type == "markdown":
494
+ if "outputs" in target_cell:
495
+ del target_cell["outputs"]
496
+ if "execution_count" in target_cell:
497
+ del target_cell["execution_count"]
498
+
499
+ # If code cell, reset execution
500
+ if target_cell["cell_type"] == "code":
501
+ target_cell["outputs"] = []
502
+ target_cell["execution_count"] = None
503
+
504
+ change_description = f"Replaced cell {cell_number}"
505
+ if cell_type is not None and cell_type != old_type:
506
+ change_description += (
507
+ f" (changed type from {old_type} to {cell_type})"
508
+ )
509
+
510
+ elif edit_mode == "insert":
511
+ # Create new cell
512
+ new_cell: dict[str, Any] = {
513
+ "cell_type": cell_type,
514
+ "source": new_source,
515
+ "metadata": {},
516
+ }
517
+
518
+ # Add code-specific fields
519
+ if cell_type == "code":
520
+ new_cell["outputs"] = []
521
+ new_cell["execution_count"] = None
522
+
523
+ # Insert the cell
524
+ cells.insert(cell_number, new_cell)
525
+ change_description = (
526
+ f"Inserted new {cell_type} cell at position {cell_number}"
527
+ )
528
+
529
+ else: # delete
530
+ # Store deleted cell info for reporting
531
+ deleted_cell = cells[cell_number]
532
+ deleted_type = deleted_cell.get("cell_type", "code")
533
+
534
+ # Remove the cell
535
+ del cells[cell_number]
536
+ change_description = (
537
+ f"Deleted {deleted_type} cell at position {cell_number}"
538
+ )
539
+
540
+ # Write the updated notebook back to file
541
+ with open(file_path, "w", encoding="utf-8") as f:
542
+ json.dump(notebook, f, indent=1)
543
+
544
+ # Update document context
545
+ updated_content = json.dumps(notebook, indent=1)
546
+ self.document_context.update_document(path, updated_content)
547
+
548
+ await tool_ctx.info(
549
+ f"Successfully edited notebook: {path} - {change_description}"
550
+ )
551
+ return f"Successfully edited notebook: {path} - {change_description}"
552
+ except Exception as e:
553
+ await tool_ctx.error(f"Error editing notebook: {str(e)}")
554
+ return f"Error editing notebook: {str(e)}"
@@ -0,0 +1 @@
1
+ """Project analysis and management tools for Hanzo Dev MCP."""