hanzo-mcp 0.3.4__py3-none-any.whl → 0.5.0__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 (87) hide show
  1. hanzo_mcp/__init__.py +1 -1
  2. hanzo_mcp/cli.py +123 -160
  3. hanzo_mcp/cli_enhanced.py +438 -0
  4. hanzo_mcp/config/__init__.py +19 -0
  5. hanzo_mcp/config/settings.py +388 -0
  6. hanzo_mcp/config/tool_config.py +197 -0
  7. hanzo_mcp/prompts/__init__.py +117 -0
  8. hanzo_mcp/prompts/compact_conversation.py +77 -0
  9. hanzo_mcp/prompts/create_release.py +38 -0
  10. hanzo_mcp/prompts/project_system.py +120 -0
  11. hanzo_mcp/prompts/project_todo_reminder.py +111 -0
  12. hanzo_mcp/prompts/utils.py +286 -0
  13. hanzo_mcp/server.py +120 -98
  14. hanzo_mcp/tools/__init__.py +107 -31
  15. hanzo_mcp/tools/agent/__init__.py +8 -11
  16. hanzo_mcp/tools/agent/agent_tool.py +290 -224
  17. hanzo_mcp/tools/agent/prompt.py +16 -13
  18. hanzo_mcp/tools/agent/tool_adapter.py +9 -9
  19. hanzo_mcp/tools/common/__init__.py +17 -16
  20. hanzo_mcp/tools/common/base.py +79 -110
  21. hanzo_mcp/tools/common/batch_tool.py +330 -0
  22. hanzo_mcp/tools/common/context.py +26 -292
  23. hanzo_mcp/tools/common/permissions.py +12 -12
  24. hanzo_mcp/tools/common/thinking_tool.py +153 -0
  25. hanzo_mcp/tools/common/validation.py +1 -63
  26. hanzo_mcp/tools/filesystem/__init__.py +88 -41
  27. hanzo_mcp/tools/filesystem/base.py +32 -24
  28. hanzo_mcp/tools/filesystem/content_replace.py +114 -107
  29. hanzo_mcp/tools/filesystem/directory_tree.py +129 -105
  30. hanzo_mcp/tools/filesystem/edit.py +279 -0
  31. hanzo_mcp/tools/filesystem/grep.py +458 -0
  32. hanzo_mcp/tools/filesystem/grep_ast_tool.py +250 -0
  33. hanzo_mcp/tools/filesystem/multi_edit.py +362 -0
  34. hanzo_mcp/tools/filesystem/read.py +255 -0
  35. hanzo_mcp/tools/filesystem/write.py +156 -0
  36. hanzo_mcp/tools/jupyter/__init__.py +41 -29
  37. hanzo_mcp/tools/jupyter/base.py +66 -57
  38. hanzo_mcp/tools/jupyter/{edit_notebook.py → notebook_edit.py} +162 -139
  39. hanzo_mcp/tools/jupyter/notebook_read.py +152 -0
  40. hanzo_mcp/tools/shell/__init__.py +29 -20
  41. hanzo_mcp/tools/shell/base.py +87 -45
  42. hanzo_mcp/tools/shell/bash_session.py +731 -0
  43. hanzo_mcp/tools/shell/bash_session_executor.py +295 -0
  44. hanzo_mcp/tools/shell/command_executor.py +435 -384
  45. hanzo_mcp/tools/shell/run_command.py +284 -131
  46. hanzo_mcp/tools/shell/run_command_windows.py +328 -0
  47. hanzo_mcp/tools/shell/session_manager.py +196 -0
  48. hanzo_mcp/tools/shell/session_storage.py +325 -0
  49. hanzo_mcp/tools/todo/__init__.py +66 -0
  50. hanzo_mcp/tools/todo/base.py +319 -0
  51. hanzo_mcp/tools/todo/todo_read.py +148 -0
  52. hanzo_mcp/tools/todo/todo_write.py +378 -0
  53. hanzo_mcp/tools/vector/__init__.py +95 -0
  54. hanzo_mcp/tools/vector/infinity_store.py +365 -0
  55. hanzo_mcp/tools/vector/project_manager.py +361 -0
  56. hanzo_mcp/tools/vector/vector_index.py +115 -0
  57. hanzo_mcp/tools/vector/vector_search.py +215 -0
  58. {hanzo_mcp-0.3.4.dist-info → hanzo_mcp-0.5.0.dist-info}/METADATA +35 -3
  59. hanzo_mcp-0.5.0.dist-info/RECORD +63 -0
  60. {hanzo_mcp-0.3.4.dist-info → hanzo_mcp-0.5.0.dist-info}/WHEEL +1 -1
  61. hanzo_mcp/tools/agent/base_provider.py +0 -73
  62. hanzo_mcp/tools/agent/litellm_provider.py +0 -45
  63. hanzo_mcp/tools/agent/lmstudio_agent.py +0 -385
  64. hanzo_mcp/tools/agent/lmstudio_provider.py +0 -219
  65. hanzo_mcp/tools/agent/provider_registry.py +0 -120
  66. hanzo_mcp/tools/common/error_handling.py +0 -86
  67. hanzo_mcp/tools/common/logging_config.py +0 -115
  68. hanzo_mcp/tools/common/session.py +0 -91
  69. hanzo_mcp/tools/common/think_tool.py +0 -123
  70. hanzo_mcp/tools/common/version_tool.py +0 -120
  71. hanzo_mcp/tools/filesystem/edit_file.py +0 -287
  72. hanzo_mcp/tools/filesystem/get_file_info.py +0 -170
  73. hanzo_mcp/tools/filesystem/read_files.py +0 -198
  74. hanzo_mcp/tools/filesystem/search_content.py +0 -275
  75. hanzo_mcp/tools/filesystem/write_file.py +0 -162
  76. hanzo_mcp/tools/jupyter/notebook_operations.py +0 -514
  77. hanzo_mcp/tools/jupyter/read_notebook.py +0 -165
  78. hanzo_mcp/tools/project/__init__.py +0 -64
  79. hanzo_mcp/tools/project/analysis.py +0 -882
  80. hanzo_mcp/tools/project/base.py +0 -66
  81. hanzo_mcp/tools/project/project_analyze.py +0 -173
  82. hanzo_mcp/tools/shell/run_script.py +0 -215
  83. hanzo_mcp/tools/shell/script_tool.py +0 -244
  84. hanzo_mcp-0.3.4.dist-info/RECORD +0 -53
  85. {hanzo_mcp-0.3.4.dist-info → hanzo_mcp-0.5.0.dist-info}/entry_points.txt +0 -0
  86. {hanzo_mcp-0.3.4.dist-info → hanzo_mcp-0.5.0.dist-info}/licenses/LICENSE +0 -0
  87. {hanzo_mcp-0.3.4.dist-info → hanzo_mcp-0.5.0.dist-info}/top_level.txt +0 -0
@@ -1,136 +1,131 @@
1
1
  """Edit notebook tool implementation.
2
2
 
3
- This module provides the EditNotebookTool for editing Jupyter notebook files.
3
+ This module provides the NoteBookEditTool for editing Jupyter notebook files.
4
4
  """
5
5
 
6
6
  import json
7
7
  from pathlib import Path
8
- from typing import Any, final, override
8
+ from typing import Annotated, Any, Literal, TypedDict, Unpack, final, override
9
9
 
10
- from mcp.server.fastmcp import Context as MCPContext
11
- from mcp.server.fastmcp import FastMCP
10
+ from fastmcp import Context as MCPContext
11
+ from fastmcp import FastMCP
12
+ from fastmcp.server.dependencies import get_context
13
+ from pydantic import Field
12
14
 
13
15
  from hanzo_mcp.tools.jupyter.base import JupyterBaseTool
14
16
 
17
+ NotebookPath = Annotated[
18
+ str,
19
+ Field(
20
+ description="The absolute path to the Jupyter notebook file to edit (must be absolute, not relative)",
21
+ ),
22
+ ]
23
+
24
+ CellNumber = Annotated[
25
+ int,
26
+ Field(
27
+ description="The index of the cell to edit (0-based)",
28
+ ge=0,
29
+ ),
30
+ ]
31
+
32
+ NewSource = Annotated[
33
+ str,
34
+ Field(
35
+ description="The new source for the cell",
36
+ default="",
37
+ ),
38
+ ]
39
+
40
+ CellType = Annotated[
41
+ Literal["code", "markdown"],
42
+ Field(
43
+ 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.",
44
+ default="code",
45
+ ),
46
+ ]
47
+
48
+ EditMode = Annotated[
49
+ Literal["replace", "insert", "delete"],
50
+ Field(
51
+ description="The of edit to make (replace, insert, delete). Defaults to replace.",
52
+ default="replace",
53
+ ),
54
+ ]
55
+
56
+
57
+ class NotebookEditToolParams(TypedDict):
58
+ """Parameters for the NotebookEditTool.
59
+
60
+ Attributes:
61
+ notebook_path: The absolute path to the Jupyter notebook file to edit (must be absolute, not relative)
62
+ cell_number: The index of the cell to edit (0-based)
63
+ new_source: The new source for the cell
64
+ 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.
65
+ edit_mode: The of edit to make (replace, insert, delete). Defaults to replace.
66
+ """
67
+
68
+ notebook_path: NotebookPath
69
+ cell_number: CellNumber
70
+ new_source: NewSource
71
+ cell_type: CellType
72
+ edit_mode: EditMode
73
+
15
74
 
16
75
  @final
17
- class EditNotebookTool(JupyterBaseTool):
76
+ class NoteBookEditTool(JupyterBaseTool):
18
77
  """Tool for editing Jupyter notebook files."""
19
-
78
+
20
79
  @property
21
- @override
80
+ @override
22
81
  def name(self) -> str:
23
82
  """Get the tool name.
24
-
83
+
25
84
  Returns:
26
85
  Tool name
27
86
  """
28
- return "edit_notebook"
29
-
87
+ return "notebook_edit"
88
+
30
89
  @property
31
- @override
90
+ @override
32
91
  def description(self) -> str:
33
92
  """Get the tool description.
34
-
93
+
35
94
  Returns:
36
95
  Tool description
37
96
  """
38
- return """Edit a specific cell in a Jupyter notebook.
97
+ 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."
39
98
 
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
+ @override
100
+ async def call(
101
+ self,
102
+ ctx: MCPContext,
103
+ **params: Unpack[NotebookEditToolParams],
104
+ ) -> str:
99
105
  """Execute the tool with the given parameters.
100
-
106
+
101
107
  Args:
102
108
  ctx: MCP context
103
109
  **params: Tool parameters
104
-
110
+
105
111
  Returns:
106
112
  Tool result
107
113
  """
108
114
  tool_ctx = self.create_tool_context(ctx)
109
115
  self.set_tool_context_info(tool_ctx)
110
-
116
+
111
117
  # Extract parameters
112
- path = params.get("path")
118
+ notebook_path = params.get("notebook_path")
113
119
  cell_number = params.get("cell_number")
114
120
  new_source = params.get("new_source")
115
121
  cell_type = params.get("cell_type")
116
122
  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)
123
+
124
+ path_validation = self.validate_path(notebook_path)
125
125
  if path_validation.is_error:
126
126
  await tool_ctx.error(path_validation.error_message)
127
127
  return f"Error: {path_validation.error_message}"
128
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
129
  # Validate edit_mode
135
130
  if edit_mode not in ["replace", "insert", "delete"]:
136
131
  await tool_ctx.error("Edit mode must be replace, insert, or delete")
@@ -143,33 +138,37 @@ In delete mode, the specified cell is removed."""
143
138
 
144
139
  # Don't validate new_source for delete mode
145
140
  if edit_mode != "delete" and not new_source:
146
- await tool_ctx.error("New source is required for replace or insert operations")
141
+ await tool_ctx.error(
142
+ "New source is required for replace or insert operations"
143
+ )
147
144
  return "Error: New source is required for replace or insert operations"
148
145
 
149
- await tool_ctx.info(f"Editing notebook: {path_str} (cell: {cell_number}, mode: {edit_mode})")
146
+ await tool_ctx.info(
147
+ f"Editing notebook: {notebook_path} (cell: {cell_number}, mode: {edit_mode})"
148
+ )
150
149
 
151
150
  # Check if path is allowed
152
- if not self.is_path_allowed(path_str):
151
+ if not self.is_path_allowed(notebook_path):
153
152
  await tool_ctx.error(
154
- f"Access denied - path outside allowed directories: {path_str}"
153
+ f"Access denied - path outside allowed directories: {notebook_path}"
155
154
  )
156
- return f"Error: Access denied - path outside allowed directories: {path_str}"
155
+ return f"Error: Access denied - path outside allowed directories: {notebook_path}"
157
156
 
158
157
  try:
159
- file_path = Path(path_str)
158
+ file_path = Path(notebook_path)
160
159
 
161
160
  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}"
161
+ await tool_ctx.error(f"File does not exist: {notebook_path}")
162
+ return f"Error: File does not exist: {notebook_path}"
164
163
 
165
164
  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}"
165
+ await tool_ctx.error(f"Path is not a file: {notebook_path}")
166
+ return f"Error: Path is not a file: {notebook_path}"
168
167
 
169
168
  # Check file extension
170
169
  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}"
170
+ await tool_ctx.error(f"File is not a Jupyter notebook: {notebook_path}")
171
+ return f"Error: File is not a Jupyter notebook: {notebook_path}"
173
172
 
174
173
  # Read and parse the notebook
175
174
  try:
@@ -177,54 +176,62 @@ In delete mode, the specified cell is removed."""
177
176
  content = f.read()
178
177
  notebook = json.loads(content)
179
178
  except json.JSONDecodeError:
180
- await tool_ctx.error(f"Invalid notebook format: {path_str}")
181
- return f"Error: Invalid notebook format: {path_str}"
179
+ await tool_ctx.error(f"Invalid notebook format: {notebook_path}")
180
+ return f"Error: Invalid notebook format: {notebook_path}"
182
181
  except UnicodeDecodeError:
183
- await tool_ctx.error(f"Cannot read notebook file: {path_str}")
184
- return f"Error: Cannot read notebook file: {path_str}"
182
+ await tool_ctx.error(f"Cannot read notebook file: {notebook_path}")
183
+ return f"Error: Cannot read notebook file: {notebook_path}"
185
184
 
186
185
  # Check cell_number is valid
187
186
  cells = notebook.get("cells", [])
188
-
187
+
189
188
  if edit_mode == "insert":
190
189
  if cell_number > len(cells):
191
- await tool_ctx.error(f"Cell number {cell_number} is out of bounds for insert (max: {len(cells)})")
190
+ await tool_ctx.error(
191
+ f"Cell number {cell_number} is out of bounds for insert (max: {len(cells)})"
192
+ )
192
193
  return f"Error: Cell number {cell_number} is out of bounds for insert (max: {len(cells)})"
193
194
  else: # replace or delete
194
195
  if cell_number >= len(cells):
195
- await tool_ctx.error(f"Cell number {cell_number} is out of bounds (max: {len(cells) - 1})")
196
+ await tool_ctx.error(
197
+ f"Cell number {cell_number} is out of bounds (max: {len(cells) - 1})"
198
+ )
196
199
  return f"Error: Cell number {cell_number} is out of bounds (max: {len(cells) - 1})"
197
200
 
198
201
  # Get notebook language (needed for context but not directly used in this block)
199
- _ = notebook.get("metadata", {}).get("language_info", {}).get("name", "python")
202
+ _ = (
203
+ notebook.get("metadata", {})
204
+ .get("language_info", {})
205
+ .get("name", "python")
206
+ )
200
207
 
201
208
  # Perform the requested operation
202
209
  if edit_mode == "replace":
203
210
  # Get the target cell
204
211
  target_cell = cells[cell_number]
205
-
212
+
206
213
  # Store previous contents for reporting
207
214
  old_type = target_cell.get("cell_type", "code")
208
215
  old_source = target_cell.get("source", "")
209
-
216
+
210
217
  # Fix for old_source which might be a list of strings
211
218
  if isinstance(old_source, list):
212
219
  old_source = "".join([str(item) for item in old_source])
213
-
220
+
214
221
  # Update source
215
222
  target_cell["source"] = new_source
216
-
223
+
217
224
  # Update type if specified
218
225
  if cell_type is not None:
219
226
  target_cell["cell_type"] = cell_type
220
-
227
+
221
228
  # If changing to markdown, remove code-specific fields
222
229
  if cell_type == "markdown":
223
230
  if "outputs" in target_cell:
224
231
  del target_cell["outputs"]
225
232
  if "execution_count" in target_cell:
226
233
  del target_cell["execution_count"]
227
-
234
+
228
235
  # If code cell, reset execution
229
236
  if target_cell["cell_type"] == "code":
230
237
  target_cell["outputs"] = []
@@ -232,64 +239,80 @@ In delete mode, the specified cell is removed."""
232
239
 
233
240
  change_description = f"Replaced cell {cell_number}"
234
241
  if cell_type is not None and cell_type != old_type:
235
- change_description += f" (changed type from {old_type} to {cell_type})"
242
+ change_description += (
243
+ f" (changed type from {old_type} to {cell_type})"
244
+ )
236
245
 
237
246
  elif edit_mode == "insert":
238
247
  # Create new cell
239
248
  new_cell: dict[str, Any] = {
240
249
  "cell_type": cell_type,
241
250
  "source": new_source,
242
- "metadata": {}
251
+ "metadata": {},
243
252
  }
244
-
253
+
245
254
  # Add code-specific fields
246
255
  if cell_type == "code":
247
256
  new_cell["outputs"] = []
248
257
  new_cell["execution_count"] = None
249
-
258
+
250
259
  # Insert the cell
251
260
  cells.insert(cell_number, new_cell)
252
- change_description = f"Inserted new {cell_type} cell at position {cell_number}"
261
+ change_description = (
262
+ f"Inserted new {cell_type} cell at position {cell_number}"
263
+ )
253
264
 
254
265
  else: # delete
255
266
  # Store deleted cell info for reporting
256
267
  deleted_cell = cells[cell_number]
257
268
  deleted_type = deleted_cell.get("cell_type", "code")
258
-
269
+
259
270
  # Remove the cell
260
271
  del cells[cell_number]
261
- change_description = f"Deleted {deleted_type} cell at position {cell_number}"
272
+ change_description = (
273
+ f"Deleted {deleted_type} cell at position {cell_number}"
274
+ )
262
275
 
263
276
  # Write the updated notebook back to file
264
277
  with open(file_path, "w", encoding="utf-8") as f:
265
278
  json.dump(notebook, f, indent=1)
266
279
 
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}"
280
+ await tool_ctx.info(
281
+ f"Successfully edited notebook: {notebook_path} - {change_description}"
282
+ )
283
+ return (
284
+ f"Successfully edited notebook: {notebook_path} - {change_description}"
285
+ )
273
286
  except Exception as e:
274
287
  await tool_ctx.error(f"Error editing notebook: {str(e)}")
275
288
  return f"Error editing notebook: {str(e)}"
276
-
289
+
277
290
  @override
278
291
  def register(self, mcp_server: FastMCP) -> None:
279
292
  """Register this edit notebook tool with the MCP server.
280
-
293
+
281
294
  Creates a wrapper function with explicitly defined parameters that match
282
295
  the tool's parameter schema and registers it with the MCP server.
283
-
296
+
284
297
  Args:
285
298
  mcp_server: The FastMCP server instance
286
299
  """
287
300
  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)
301
+
302
+ @mcp_server.tool(name=self.name, description=self.description)
303
+ async def notebook_edit(
304
+ notebook_path: NotebookPath,
305
+ cell_number: CellNumber,
306
+ new_source: NewSource,
307
+ cell_type: CellType,
308
+ edit_mode: EditMode,
309
+ ) -> str:
310
+ ctx = get_context()
311
+ return await tool_self.call(
312
+ ctx,
313
+ notebook_path=notebook_path,
314
+ cell_number=cell_number,
315
+ new_source=new_source,
316
+ cell_type=cell_type,
317
+ edit_mode=edit_mode,
318
+ )
@@ -0,0 +1,152 @@
1
+ """Read notebook tool implementation.
2
+
3
+ This module provides the NotebookReadTool for reading Jupyter notebook files.
4
+ """
5
+
6
+ import json
7
+ from pathlib import Path
8
+ from typing import Annotated, TypedDict, Unpack, final, override
9
+
10
+ from fastmcp import Context as MCPContext
11
+ from fastmcp import FastMCP
12
+ from fastmcp.server.dependencies import get_context
13
+ from pydantic import Field
14
+
15
+ from hanzo_mcp.tools.jupyter.base import JupyterBaseTool
16
+
17
+ NotebookPath = Annotated[
18
+ str,
19
+ Field(
20
+ description="The absolute path to the Jupyter notebook file to read (must be absolute, not relative)",
21
+ ),
22
+ ]
23
+
24
+
25
+ class NotebookReadToolParams(TypedDict):
26
+ """Parameters for the NotebookReadTool.
27
+
28
+ Attributes:
29
+ notebook_path: The absolute path to the Jupyter notebook file to read (must be absolute, not relative)
30
+ """
31
+
32
+ notebook_path: NotebookPath
33
+
34
+
35
+ @final
36
+ class NotebookReadTool(JupyterBaseTool):
37
+ """Tool for reading Jupyter notebook files."""
38
+
39
+ @property
40
+ @override
41
+ def name(self) -> str:
42
+ """Get the tool name.
43
+
44
+ Returns:
45
+ Tool name
46
+ """
47
+ return "notebook_read"
48
+
49
+ @property
50
+ @override
51
+ def description(self) -> str:
52
+ """Get the tool description.
53
+
54
+ Returns:
55
+ Tool description
56
+ """
57
+ return "Reads a Jupyter notebook (.ipynb file) and returns all of the cells with their outputs. 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."
58
+
59
+ @override
60
+ async def call(
61
+ self,
62
+ ctx: MCPContext,
63
+ **params: Unpack[NotebookReadToolParams],
64
+ ) -> str:
65
+ """Execute the tool with the given parameters.
66
+
67
+ Args:
68
+ ctx: MCP context
69
+ **params: Tool parameters
70
+
71
+ Returns:
72
+ Tool result
73
+ """
74
+ tool_ctx = self.create_tool_context(ctx)
75
+ self.set_tool_context_info(tool_ctx)
76
+
77
+ # Extract parameters
78
+ notebook_path: NotebookPath = params["notebook_path"]
79
+
80
+ # Validate path parameter
81
+ path_validation = self.validate_path(notebook_path)
82
+ if path_validation.is_error:
83
+ await tool_ctx.error(path_validation.error_message)
84
+ return f"Error: {path_validation.error_message}"
85
+
86
+ await tool_ctx.info(f"Reading notebook: {notebook_path}")
87
+
88
+ # Check if path is allowed
89
+ if not self.is_path_allowed(notebook_path):
90
+ await tool_ctx.error(
91
+ f"Access denied - path outside allowed directories: {notebook_path}"
92
+ )
93
+ return f"Error: Access denied - path outside allowed directories: {notebook_path}"
94
+
95
+ try:
96
+ file_path = Path(notebook_path)
97
+
98
+ if not file_path.exists():
99
+ await tool_ctx.error(f"File does not exist: {notebook_path}")
100
+ return f"Error: File does not exist: {notebook_path}"
101
+
102
+ if not file_path.is_file():
103
+ await tool_ctx.error(f"Path is not a file: {notebook_path}")
104
+ return f"Error: Path is not a file: {notebook_path}"
105
+
106
+ # Check file extension
107
+ if file_path.suffix.lower() != ".ipynb":
108
+ await tool_ctx.error(f"File is not a Jupyter notebook: {notebook_path}")
109
+ return f"Error: File is not a Jupyter notebook: {notebook_path}"
110
+
111
+ # Read and parse the notebook
112
+ try:
113
+ # This will read the file, so we don't need to read it separately
114
+ _, processed_cells = await self.parse_notebook(file_path)
115
+
116
+ # Format the notebook content as a readable string
117
+ result = self.format_notebook_cells(processed_cells)
118
+
119
+ await tool_ctx.info(
120
+ f"Successfully read notebook: {notebook_path} ({len(processed_cells)} cells)"
121
+ )
122
+ return result
123
+ except json.JSONDecodeError:
124
+ await tool_ctx.error(f"Invalid notebook format: {notebook_path}")
125
+ return f"Error: Invalid notebook format: {notebook_path}"
126
+ except UnicodeDecodeError:
127
+ await tool_ctx.error(f"Cannot read notebook file: {notebook_path}")
128
+ return f"Error: Cannot read notebook file: {notebook_path}"
129
+ except Exception as e:
130
+ await tool_ctx.error(f"Error reading notebook: {str(e)}")
131
+ return f"Error reading notebook: {str(e)}"
132
+
133
+ @override
134
+ def register(self, mcp_server: FastMCP) -> None:
135
+ """Register this read notebook tool with the MCP server.
136
+
137
+ Creates a wrapper function with explicitly defined parameters that match
138
+ the tool's parameter schema and registers it with the MCP server.
139
+
140
+ Args:
141
+ mcp_server: The FastMCP server instance
142
+ """
143
+
144
+ tool_self = self # Create a reference to self for use in the closure
145
+
146
+ @mcp_server.tool(name=self.name, description=self.description)
147
+ async def notebook_read(
148
+ ctx: MCPContext,
149
+ notebook_path: NotebookPath,
150
+ ) -> str:
151
+ ctx = get_context()
152
+ return await tool_self.call(ctx, notebook_path=notebook_path)