Skip to content

Commit ae57eb4

Browse files
committed
chore(release): Bump version to 0.2.4 and improved test coverage.
- Added `notebook_get_outline` for analyzing notebook structure and extracting cell outlines. - Introduced `notebook_search` for case-insensitive searching within notebook cells. - Enhanced test coverage with dedicated tests for error paths and edge cases, improving overall code coverage to 84%. - Updated version and clarified Python minimum version of 3.10
1 parent b6b8620 commit ae57eb4

File tree

11 files changed

+1281
-131
lines changed

11 files changed

+1281
-131
lines changed

CHANGELOG.md

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,25 @@ All notable changes to this project will be documented in this file.
55
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
66
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
77

8+
## [0.2.4] - 2025-04-26
9+
10+
### Added
11+
12+
- Added tools to get an outline and search a notebook, so specific cells can be targeted for read/edit.
13+
- The `notebook_get_outline` method analyzes a Jupyter notebook's structure, extracting cell types, line counts, and outlines for code and markdown cells.
14+
- The `notebook_search` method allows for case-insensitive searching within notebook cells, returning matches with context snippets.
15+
- Added dedicated tests for error paths and edge cases in the NotebookTools module, focusing on improving code coverage.
16+
- Added tests for handling issues with `diagnose_imports`, including subprocess errors and malformed JSON.
17+
- Added validation tests for notebooks addressing invalid JSON and non-notebook files.
18+
- Added tests for outline extraction with invalid code syntax.
19+
- Added tests for empty search queries and behavior of large file truncation.
20+
- Added edge case tests for export functionality and cell transformations.
21+
22+
### Changed
23+
- Improved overall code coverage to 84%.
24+
- Improved `tools.py` coverage to 80%.
25+
- Achieved 100% coverage for `notebook_ops.py`.
26+
827
## [0.2.3] - 2025-04-20
928

1029
### Added

README.md

Lines changed: 10 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,10 @@ This MCP server uses the `nbformat` library to safely manipulate notebook struct
2626

2727
## Latest Version
2828

29-
**Current Version:** `0.2.3` - See the [CHANGELOG.md](CHANGELOG.md) for details on recent changes.
29+
**Current Version:** `0.2.4` - See the [CHANGELOG.md](CHANGELOG.md) for details on recent changes. Two new tools were added that make it easier to work with large notebooks:
30+
31+
- `notebook_get_outline` analyzes a Jupyter notebook's structure, extracting cell types, line counts, and headings/functions/classes. It then returns an outline the agent can reference.
32+
- `notebook_search` enables case-insensitive searching within notebook cells. The results show which cell the match was found, and include contextual snippets. This helps the agent know which cell to read/edit when asked to modify something.
3033

3134
## Features
3235

@@ -56,14 +59,16 @@ Exposes the following MCP tools (registered under the `notebook_mcp` server):
5659
* `notebook_validate`: Validates the notebook structure against the `nbformat` schema.
5760
* `notebook_get_info`: Retrieves general information (cell count, metadata, kernel, language info).
5861
* `notebook_export`: Exports the notebook to another format (e.g., python, html) using nbconvert. **Note:** See External Dependencies below for requirements needed for certain export formats like PDF.
62+
* `notebook_get_outline`: Produces an outline showing cell numbers with major headings/functions and line counts to make it easier for the agent to navigate a large notebook.
63+
* `notebook_search`: Searches cells for a keyword, showing which cell matches were found with contextual snippets. This helps the agent know which cell to read/edit when asked to modify something.
5964

6065
## Requirements
6166

6267
This project has both Python package dependencies and potentially external system dependencies for full functionality.
6368

6469
### Python Dependencies
6570

66-
* **Python Version:** 3.9+
71+
* **Python Version:** 3.10+
6772
* **Core:** `mcp>=0.1.0`, `nbformat>=5.0`, `nbconvert>=6.0`, `ipython`, `jupyter_core`. These are installed automatically when you install `cursor-notebook-mcp`.
6873
* **Optional - SSE Transport:** `uvicorn>=0.20.0`, `starlette>=0.25.0`. Needed only if using the SSE transport mode. Install via `pip install cursor-notebook-mcp[sse]`.
6974
* **Optional - Development/Testing:** `pytest>=7.0`, `pytest-asyncio>=0.18`, `pytest-cov`, `coveralls`. Install via `pip install -e ".[dev]"` from source checkout.
@@ -230,19 +235,19 @@ When using `sse`, you must run the server process manually first (see "Running t
230235
For smooth collaboration with the AI agent on Jupyter Notebooks, you might want to add rules like these to your Cursor configuration. Go to Cursor Settings > Rules and add them in either User Roles or Project Rules. This ensures that Cursor's AI features will consistently follow these best practices when working with Jupyter notebooks.
231236

232237
```markdown
233-
### Jupyter Notebook Rules for Cursor (Using notebook_mcp):
238+
### Jupyter Notebook Rules (Using notebook_mcp):
234239
235240
1. **Tool Usage:**
236241
* Always use the tools provided by the `notebook_mcp` server for operations on Jupyter Notebook (`.ipynb`) files.
237242
* Avoid using the standard `edit_file` tool on `.ipynb` files, as this can corrupt the notebook structure.
238243
239244
2. **Investigation Strategy:**
240245
* A comprehensive suite of tools is available to inspect notebooks. If the user mentions an issue, a specific cell, or asks for a modification, first attempt to gather context independently.
241-
* Use the available tools (`notebook_read`, `notebook_read_cell`, `notebook_get_info`, `notebook_read_metadata`, `notebook_read_cell_output`, `notebook_validate`) to examine the notebook structure, content, metadata, and outputs to locate the relevant context or identify the problem.
246+
* Use the available tools (`notebook_get_outline`, `notebook_search`, `notebook_read`, `notebook_read_cell`, `notebook_get_info`, `notebook_read_metadata`, `notebook_read_cell_output`, `notebook_validate`) to examine the notebook structure, content, metadata, and outputs to locate the relevant context or identify the problem.
242247
* Ask the user for clarification only if the necessary information cannot be determined after using the investigation tools.
243248
244249
3. **Available Tools:**
245-
* Be aware of the different categories of tools: File operations (`create`, `delete`, `rename`), Notebook/Cell Reading (`read`, `read_cell`, `get_cell_count`, `get_info`), Cell Manipulation (`add_cell`, `edit_cell`, `delete_cell`, `move_cell`, `change_cell_type`, `duplicate_cell`, `split_cell`, `merge_cells`), Metadata (`read/edit_metadata`, `read/edit_cell_metadata`), Outputs (`read_cell_output`, `clear_cell_outputs`, `clear_all_outputs`), and Utility (`validate`, `export`, `diagnose_imports`).
250+
* Be aware of the different categories of tools: Situation awareness (`notebook_get_outline`), Search (`notebook_search`), File operations (`create`, `delete`, `rename`), Notebook/Cell Reading (`read`, `read_cell`, `get_cell_count`, `get_info`), Cell Manipulation (`add_cell`, `edit_cell`, `delete_cell`, `move_cell`, `change_cell_type`, `duplicate_cell`, `split_cell`, `merge_cells`), Metadata (`read/edit_metadata`, `read/edit_cell_metadata`), Outputs (`read_cell_output`, `clear_cell_outputs`, `clear_all_outputs`), and Utility (`validate`, `export`, `diagnose_imports`).
246251
247252
4. **Math Notation:** For LaTeX in Markdown cells, use `$ ... $` for inline math and `$$ ... $$` for display math. Avoid `\( ... \)` and `\[ ... \]`.
248253

cursor_notebook_mcp/__init__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,6 @@
55
AI agents within Cursor to interact with Jupyter Notebook (.ipynb) files.
66
"""
77

8-
__version__ = "0.2.3"
8+
__version__ = "0.2.4"
99

1010
# Components are imported directly where needed (e.g., in notebook_mcp_server.py)

cursor_notebook_mcp/server.py

Lines changed: 12 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -231,10 +231,10 @@ def main():
231231
config = ServerConfig(args)
232232
except (SystemExit, ValueError) as e:
233233
print(f"ERROR: Configuration failed: {e}", file=sys.stderr)
234-
sys.exit(e.code if isinstance(e, SystemExit) else 1)
234+
return sys.exit(e.code if isinstance(e, SystemExit) else 1)
235235
except Exception as e:
236236
print(f"CRITICAL: Failed during argument parsing or validation: {e}", file=sys.stderr)
237-
sys.exit(1)
237+
return sys.exit(1)
238238

239239
try:
240240
setup_logging(config.log_dir, config.log_level)
@@ -243,7 +243,11 @@ def main():
243243
print(f"CRITICAL: Failed during logging setup: {e}", file=sys.stderr)
244244
logging.basicConfig(level=logging.ERROR)
245245
logging.exception("Logging setup failed critically")
246-
sys.exit(1)
246+
return sys.exit(1)
247+
248+
# Set a default logger if it's still None to prevent NoneType errors
249+
if logger is None:
250+
logger = logging.getLogger(__name__)
247251

248252
logger.info(f"Notebook MCP Server starting (Version: {config.version}) - via {__name__}")
249253
logger.info(f"Allowed Roots: {config.allowed_roots}")
@@ -258,7 +262,7 @@ def main():
258262
logger.info("Notebook tools initialized and registered.")
259263
except Exception as e:
260264
logger.exception("Failed to initialize MCP server or tools.")
261-
sys.exit(1)
265+
return sys.exit(1)
262266

263267
try:
264268
if config.transport == 'stdio':
@@ -272,19 +276,19 @@ def main():
272276
run_sse_server(mcp_server, config)
273277
except ImportError as e:
274278
logger.error(f"Failed to start SSE server due to missing packages: {e}")
275-
sys.exit(1)
279+
return sys.exit(1)
276280
except Exception as e:
277281
logger.exception("Failed to start or run SSE server.")
278-
sys.exit(1)
282+
return sys.exit(1)
279283
logger.info("Server finished (SSE).")
280284

281285
else:
282286
logger.error(f"Internal Error: Invalid transport specified: {config.transport}")
283-
sys.exit(1)
287+
return sys.exit(1)
284288

285289
except Exception as e:
286290
logger.exception("Server encountered a fatal error during execution.")
287-
sys.exit(1)
291+
return sys.exit(1)
288292

289293
# If this script is run directly (e.g., python -m cursor_notebook_mcp.server)
290294
if __name__ == "__main__":

cursor_notebook_mcp/sse_transport.py

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -48,7 +48,6 @@ async def handle_sse(request):
4848
log_prefix = f"[SSE {client_host}:{client_port}]"
4949
logger.info(f"{log_prefix} New SSE connection request from {client_host}:{client_port}")
5050

51-
# transport = SseServerTransport(endpoint=mcp_server) # Old incorrect line
5251
try:
5352
logger.debug(f"{log_prefix} Setting up SSE connection")
5453
# connect_sse handles the SSE handshake and provides streams
@@ -66,8 +65,8 @@ async def handle_sse(request):
6665

6766
# Call run() on the underlying server instance, passing streams and options
6867
await underlying_server.run(
69-
read_stream=streams[0],
70-
write_stream=streams[1],
68+
read_stream=read_stream,
69+
write_stream=write_stream,
7170
initialization_options=init_options
7271
)
7372
logger.info(f"{log_prefix} MCP session finished.")

cursor_notebook_mcp/tools.py

Lines changed: 4 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -17,9 +17,6 @@
1717
import nbformat
1818
from nbformat import NotebookNode
1919

20-
# Assuming ServerConfig is defined elsewhere (e.g., in the main server script)
21-
# from notebook_mcp_server import ServerConfig
22-
# Assuming notebook_ops functions are available
2320
from . import notebook_ops
2421

2522
logger = logging.getLogger(__name__)
@@ -69,7 +66,7 @@ def _register_tools(self):
6966
self.notebook_clear_cell_outputs,
7067
self.notebook_clear_all_outputs,
7168
self.notebook_move_cell,
72-
self.diagnose_imports, # Keep diagnostic tool
69+
self.diagnose_imports,
7370
self.notebook_validate,
7471
self.notebook_get_info,
7572
self.notebook_read_cell_output,
@@ -1156,17 +1153,15 @@ def _extract_markdown_outline(self, source: str) -> List[str]:
11561153
for line in lines:
11571154
stripped_line = line.strip()
11581155
# Check for Markdown heading first
1159-
md_match = re.match(r'^(#+)\s+(.*)', stripped_line)
1156+
md_match = re.match(r'^(#+)\s*(.*)', stripped_line) # Allow zero spaces after #
11601157
if md_match:
11611158
level = len(md_match.group(1))
11621159
heading_text = md_match.group(2).strip()
11631160
if heading_text:
11641161
headings.append(f"H{level}: {heading_text}")
11651162
else:
1166-
# If not Markdown, check for HTML heading
1167-
# We search the stripped_line instead of match to find tag anywhere
1168-
html_match = html_heading_re.search(stripped_line)
1169-
if html_match:
1163+
# If not Markdown, check for HTML headings using finditer for multiple matches per line
1164+
for html_match in html_heading_re.finditer(stripped_line):
11701165
level = int(html_match.group(1))
11711166
# Basic cleanup: remove potential inner tags for outline brevity
11721167
heading_text = re.sub(r'<.*?>', '', html_match.group(2)).strip()

pyproject.toml

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
44

55
[project]
66
name = "cursor-notebook-mcp"
7-
version = "0.2.3"
7+
version = "0.2.4"
88
authors = [
99
{ name="Jim Beno", email="jim@jimbeno.net" },
1010
]
@@ -21,7 +21,6 @@ classifiers = [
2121
"License :: OSI Approved :: MIT License",
2222
"Operating System :: OS Independent",
2323
"Programming Language :: Python :: 3",
24-
"Programming Language :: Python :: 3.9",
2524
"Programming Language :: Python :: 3.10",
2625
"Programming Language :: Python :: 3.11",
2726
"Programming Language :: Python :: 3.12",

tests/test_notebook_ops.py

Lines changed: 127 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -126,4 +126,130 @@ def test_setup_logging_filehandler_error(mock_stderr, mock_filehandler, mock_mak
126126
assert "Could not set up file logging" in mock_stderr.getvalue()
127127
assert "Cannot open log file for writing" in mock_stderr.getvalue()
128128

129-
# TODO: Add more tests here
129+
# --- Additional Tests for notebook_ops.py Coverage ---
130+
131+
def test_is_path_allowed_empty_roots():
132+
"""Test is_path_allowed behavior with empty allowed_roots list."""
133+
test_path = "/some/path"
134+
allowed_roots = []
135+
136+
# Function should return False if no roots are configured
137+
assert not notebook_ops.is_path_allowed(test_path, allowed_roots)
138+
139+
def test_is_path_allowed_path_resolve_error():
140+
"""Test is_path_allowed behavior when path resolution fails."""
141+
with mock.patch('os.path.realpath', side_effect=Exception("Path resolution error")):
142+
test_path = "/some/path"
143+
allowed_roots = ["/allowed/root"]
144+
145+
# Function should return False if path resolution fails
146+
assert not notebook_ops.is_path_allowed(test_path, allowed_roots)
147+
148+
def test_is_path_allowed_root_resolve_error():
149+
"""Test is_path_allowed behavior when allowed root resolution fails."""
150+
# First call returns successfully, second call raises exception
151+
def mock_realpath(path):
152+
if path == "/some/path":
153+
return "/some/path"
154+
raise Exception("Root resolution error")
155+
156+
with mock.patch('os.path.realpath', side_effect=mock_realpath):
157+
test_path = "/some/path"
158+
allowed_roots = ["/allowed/root"]
159+
160+
# Function should continue to the next root and return False
161+
assert not notebook_ops.is_path_allowed(test_path, allowed_roots)
162+
163+
@pytest.mark.asyncio
164+
async def test_read_notebook_non_absolute_path():
165+
"""Test read_notebook rejects non-absolute paths."""
166+
non_abs_path = "relative/path/notebook.ipynb"
167+
allowed_roots = ["/valid/root"]
168+
169+
with pytest.raises(ValueError, match="Invalid notebook path: Only absolute paths are allowed"):
170+
await notebook_ops.read_notebook(non_abs_path, allowed_roots)
171+
172+
@pytest.mark.asyncio
173+
async def test_read_notebook_outside_allowed_roots(tmp_path):
174+
"""Test read_notebook rejects paths outside allowed roots."""
175+
dummy_path = "/some/path/outside/notebook.ipynb"
176+
allowed_roots = [str(tmp_path)]
177+
178+
with pytest.raises(PermissionError, match="Access denied: Path .* is outside the allowed workspace roots"):
179+
await notebook_ops.read_notebook(dummy_path, allowed_roots)
180+
181+
@pytest.mark.asyncio
182+
async def test_read_notebook_invalid_extension(tmp_path):
183+
"""Test read_notebook rejects non-notebook files."""
184+
dummy_path = tmp_path / "not_a_notebook.txt"
185+
dummy_path.touch()
186+
allowed_roots = [str(tmp_path)]
187+
188+
with pytest.raises(ValueError, match="Invalid file type: .* must point to a .ipynb file"):
189+
await notebook_ops.read_notebook(str(dummy_path), allowed_roots)
190+
191+
@pytest.mark.asyncio
192+
async def test_write_notebook_non_absolute_path():
193+
"""Test write_notebook rejects non-absolute paths."""
194+
non_abs_path = "relative/path/notebook.ipynb"
195+
allowed_roots = ["/valid/root"]
196+
nb = nbformat.v4.new_notebook()
197+
198+
with pytest.raises(ValueError, match="Invalid notebook path: Only absolute paths are allowed for writing"):
199+
await notebook_ops.write_notebook(non_abs_path, nb, allowed_roots)
200+
201+
@pytest.mark.asyncio
202+
async def test_write_notebook_outside_allowed_roots(tmp_path):
203+
"""Test write_notebook rejects paths outside allowed roots."""
204+
dummy_path = "/some/path/outside/notebook.ipynb"
205+
allowed_roots = [str(tmp_path)]
206+
nb = nbformat.v4.new_notebook()
207+
208+
with pytest.raises(PermissionError, match="Access denied: Path .* is outside the allowed workspace roots"):
209+
await notebook_ops.write_notebook(dummy_path, nb, allowed_roots)
210+
211+
@pytest.mark.asyncio
212+
async def test_write_notebook_invalid_extension(tmp_path):
213+
"""Test write_notebook rejects non-notebook files."""
214+
dummy_path = str(tmp_path / "not_a_notebook.txt")
215+
allowed_roots = [str(tmp_path)]
216+
nb = nbformat.v4.new_notebook()
217+
218+
with pytest.raises(ValueError, match="Invalid file type for writing: .* must point to a .ipynb file"):
219+
await notebook_ops.write_notebook(dummy_path, nb, allowed_roots)
220+
221+
@pytest.mark.asyncio
222+
async def test_write_notebook_create_parent_dir(tmp_path):
223+
"""Test write_notebook creates parent directory."""
224+
parent_dir = tmp_path / "nested" / "dir"
225+
dummy_path = parent_dir / "notebook.ipynb"
226+
allowed_roots = [str(tmp_path)]
227+
nb = nbformat.v4.new_notebook()
228+
229+
# Ensure the parent directory doesn't exist yet
230+
assert not parent_dir.exists()
231+
232+
# Mock the directory creation and nbformat.write to verify they're called
233+
with mock.patch('os.path.isdir', return_value=False), \
234+
mock.patch('os.makedirs') as mock_makedirs, \
235+
mock.patch('nbformat.write') as mock_write:
236+
237+
await notebook_ops.write_notebook(str(dummy_path), nb, allowed_roots)
238+
239+
mock_makedirs.assert_called_once_with(str(parent_dir), exist_ok=True)
240+
mock_write.assert_called_once()
241+
242+
@pytest.mark.asyncio
243+
async def test_write_notebook_parent_dir_creation_fails(tmp_path):
244+
"""Test write_notebook handles error during parent directory creation."""
245+
parent_dir = tmp_path / "nested" / "dir"
246+
dummy_path = parent_dir / "notebook.ipynb"
247+
allowed_roots = [str(tmp_path)]
248+
nb = nbformat.v4.new_notebook()
249+
250+
# Mock directory checks and creation to simulate failure
251+
with mock.patch('os.path.isdir', return_value=False), \
252+
mock.patch('os.makedirs', side_effect=OSError("Failed to create directory")):
253+
254+
with pytest.raises(IOError, match="Could not create directory for notebook .* Failed to create directory"):
255+
await notebook_ops.write_notebook(str(dummy_path), nb, allowed_roots)

0 commit comments

Comments
 (0)