claude_code_acp/mcp/tools/
notebook_edit.rs

1//! NotebookEdit tool for editing Jupyter notebooks
2//!
3//! Edits Jupyter notebook (.ipynb) files by replacing, inserting, or deleting cells.
4
5use async_trait::async_trait;
6use serde::{Deserialize, Serialize};
7use serde_json::{Value, json};
8use std::fs;
9
10use super::base::Tool;
11use crate::mcp::registry::{ToolContext, ToolResult};
12
13/// Input parameters for NotebookEdit
14#[derive(Debug, Deserialize)]
15struct NotebookEditInput {
16    /// The absolute path to the notebook file
17    notebook_path: String,
18    /// The new source for the cell
19    new_source: String,
20    /// The cell number (0-indexed) or cell ID to edit
21    #[serde(default)]
22    cell_number: Option<usize>,
23    /// The cell ID to edit (alternative to cell_number)
24    #[serde(default)]
25    cell_id: Option<String>,
26    /// The type of the cell (code or markdown)
27    #[serde(default)]
28    cell_type: Option<String>,
29    /// The edit mode (replace, insert, delete)
30    #[serde(default)]
31    edit_mode: Option<String>,
32}
33
34/// Jupyter notebook structure
35#[derive(Debug, Deserialize, Serialize)]
36struct Notebook {
37    cells: Vec<NotebookCell>,
38    metadata: Value,
39    nbformat: u32,
40    nbformat_minor: u32,
41}
42
43/// Notebook cell structure
44#[derive(Debug, Deserialize, Serialize, Clone)]
45struct NotebookCell {
46    cell_type: String,
47    source: Value, // Can be string or array of strings
48    #[serde(default, skip_serializing_if = "Vec::is_empty")]
49    outputs: Vec<Value>,
50    #[serde(default, skip_serializing_if = "Option::is_none")]
51    execution_count: Option<u32>,
52    #[serde(default, skip_serializing_if = "Option::is_none")]
53    id: Option<String>,
54    #[serde(default)]
55    metadata: Value,
56}
57
58/// NotebookEdit tool for editing Jupyter notebooks
59#[derive(Debug, Default)]
60pub struct NotebookEditTool;
61
62impl NotebookEditTool {
63    /// Create a new NotebookEdit tool
64    pub fn new() -> Self {
65        Self
66    }
67
68    /// Create a new cell with the given source and type
69    fn create_cell(source: &str, cell_type: &str, cell_id: Option<String>) -> NotebookCell {
70        let lines: Vec<String> = source.lines().map(|l| format!("{}\n", l)).collect();
71        let source_value = if lines.len() == 1 {
72            Value::String(lines[0].clone())
73        } else {
74            Value::Array(lines.into_iter().map(Value::String).collect())
75        };
76
77        NotebookCell {
78            cell_type: cell_type.to_string(),
79            source: source_value,
80            outputs: Vec::new(),
81            execution_count: if cell_type == "code" { Some(0) } else { None },
82            id: cell_id.or_else(|| Some(uuid::Uuid::new_v4().to_string())),
83            metadata: json!({}),
84        }
85    }
86}
87
88#[async_trait]
89impl Tool for NotebookEditTool {
90    fn name(&self) -> &str {
91        "NotebookEdit"
92    }
93
94    fn description(&self) -> &str {
95        "Completely replaces the contents of a specific cell in a Jupyter notebook (.ipynb file) \
96         with new source. The notebook_path parameter must be an absolute path. The cell_number \
97         is 0-indexed. Use edit_mode=insert to add a new cell at the index specified by \
98         cell_number. Use edit_mode=delete to delete the cell at the index specified by cell_number."
99    }
100
101    fn input_schema(&self) -> Value {
102        json!({
103            "type": "object",
104            "required": ["notebook_path", "new_source"],
105            "properties": {
106                "notebook_path": {
107                    "type": "string",
108                    "description": "The absolute path to the Jupyter notebook file to edit"
109                },
110                "new_source": {
111                    "type": "string",
112                    "description": "The new source for the cell"
113                },
114                "cell_number": {
115                    "type": "number",
116                    "description": "The 0-indexed cell number to edit"
117                },
118                "cell_id": {
119                    "type": "string",
120                    "description": "The ID of the cell to edit. When inserting a new cell, the new cell will be inserted after the cell with this ID."
121                },
122                "cell_type": {
123                    "type": "string",
124                    "enum": ["code", "markdown"],
125                    "description": "The type of the cell. Required when using edit_mode=insert."
126                },
127                "edit_mode": {
128                    "type": "string",
129                    "enum": ["replace", "insert", "delete"],
130                    "description": "The type of edit to make. Defaults to replace."
131                }
132            }
133        })
134    }
135
136    async fn execute(&self, input: Value, _context: &ToolContext) -> ToolResult {
137        // Parse input
138        let params: NotebookEditInput = match serde_json::from_value(input) {
139            Ok(p) => p,
140            Err(e) => return ToolResult::error(format!("Invalid input: {}", e)),
141        };
142
143        // Validate path
144        if !std::path::Path::new(&params.notebook_path)
145            .extension()
146            .is_some_and(|ext| ext.eq_ignore_ascii_case("ipynb"))
147        {
148            return ToolResult::error("File must have .ipynb extension");
149        }
150
151        // Read the existing notebook
152        let content = match fs::read_to_string(&params.notebook_path) {
153            Ok(c) => c,
154            Err(e) => {
155                return ToolResult::error(format!(
156                    "Failed to read notebook '{}': {}",
157                    params.notebook_path, e
158                ));
159            }
160        };
161
162        // Parse as notebook
163        let mut notebook: Notebook = match serde_json::from_str(&content) {
164            Ok(n) => n,
165            Err(e) => return ToolResult::error(format!("Failed to parse notebook: {}", e)),
166        };
167
168        let edit_mode = params.edit_mode.as_deref().unwrap_or("replace");
169
170        // Find the cell index
171        let cell_index = if let Some(idx) = params.cell_number {
172            idx
173        } else if let Some(ref id) = params.cell_id {
174            // Find cell by ID
175            notebook
176                .cells
177                .iter()
178                .position(|c| c.id.as_deref() == Some(id))
179                .unwrap_or(notebook.cells.len())
180        } else {
181            // Default to first cell for replace, end for insert
182            if edit_mode == "insert" {
183                notebook.cells.len()
184            } else {
185                0
186            }
187        };
188
189        let cell_type = params.cell_type.as_deref().unwrap_or("code");
190
191        match edit_mode {
192            "insert" => {
193                // Insert a new cell
194                if cell_index > notebook.cells.len() {
195                    return ToolResult::error(format!(
196                        "Cell index {} is out of bounds (notebook has {} cells)",
197                        cell_index,
198                        notebook.cells.len()
199                    ));
200                }
201
202                let new_cell = Self::create_cell(&params.new_source, cell_type, None);
203                notebook.cells.insert(cell_index, new_cell);
204
205                // Write back
206                let output_json = serde_json::to_string_pretty(&notebook)
207                    .map_err(|e| format!("Failed to serialize notebook: {}", e));
208
209                match output_json {
210                    Ok(json) => {
211                        if let Err(e) = fs::write(&params.notebook_path, json) {
212                            return ToolResult::error(format!("Failed to write notebook: {}", e));
213                        }
214                    }
215                    Err(e) => return ToolResult::error(e),
216                }
217
218                ToolResult::success(format!(
219                    "Inserted new {} cell at index {} in {}",
220                    cell_type, cell_index, params.notebook_path
221                ))
222            }
223            "delete" => {
224                // Delete a cell
225                if cell_index >= notebook.cells.len() {
226                    return ToolResult::error(format!(
227                        "Cell index {} is out of bounds (notebook has {} cells)",
228                        cell_index,
229                        notebook.cells.len()
230                    ));
231                }
232
233                let removed = notebook.cells.remove(cell_index);
234
235                // Write back
236                let output_json = serde_json::to_string_pretty(&notebook)
237                    .map_err(|e| format!("Failed to serialize notebook: {}", e));
238
239                match output_json {
240                    Ok(json) => {
241                        if let Err(e) = fs::write(&params.notebook_path, json) {
242                            return ToolResult::error(format!("Failed to write notebook: {}", e));
243                        }
244                    }
245                    Err(e) => return ToolResult::error(e),
246                }
247
248                ToolResult::success(format!(
249                    "Deleted {} cell at index {} from {}",
250                    removed.cell_type, cell_index, params.notebook_path
251                ))
252            }
253            _ => {
254                // Replace (default)
255                if cell_index >= notebook.cells.len() {
256                    return ToolResult::error(format!(
257                        "Cell index {} is out of bounds (notebook has {} cells)",
258                        cell_index,
259                        notebook.cells.len()
260                    ));
261                }
262
263                // Update the cell
264                {
265                    let cell = &mut notebook.cells[cell_index];
266
267                    // Update cell type if specified
268                    if params.cell_type.is_some() {
269                        cell.cell_type = cell_type.to_string();
270                    }
271
272                    // Update source
273                    let lines: Vec<String> = params
274                        .new_source
275                        .lines()
276                        .map(|l| format!("{}\n", l))
277                        .collect();
278                    cell.source = if lines.len() == 1 {
279                        Value::String(lines[0].clone())
280                    } else {
281                        Value::Array(lines.into_iter().map(Value::String).collect())
282                    };
283
284                    // Clear outputs for code cells
285                    if cell.cell_type == "code" {
286                        cell.outputs.clear();
287                        cell.execution_count = None;
288                    }
289                }
290
291                // Get cell type for the success message
292                let cell_type_str = notebook.cells[cell_index].cell_type.clone();
293
294                // Write back
295                let output_json = serde_json::to_string_pretty(&notebook)
296                    .map_err(|e| format!("Failed to serialize notebook: {}", e));
297
298                match output_json {
299                    Ok(json) => {
300                        if let Err(e) = fs::write(&params.notebook_path, json) {
301                            return ToolResult::error(format!("Failed to write notebook: {}", e));
302                        }
303                    }
304                    Err(e) => return ToolResult::error(e),
305                }
306
307                ToolResult::success(format!(
308                    "Replaced cell {} ({}) in {}",
309                    cell_index, cell_type_str, params.notebook_path
310                ))
311            }
312        }
313    }
314}
315
316#[cfg(test)]
317mod tests {
318    use super::*;
319    use std::io::Write as IoWrite;
320    use tempfile::TempDir;
321
322    fn sample_notebook() -> &'static str {
323        r##"{
324            "cells": [
325                {
326                    "cell_type": "markdown",
327                    "id": "cell-1",
328                    "metadata": {},
329                    "source": ["# Test Notebook\n"]
330                },
331                {
332                    "cell_type": "code",
333                    "execution_count": 1,
334                    "id": "cell-2",
335                    "metadata": {},
336                    "source": ["print('Hello')"],
337                    "outputs": []
338                }
339            ],
340            "metadata": {},
341            "nbformat": 4,
342            "nbformat_minor": 5
343        }"##
344    }
345
346    #[test]
347    fn test_notebook_edit_properties() {
348        let tool = NotebookEditTool::new();
349        assert_eq!(tool.name(), "NotebookEdit");
350        assert!(tool.description().contains("Jupyter"));
351        assert!(tool.description().contains("cell"));
352    }
353
354    #[test]
355    fn test_notebook_edit_input_schema() {
356        let tool = NotebookEditTool::new();
357        let schema = tool.input_schema();
358
359        assert_eq!(schema["type"], "object");
360        assert!(schema["properties"]["notebook_path"].is_object());
361        assert!(schema["properties"]["new_source"].is_object());
362        assert!(schema["properties"]["cell_number"].is_object());
363        assert!(schema["properties"]["edit_mode"].is_object());
364    }
365
366    #[tokio::test]
367    async fn test_notebook_edit_replace() {
368        let temp_dir = TempDir::new().unwrap();
369        let notebook_path = temp_dir.path().join("test.ipynb");
370
371        let mut file = fs::File::create(&notebook_path).unwrap();
372        write!(file, "{}", sample_notebook()).unwrap();
373
374        let tool = NotebookEditTool::new();
375        let context = ToolContext::new("test-session", temp_dir.path());
376
377        let result = tool
378            .execute(
379                json!({
380                    "notebook_path": notebook_path.to_str().unwrap(),
381                    "new_source": "# Updated Title",
382                    "cell_number": 0
383                }),
384                &context,
385            )
386            .await;
387
388        assert!(!result.is_error);
389        assert!(result.content.contains("Replaced"));
390
391        // Verify the change
392        let content = fs::read_to_string(&notebook_path).unwrap();
393        assert!(content.contains("Updated Title"));
394    }
395
396    #[tokio::test]
397    async fn test_notebook_edit_insert() {
398        let temp_dir = TempDir::new().unwrap();
399        let notebook_path = temp_dir.path().join("test.ipynb");
400
401        let mut file = fs::File::create(&notebook_path).unwrap();
402        write!(file, "{}", sample_notebook()).unwrap();
403
404        let tool = NotebookEditTool::new();
405        let context = ToolContext::new("test-session", temp_dir.path());
406
407        let result = tool
408            .execute(
409                json!({
410                    "notebook_path": notebook_path.to_str().unwrap(),
411                    "new_source": "# New Cell",
412                    "cell_number": 1,
413                    "cell_type": "markdown",
414                    "edit_mode": "insert"
415                }),
416                &context,
417            )
418            .await;
419
420        assert!(!result.is_error);
421        assert!(result.content.contains("Inserted"));
422
423        // Verify the notebook now has 3 cells
424        let content = fs::read_to_string(&notebook_path).unwrap();
425        let notebook: Notebook = serde_json::from_str(&content).unwrap();
426        assert_eq!(notebook.cells.len(), 3);
427    }
428
429    #[tokio::test]
430    async fn test_notebook_edit_delete() {
431        let temp_dir = TempDir::new().unwrap();
432        let notebook_path = temp_dir.path().join("test.ipynb");
433
434        let mut file = fs::File::create(&notebook_path).unwrap();
435        write!(file, "{}", sample_notebook()).unwrap();
436
437        let tool = NotebookEditTool::new();
438        let context = ToolContext::new("test-session", temp_dir.path());
439
440        let result = tool
441            .execute(
442                json!({
443                    "notebook_path": notebook_path.to_str().unwrap(),
444                    "new_source": "",
445                    "cell_number": 0,
446                    "edit_mode": "delete"
447                }),
448                &context,
449            )
450            .await;
451
452        assert!(!result.is_error);
453        assert!(result.content.contains("Deleted"));
454
455        // Verify the notebook now has 1 cell
456        let content = fs::read_to_string(&notebook_path).unwrap();
457        let notebook: Notebook = serde_json::from_str(&content).unwrap();
458        assert_eq!(notebook.cells.len(), 1);
459    }
460
461    #[tokio::test]
462    async fn test_notebook_edit_invalid_index() {
463        let temp_dir = TempDir::new().unwrap();
464        let notebook_path = temp_dir.path().join("test.ipynb");
465
466        let mut file = fs::File::create(&notebook_path).unwrap();
467        write!(file, "{}", sample_notebook()).unwrap();
468
469        let tool = NotebookEditTool::new();
470        let context = ToolContext::new("test-session", temp_dir.path());
471
472        let result = tool
473            .execute(
474                json!({
475                    "notebook_path": notebook_path.to_str().unwrap(),
476                    "new_source": "test",
477                    "cell_number": 99
478                }),
479                &context,
480            )
481            .await;
482
483        assert!(result.is_error);
484        assert!(result.content.contains("out of bounds"));
485    }
486}