Skip to content

Commit 2358010

Browse files
Fix: sync error (#148)
* add thread lock and transaction to fix sync error * simple api
1 parent bbbd285 commit 2358010

File tree

5 files changed

+222
-137
lines changed

5 files changed

+222
-137
lines changed

jupyter_mcp_server/__version__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,4 +4,4 @@
44

55
"""Jupyter MCP Server."""
66

7-
__version__ = "0.18.0"
7+
__version__ = "0.18.1"

jupyter_mcp_server/tools/delete_cell_tool.py

Lines changed: 10 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -10,12 +10,12 @@
1010
from jupyter_server_client import JupyterServerClient
1111
from jupyter_mcp_server.tools._base import BaseTool, ServerMode
1212
from jupyter_mcp_server.notebook_manager import NotebookManager
13-
from jupyter_mcp_server.utils import get_current_notebook_context, get_jupyter_ydoc, clean_notebook_outputs
13+
from jupyter_mcp_server.utils import get_current_notebook_context, get_notebook_model, clean_notebook_outputs
1414

1515

1616
class DeleteCellTool(BaseTool):
1717
"""Tool to delete a specific cell from a notebook."""
18-
18+
1919
def _get_cell_source(self, cell: Any) -> str:
2020
"""Get the cell source from the cell"""
2121
cell_source = cell.get("source", "")
@@ -38,35 +38,21 @@ async def _delete_cell_ydoc(
3838
cell_index: Index of cell to delete
3939
4040
Returns:
41-
Success message
41+
NotebookNode
4242
"""
43-
# Get file_id from file_id_manager
44-
file_id_manager = serverapp.web_app.settings.get("file_id_manager")
45-
if file_id_manager is None:
46-
raise RuntimeError("file_id_manager not available in serverapp")
47-
48-
file_id = file_id_manager.get_id(notebook_path)
49-
50-
# Try to get YDoc
51-
ydoc = await get_jupyter_ydoc(serverapp, file_id)
52-
53-
if ydoc:
54-
if cell_index >= len(ydoc.ycells):
43+
nb = await get_notebook_model(serverapp, notebook_path)
44+
if nb:
45+
if cell_index >= len(nb):
5546
raise ValueError(
56-
f"Cell index {cell_index} is out of range. Notebook has {len(ydoc.ycells)} cells."
47+
f"Cell index {cell_index} is out of range. Notebook has {len(nb)} cells."
5748
)
5849

59-
cell = ydoc.ycells[cell_index]
60-
result = {
50+
cell = nb.delete_cell(cell_index)
51+
return {
6152
"index": cell_index,
62-
"cell_type": cell.get("cell_type", "unknown"),
53+
"cell_type": cell.cell_type,
6354
"source": self._get_cell_source(cell),
6455
}
65-
66-
# Delete the cell from YDoc
67-
del ydoc.ycells[cell_index]
68-
69-
return result
7056
else:
7157
# YDoc not available, use file operations
7258
return await self._delete_cell_file(notebook_path, cell_index)

jupyter_mcp_server/tools/insert_cell_tool.py

Lines changed: 106 additions & 76 deletions
Original file line numberDiff line numberDiff line change
@@ -10,72 +10,81 @@
1010
from jupyter_server_client import JupyterServerClient
1111
from jupyter_mcp_server.tools._base import BaseTool, ServerMode
1212
from jupyter_mcp_server.notebook_manager import NotebookManager
13-
from jupyter_mcp_server.utils import get_current_notebook_context, get_jupyter_ydoc, clean_notebook_outputs
13+
from jupyter_mcp_server.utils import get_current_notebook_context, get_notebook_model, clean_notebook_outputs
1414
from jupyter_mcp_server.models import Notebook
1515

1616

17+
1718
class InsertCellTool(BaseTool):
1819
"""Tool to insert a cell at a specified position."""
1920

21+
def _validate_cell_insertion_params(
22+
self,
23+
cell_index: int,
24+
total_cells: int,
25+
cell_type: str
26+
) -> int:
27+
"""Validate and normalize cell insertion parameters.
28+
29+
Args:
30+
cell_index: Target index for insertion (-1 for append)
31+
total_cells: Total number of cells in the notebook
32+
cell_type: Type of cell to insert
33+
34+
Returns:
35+
Normalized actual_index for insertion
36+
37+
Raises:
38+
IndexError: When cell_index is out of valid range
39+
ValueError: When cell_type is invalid
40+
"""
41+
if cell_index < -1 or cell_index > total_cells:
42+
raise IndexError(
43+
f"Index {cell_index} is outside valid range [-1, {total_cells}]. "
44+
f"Use -1 to append at end."
45+
)
46+
47+
# Normalize -1 to append position
48+
actual_index = cell_index if cell_index != -1 else total_cells
49+
return actual_index
50+
2051
async def _insert_cell_ydoc(
2152
self,
2253
serverapp: Any,
2354
notebook_path: str,
2455
cell_index: int,
2556
cell_type: Literal["code", "markdown"],
2657
cell_source: str
27-
) -> tuple[int, int]:
58+
) -> tuple[Notebook, int, int]:
2859
"""Insert cell using YDoc (collaborative editing mode).
2960
3061
Args:
3162
serverapp: Jupyter ServerApp instance
3263
notebook_path: Path to the notebook
3364
cell_index: Index to insert at (-1 for append)
34-
cell_type: Type of cell to insert
65+
cell_type: Type of cell to insert ("code", "markdown")
3566
cell_source: Source content for the cell
3667
3768
Returns:
38-
Success message with surrounding cells info
69+
Tuple of (notebook, actual_index, total_cells_after_insertion)
70+
71+
Raises:
72+
IndexError: When cell_index is out of range
3973
"""
40-
# Get file_id from file_id_manager
41-
file_id_manager = serverapp.web_app.settings.get("file_id_manager")
42-
if file_id_manager is None:
43-
raise RuntimeError("file_id_manager not available in serverapp")
74+
nb = await get_notebook_model(serverapp, notebook_path)
4475

45-
file_id = file_id_manager.get_id(notebook_path)
46-
47-
# Try to get YDoc
48-
ydoc = await get_jupyter_ydoc(serverapp, file_id)
49-
50-
if ydoc:
76+
if nb:
5177
# Notebook is open in collaborative mode, use YDoc
52-
total_cells = len(ydoc.ycells)
53-
actual_index = cell_index if cell_index != -1 else total_cells
54-
55-
if actual_index < 0 or actual_index > total_cells:
56-
raise ValueError(
57-
f"Cell index {cell_index} is out of range. Notebook has {total_cells} cells. Use -1 to append at end."
58-
)
59-
60-
# Create the cell
61-
cell = {
62-
"cell_type": cell_type,
63-
"source": "",
64-
}
65-
ycell = ydoc.create_ycell(cell)
78+
total_cells = len(nb)
6679

67-
# Insert at the specified position
68-
if actual_index >= total_cells:
69-
ydoc.ycells.append(ycell)
70-
else:
71-
ydoc.ycells.insert(actual_index, ycell)
80+
# Validate insertion parameters
81+
actual_index = self._validate_cell_insertion_params(
82+
cell_index, total_cells, cell_type
83+
)
7284

73-
# Write content to the cell collaboratively
74-
if cell_source:
75-
# Set the source directly on the ycell
76-
ycell["source"] = cell_source
85+
nb.insert_cell(actual_index, cell_source, cell_type)
7786

78-
return actual_index, len(ydoc.ycells)
87+
return Notebook(**nb.as_dict()), actual_index, len(nb)
7988
else:
8089
# YDoc not available, use file operations
8190
return await self._insert_cell_file(notebook_path, cell_index, cell_type, cell_source)
@@ -86,17 +95,21 @@ async def _insert_cell_file(
8695
cell_index: int,
8796
cell_type: Literal["code", "markdown"],
8897
cell_source: str
89-
) -> tuple[int, int]:
98+
) -> tuple[Notebook, int, int]:
9099
"""Insert cell using file operations (non-collaborative mode).
91-
100+
92101
Args:
93102
notebook_path: Absolute path to the notebook
94103
cell_index: Index to insert at (-1 for append)
95-
cell_type: Type of cell to insert
104+
cell_type: Type of cell to insert ("code", "markdown")
96105
cell_source: Source content for the cell
97106
98107
Returns:
99-
Success message with surrounding cells info
108+
Tuple of (notebook, actual_index, total_cells_after_insertion)
109+
110+
Raises:
111+
IndexError: When cell_index is out of range
112+
ValueError: When cell_type is invalid
100113
"""
101114
# Read notebook file
102115
with open(notebook_path, "r", encoding="utf-8") as f:
@@ -107,55 +120,63 @@ async def _insert_cell_file(
107120
clean_notebook_outputs(notebook)
108121

109122
total_cells = len(notebook.cells)
110-
actual_index = cell_index if cell_index != -1 else total_cells
111123

112-
if actual_index < 0 or actual_index > total_cells:
113-
raise ValueError(
114-
f"Cell index {cell_index} is out of range. Notebook has {total_cells} cells. Use -1 to append at end."
115-
)
124+
# Validate insertion parameters
125+
actual_index = self._validate_cell_insertion_params(
126+
cell_index, total_cells, cell_type
127+
)
116128

117-
# Create and insert the cell
129+
# Create and insert the cell using unified method
130+
# Create and insert the cell
118131
if cell_type == "code":
119132
new_cell = nbformat.v4.new_code_cell(source=cell_source or "")
120133
elif cell_type == "markdown":
121134
new_cell = nbformat.v4.new_markdown_cell(source=cell_source or "")
122-
else:
123-
raise ValueError(f"Invalid cell_type: {cell_type}. Must be 'code' or 'markdown'.")
124-
125135
notebook.cells.insert(actual_index, new_cell)
126136

127137
# Write back to file
128138
with open(notebook_path, "w", encoding="utf-8") as f:
129139
nbformat.write(notebook, f)
130140

131-
return actual_index, len(notebook.cells)
141+
notebook = Notebook(**notebook)
142+
143+
return notebook, actual_index, len(notebook.cells)
132144

133145
async def _insert_cell_websocket(
134146
self,
135147
notebook_manager: NotebookManager,
136148
cell_index: int,
137149
cell_type: Literal["code", "markdown"],
138150
cell_source: str
139-
) -> tuple[int, Notebook]:
151+
) -> tuple[Notebook, int, int]:
140152
"""Insert cell using WebSocket connection (MCP_SERVER mode).
141153
142154
Args:
143155
notebook_manager: Notebook manager instance
144156
cell_index: Index to insert at (-1 for append)
145-
cell_type: Type of cell to insert
157+
cell_type: Type of cell to insert ("code", "markdown")
146158
cell_source: Source content for the cell
147159
148160
Returns:
149-
Success message with surrounding cells info
161+
Tuple of (notebook, actual_index, total_cells_after_insertion)
162+
163+
Raises:
164+
IndexError: When cell_index is out of range
165+
ValueError: When cell_type is invalid
150166
"""
151167
async with notebook_manager.get_current_connection() as notebook:
152-
actual_index = cell_index if cell_index != -1 else len(notebook)
153-
if actual_index < 0 or actual_index > len(notebook):
154-
raise ValueError(f"Cell index {cell_index} out of range")
168+
total_cells = len(notebook)
169+
170+
# Validate insertion parameters
171+
actual_index = self._validate_cell_insertion_params(
172+
cell_index, total_cells, cell_type
173+
)
155174

175+
# Use the unified insert_cell method pattern
176+
# The remote notebook should have: insert_cell(index, source, cell_type)
156177
notebook.insert_cell(actual_index, cell_source, cell_type)
157178

158-
return actual_index, Notebook(**notebook.as_dict())
179+
return Notebook(**notebook.as_dict()), actual_index, len(notebook)
159180

160181
async def execute(
161182
self,
@@ -174,33 +195,44 @@ async def execute(
174195
) -> str:
175196
"""Execute the insert_cell tool.
176197
177-
This tool supports three modes of operation:
198+
This tool supports three modes of operation following a unified insertion pattern:
178199
179200
1. JUPYTER_SERVER mode with YDoc (collaborative):
180201
- Checks if notebook is open in a collaborative session
181202
- Uses YDoc for real-time collaborative editing
182203
- Changes are immediately visible to all connected users
204+
- Operations protected by thread locks and YDoc transactions
183205
184206
2. JUPYTER_SERVER mode without YDoc (file-based):
185207
- Falls back to direct file operations using nbformat
186208
- Suitable when notebook is not actively being edited
187209
188210
3. MCP_SERVER mode (WebSocket):
189211
- Uses WebSocket connection to remote Jupyter server
190-
- Accesses YDoc through NbModelClient
212+
- Delegates to remote notebook's unified insert_cell method
213+
214+
Thread Safety:
215+
- YDoc mode: Protected by thread lock + YDoc transaction (atomic)
216+
- File mode: No synchronization needed (single-threaded file I/O)
217+
- WebSocket mode: Remote server handles synchronization
191218
192219
Args:
193220
mode: Server mode (MCP_SERVER or JUPYTER_SERVER)
194221
server_client: HTTP client for MCP_SERVER mode
195222
contents_manager: Direct API access for JUPYTER_SERVER mode
196223
notebook_manager: Notebook manager instance
197224
cell_index: Target index for insertion (0-based, -1 to append)
198-
cell_type: Type of cell ("code" or "markdown")
225+
cell_type: Type of cell ("code", "markdown")
199226
cell_source: Source content for the cell
200227
**kwargs: Additional parameters
201228
202229
Returns:
203230
Success message with surrounding cells info
231+
232+
Raises:
233+
ValueError: When mode is invalid or required clients are missing
234+
IndexError: When cell_index is out of range
235+
ValueError: When cell_type is invalid
204236
"""
205237
if mode == ServerMode.JUPYTER_SERVER and contents_manager is not None:
206238
# JUPYTER_SERVER mode: Try YDoc first, fall back to file operations
@@ -216,29 +248,27 @@ async def execute(
216248
notebook_path = str(Path(root_dir) / notebook_path)
217249

218250
if serverapp:
219-
# Try YDoc approach first
220-
actual_index, new_total_cells = await self._insert_cell_ydoc(serverapp, notebook_path, cell_index, cell_type, cell_source)
251+
# Try YDoc approach first (with thread safety and transactions)
252+
notebook, actual_index, new_total_cells = await self._insert_cell_ydoc(
253+
serverapp, notebook_path, cell_index, cell_type, cell_source
254+
)
221255
else:
222256
# Fall back to file operations
223-
actual_index, new_total_cells = await self._insert_cell_file(notebook_path, cell_index, cell_type, cell_source)
224-
225-
# Load notebook using same API
226-
notebook_path = notebook_manager.get_current_notebook_path()
227-
model = await contents_manager.get(notebook_path, content=True, type='notebook')
228-
if 'content' not in model:
229-
raise ValueError(f"Could not read notebook content from {notebook_path}")
230-
notebook = Notebook(**model['content'])
257+
notebook, actual_index, new_total_cells = await self._insert_cell_file(
258+
notebook_path, cell_index, cell_type, cell_source
259+
)
231260

232261
elif mode == ServerMode.MCP_SERVER and notebook_manager is not None:
233-
# MCP_SERVER mode: Use WebSocket connection
234-
actual_index, notebook = await self._insert_cell_websocket(notebook_manager, cell_index, cell_type, cell_source)
235-
new_total_cells = len(notebook)
262+
# MCP_SERVER mode: Use WebSocket connection with unified insert_cell pattern
263+
notebook, actual_index, new_total_cells = await self._insert_cell_websocket(
264+
notebook_manager, cell_index, cell_type, cell_source
265+
)
236266
else:
237267
raise ValueError(f"Invalid mode or missing required clients: mode={mode}")
238268

239269
info_list = [f"Cell inserted successfully at index {actual_index} ({cell_type})!"]
240270
info_list.append(f"Notebook now has {new_total_cells} cells, showing surrounding cells:")
241-
# near to end
271+
# Show context near the insertion
242272
if new_total_cells - actual_index < 5:
243273
start_index = max(0, new_total_cells - 10)
244274
else:

0 commit comments

Comments
 (0)