claude_code_acp/mcp/tools/
notebook_edit.rs1use 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#[derive(Debug, Deserialize)]
15struct NotebookEditInput {
16 notebook_path: String,
18 new_source: String,
20 #[serde(default)]
22 cell_number: Option<usize>,
23 #[serde(default)]
25 cell_id: Option<String>,
26 #[serde(default)]
28 cell_type: Option<String>,
29 #[serde(default)]
31 edit_mode: Option<String>,
32}
33
34#[derive(Debug, Deserialize, Serialize)]
36struct Notebook {
37 cells: Vec<NotebookCell>,
38 metadata: Value,
39 nbformat: u32,
40 nbformat_minor: u32,
41}
42
43#[derive(Debug, Deserialize, Serialize, Clone)]
45struct NotebookCell {
46 cell_type: String,
47 source: Value, #[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#[derive(Debug, Default)]
60pub struct NotebookEditTool;
61
62impl NotebookEditTool {
63 pub fn new() -> Self {
65 Self
66 }
67
68 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 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 if !std::path::Path::new(¶ms.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 let content = match fs::read_to_string(¶ms.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 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 let cell_index = if let Some(idx) = params.cell_number {
172 idx
173 } else if let Some(ref id) = params.cell_id {
174 notebook
176 .cells
177 .iter()
178 .position(|c| c.id.as_deref() == Some(id))
179 .unwrap_or(notebook.cells.len())
180 } else {
181 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 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(¶ms.new_source, cell_type, None);
203 notebook.cells.insert(cell_index, new_cell);
204
205 let output_json = serde_json::to_string_pretty(¬ebook)
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(¶ms.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 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 let output_json = serde_json::to_string_pretty(¬ebook)
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(¶ms.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 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 {
265 let cell = &mut notebook.cells[cell_index];
266
267 if params.cell_type.is_some() {
269 cell.cell_type = cell_type.to_string();
270 }
271
272 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 if cell.cell_type == "code" {
286 cell.outputs.clear();
287 cell.execution_count = None;
288 }
289 }
290
291 let cell_type_str = notebook.cells[cell_index].cell_type.clone();
293
294 let output_json = serde_json::to_string_pretty(¬ebook)
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(¶ms.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(¬ebook_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 let content = fs::read_to_string(¬ebook_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(¬ebook_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 let content = fs::read_to_string(¬ebook_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(¬ebook_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 let content = fs::read_to_string(¬ebook_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(¬ebook_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}