claude_code_acp/mcp/tools/
notebook_read.rs

1//! NotebookRead tool for reading Jupyter notebooks
2//!
3//! Reads Jupyter notebook (.ipynb) files and returns all cells with their outputs.
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 NotebookRead
14#[derive(Debug, Deserialize)]
15struct NotebookReadInput {
16    /// The absolute path to the notebook file
17    notebook_path: String,
18}
19
20/// Jupyter notebook structure
21#[derive(Debug, Deserialize)]
22struct Notebook {
23    cells: Vec<NotebookCell>,
24    #[serde(default)]
25    #[allow(dead_code)]
26    metadata: Value,
27    #[serde(default)]
28    nbformat: u32,
29    #[serde(default)]
30    nbformat_minor: u32,
31}
32
33/// Notebook cell structure
34#[derive(Debug, Deserialize, Serialize)]
35struct NotebookCell {
36    cell_type: String,
37    source: CellSource,
38    #[serde(default)]
39    outputs: Vec<CellOutput>,
40    #[serde(default)]
41    execution_count: Option<u32>,
42    #[serde(default)]
43    id: Option<String>,
44    #[serde(default)]
45    metadata: Value,
46}
47
48/// Cell source can be a string or array of strings
49#[derive(Debug, Deserialize, Serialize)]
50#[serde(untagged)]
51enum CellSource {
52    String(String),
53    Lines(Vec<String>),
54}
55
56impl CellSource {
57    fn as_string(&self) -> String {
58        match self {
59            CellSource::String(s) => s.clone(),
60            CellSource::Lines(lines) => lines.join(""),
61        }
62    }
63}
64
65/// Cell output structure
66#[derive(Debug, Deserialize, Serialize)]
67struct CellOutput {
68    output_type: String,
69    #[serde(default)]
70    text: Option<CellSource>,
71    #[serde(default)]
72    data: Option<Value>,
73    #[serde(default)]
74    name: Option<String>,
75    #[serde(default)]
76    ename: Option<String>,
77    #[serde(default)]
78    evalue: Option<String>,
79    #[serde(default)]
80    traceback: Option<Vec<String>>,
81}
82
83/// NotebookRead tool for reading Jupyter notebooks
84#[derive(Debug, Default)]
85pub struct NotebookReadTool;
86
87impl NotebookReadTool {
88    /// Create a new NotebookRead tool
89    pub fn new() -> Self {
90        Self
91    }
92
93    /// Format a notebook for display
94    fn format_notebook(notebook: &Notebook) -> String {
95        let mut output = String::new();
96        output.push_str(&format!(
97            "Jupyter Notebook (format {}.{})\n",
98            notebook.nbformat, notebook.nbformat_minor
99        ));
100        output.push_str(&format!("Total cells: {}\n\n", notebook.cells.len()));
101
102        for (i, cell) in notebook.cells.iter().enumerate() {
103            // Cell header
104            output.push_str(&format!("--- Cell {} ({}) ---\n", i + 1, cell.cell_type));
105
106            if let Some(id) = &cell.id {
107                output.push_str(&format!("ID: {}\n", id));
108            }
109
110            if let Some(exec) = cell.execution_count {
111                output.push_str(&format!("Execution count: {}\n", exec));
112            }
113
114            output.push('\n');
115
116            // Cell source
117            let source = cell.source.as_string();
118            if cell.cell_type == "code" {
119                output.push_str("```\n");
120                output.push_str(&source);
121                if !source.ends_with('\n') {
122                    output.push('\n');
123                }
124                output.push_str("```\n");
125            } else {
126                output.push_str(&source);
127                if !source.ends_with('\n') {
128                    output.push('\n');
129                }
130            }
131
132            // Cell outputs
133            if !cell.outputs.is_empty() {
134                output.push_str("\nOutput:\n");
135                for cell_output in &cell.outputs {
136                    match cell_output.output_type.as_str() {
137                        "stream" => {
138                            if let Some(text) = &cell_output.text {
139                                output.push_str(&text.as_string());
140                            }
141                        }
142                        "execute_result" | "display_data" => {
143                            if let Some(data) = &cell_output.data {
144                                if let Some(text) = data.get("text/plain") {
145                                    if let Some(lines) = text.as_array() {
146                                        for line in lines {
147                                            if let Some(s) = line.as_str() {
148                                                output.push_str(s);
149                                            }
150                                        }
151                                    } else if let Some(s) = text.as_str() {
152                                        output.push_str(s);
153                                    }
154                                }
155                            }
156                        }
157                        "error" => {
158                            if let Some(ename) = &cell_output.ename {
159                                output.push_str(&format!("Error: {} ", ename));
160                            }
161                            if let Some(evalue) = &cell_output.evalue {
162                                output.push_str(evalue);
163                            }
164                            output.push('\n');
165                            if let Some(traceback) = &cell_output.traceback {
166                                for line in traceback {
167                                    // Strip ANSI escape codes
168                                    let clean_line = strip_ansi_codes(line);
169                                    output.push_str(&clean_line);
170                                    output.push('\n');
171                                }
172                            }
173                        }
174                        _ => {}
175                    }
176                }
177            }
178
179            output.push('\n');
180        }
181
182        output
183    }
184}
185
186/// Strip ANSI escape codes from a string
187fn strip_ansi_codes(s: &str) -> String {
188    let mut result = String::new();
189    let mut chars = s.chars().peekable();
190
191    while let Some(c) = chars.next() {
192        if c == '\x1b' {
193            // Skip escape sequence
194            if chars.peek() == Some(&'[') {
195                chars.next();
196                while let Some(&next) = chars.peek() {
197                    chars.next();
198                    if next.is_ascii_alphabetic() {
199                        break;
200                    }
201                }
202            }
203        } else {
204            result.push(c);
205        }
206    }
207
208    result
209}
210
211#[async_trait]
212impl Tool for NotebookReadTool {
213    fn name(&self) -> &str {
214        "NotebookRead"
215    }
216
217    fn description(&self) -> &str {
218        "Reads Jupyter notebooks (.ipynb files) and returns all cells with their outputs, \
219         combining code, text, and visualizations. The notebook_path parameter must be \
220         an absolute path, not a relative path."
221    }
222
223    fn input_schema(&self) -> Value {
224        json!({
225            "type": "object",
226            "required": ["notebook_path"],
227            "properties": {
228                "notebook_path": {
229                    "type": "string",
230                    "description": "The absolute path to the Jupyter notebook file to read"
231                }
232            }
233        })
234    }
235
236    async fn execute(&self, input: Value, _context: &ToolContext) -> ToolResult {
237        // Parse input
238        let params: NotebookReadInput = match serde_json::from_value(input) {
239            Ok(p) => p,
240            Err(e) => return ToolResult::error(format!("Invalid input: {}", e)),
241        };
242
243        // Validate path
244        if !std::path::Path::new(&params.notebook_path)
245            .extension()
246            .is_some_and(|ext| ext.eq_ignore_ascii_case("ipynb"))
247        {
248            return ToolResult::error("File must have .ipynb extension");
249        }
250
251        // Read the file
252        let content = match fs::read_to_string(&params.notebook_path) {
253            Ok(c) => c,
254            Err(e) => {
255                return ToolResult::error(format!(
256                    "Failed to read notebook '{}': {}",
257                    params.notebook_path, e
258                ));
259            }
260        };
261
262        // Parse as notebook
263        let notebook: Notebook = match serde_json::from_str(&content) {
264            Ok(n) => n,
265            Err(e) => return ToolResult::error(format!("Failed to parse notebook: {}", e)),
266        };
267
268        // Format for display
269        let output = Self::format_notebook(&notebook);
270
271        ToolResult::success(output).with_metadata(json!({
272            "path": params.notebook_path,
273            "cell_count": notebook.cells.len(),
274            "nbformat": notebook.nbformat,
275            "nbformat_minor": notebook.nbformat_minor
276        }))
277    }
278}
279
280#[cfg(test)]
281mod tests {
282    use super::*;
283    use std::io::Write;
284    use tempfile::TempDir;
285
286    fn sample_notebook() -> &'static str {
287        r##"{
288            "cells": [
289                {
290                    "cell_type": "markdown",
291                    "id": "cell-1",
292                    "metadata": {},
293                    "source": ["# Test Notebook\n", "This is a test."]
294                },
295                {
296                    "cell_type": "code",
297                    "execution_count": 1,
298                    "id": "cell-2",
299                    "metadata": {},
300                    "source": "print('Hello, World!')",
301                    "outputs": [
302                        {
303                            "output_type": "stream",
304                            "name": "stdout",
305                            "text": ["Hello, World!\n"]
306                        }
307                    ]
308                }
309            ],
310            "metadata": {
311                "kernelspec": {
312                    "display_name": "Python 3",
313                    "language": "python",
314                    "name": "python3"
315                }
316            },
317            "nbformat": 4,
318            "nbformat_minor": 5
319        }"##
320    }
321
322    #[test]
323    fn test_notebook_read_properties() {
324        let tool = NotebookReadTool::new();
325        assert_eq!(tool.name(), "NotebookRead");
326        assert!(tool.description().contains("Jupyter"));
327        assert!(tool.description().contains(".ipynb"));
328    }
329
330    #[test]
331    fn test_notebook_read_input_schema() {
332        let tool = NotebookReadTool::new();
333        let schema = tool.input_schema();
334
335        assert_eq!(schema["type"], "object");
336        assert!(schema["properties"]["notebook_path"].is_object());
337        assert!(
338            schema["required"]
339                .as_array()
340                .unwrap()
341                .contains(&json!("notebook_path"))
342        );
343    }
344
345    #[tokio::test]
346    async fn test_notebook_read_execute() {
347        let temp_dir = TempDir::new().unwrap();
348        let notebook_path = temp_dir.path().join("test.ipynb");
349
350        let mut file = fs::File::create(&notebook_path).unwrap();
351        write!(file, "{}", sample_notebook()).unwrap();
352
353        let tool = NotebookReadTool::new();
354        let context = ToolContext::new("test-session", temp_dir.path());
355
356        let result = tool
357            .execute(
358                json!({"notebook_path": notebook_path.to_str().unwrap()}),
359                &context,
360            )
361            .await;
362
363        assert!(!result.is_error);
364        assert!(result.content.contains("Test Notebook"));
365        assert!(result.content.contains("Hello, World!"));
366        assert!(result.content.contains("markdown"));
367        assert!(result.content.contains("code"));
368    }
369
370    #[tokio::test]
371    async fn test_notebook_read_invalid_extension() {
372        let temp_dir = TempDir::new().unwrap();
373        let tool = NotebookReadTool::new();
374        let context = ToolContext::new("test-session", temp_dir.path());
375
376        let result = tool
377            .execute(json!({"notebook_path": "/tmp/test.py"}), &context)
378            .await;
379
380        assert!(result.is_error);
381        assert!(result.content.contains(".ipynb"));
382    }
383
384    #[tokio::test]
385    async fn test_notebook_read_nonexistent() {
386        let temp_dir = TempDir::new().unwrap();
387        let tool = NotebookReadTool::new();
388        let context = ToolContext::new("test-session", temp_dir.path());
389
390        let result = tool
391            .execute(
392                json!({"notebook_path": "/tmp/nonexistent_notebook.ipynb"}),
393                &context,
394            )
395            .await;
396
397        assert!(result.is_error);
398        assert!(result.content.contains("Failed to read"));
399    }
400
401    #[test]
402    fn test_strip_ansi_codes() {
403        let input = "\x1b[31mRed text\x1b[0m normal";
404        let output = strip_ansi_codes(input);
405        assert_eq!(output, "Red text normal");
406    }
407}