claude_code_acp/mcp/tools/
notebook_read.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 NotebookReadInput {
16 notebook_path: String,
18}
19
20#[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#[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#[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#[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#[derive(Debug, Default)]
85pub struct NotebookReadTool;
86
87impl NotebookReadTool {
88 pub fn new() -> Self {
90 Self
91 }
92
93 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 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 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 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 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
186fn 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 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 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 if !std::path::Path::new(¶ms.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 let content = match fs::read_to_string(¶ms.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 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 let output = Self::format_notebook(¬ebook);
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(¬ebook_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}