hanzo-mcp 0.1.25__py3-none-any.whl → 0.1.32__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 (49) hide show
  1. hanzo_mcp/__init__.py +2 -2
  2. hanzo_mcp/cli.py +80 -9
  3. hanzo_mcp/server.py +41 -10
  4. hanzo_mcp/tools/__init__.py +54 -32
  5. hanzo_mcp/tools/agent/__init__.py +59 -0
  6. hanzo_mcp/tools/agent/agent_tool.py +474 -0
  7. hanzo_mcp/tools/agent/prompt.py +137 -0
  8. hanzo_mcp/tools/agent/tool_adapter.py +75 -0
  9. hanzo_mcp/tools/common/__init__.py +29 -0
  10. hanzo_mcp/tools/common/base.py +216 -0
  11. hanzo_mcp/tools/common/context.py +7 -3
  12. hanzo_mcp/tools/common/permissions.py +63 -119
  13. hanzo_mcp/tools/common/session.py +91 -0
  14. hanzo_mcp/tools/common/thinking_tool.py +123 -0
  15. hanzo_mcp/tools/common/version_tool.py +62 -0
  16. hanzo_mcp/tools/filesystem/__init__.py +85 -5
  17. hanzo_mcp/tools/filesystem/base.py +113 -0
  18. hanzo_mcp/tools/filesystem/content_replace.py +287 -0
  19. hanzo_mcp/tools/filesystem/directory_tree.py +286 -0
  20. hanzo_mcp/tools/filesystem/edit_file.py +287 -0
  21. hanzo_mcp/tools/filesystem/get_file_info.py +170 -0
  22. hanzo_mcp/tools/filesystem/read_files.py +198 -0
  23. hanzo_mcp/tools/filesystem/search_content.py +275 -0
  24. hanzo_mcp/tools/filesystem/write_file.py +162 -0
  25. hanzo_mcp/tools/jupyter/__init__.py +67 -4
  26. hanzo_mcp/tools/jupyter/base.py +284 -0
  27. hanzo_mcp/tools/jupyter/edit_notebook.py +295 -0
  28. hanzo_mcp/tools/jupyter/notebook_operations.py +72 -112
  29. hanzo_mcp/tools/jupyter/read_notebook.py +165 -0
  30. hanzo_mcp/tools/project/__init__.py +64 -1
  31. hanzo_mcp/tools/project/analysis.py +9 -6
  32. hanzo_mcp/tools/project/base.py +66 -0
  33. hanzo_mcp/tools/project/project_analyze.py +173 -0
  34. hanzo_mcp/tools/shell/__init__.py +58 -1
  35. hanzo_mcp/tools/shell/base.py +148 -0
  36. hanzo_mcp/tools/shell/command_executor.py +203 -322
  37. hanzo_mcp/tools/shell/run_command.py +204 -0
  38. hanzo_mcp/tools/shell/run_script.py +215 -0
  39. hanzo_mcp/tools/shell/script_tool.py +244 -0
  40. {hanzo_mcp-0.1.25.dist-info → hanzo_mcp-0.1.32.dist-info}/METADATA +83 -77
  41. hanzo_mcp-0.1.32.dist-info/RECORD +46 -0
  42. {hanzo_mcp-0.1.25.dist-info → hanzo_mcp-0.1.32.dist-info}/licenses/LICENSE +2 -2
  43. hanzo_mcp/tools/common/thinking.py +0 -65
  44. hanzo_mcp/tools/filesystem/file_operations.py +0 -1050
  45. hanzo_mcp-0.1.25.dist-info/RECORD +0 -24
  46. hanzo_mcp-0.1.25.dist-info/zip-safe +0 -1
  47. {hanzo_mcp-0.1.25.dist-info → hanzo_mcp-0.1.32.dist-info}/WHEEL +0 -0
  48. {hanzo_mcp-0.1.25.dist-info → hanzo_mcp-0.1.32.dist-info}/entry_points.txt +0 -0
  49. {hanzo_mcp-0.1.25.dist-info → hanzo_mcp-0.1.32.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)