Skip to content

Commit 22dafb7

Browse files
delphos-mikeclaude
andauthored
Fix: Access ywebsocket_server via extension_manager to enable RTC mode (#135)
## Problem When using jupyter-mcp-server as a Jupyter Server Extension with jupyter-collaboration, MCP tools fall back to "file mode" instead of using RTC (Real-Time Collaboration) mode. **Symptoms:** - User executes cells successfully from JupyterLab UI - MCP client (Claude Code) modifies/executes cells via MCP tools (works fine) - After MCP modifications, user's UI becomes unresponsive - Cells don't execute from UI, execution counter doesn't update - Browser refresh required to restore UI functionality - Kernel stays alive but WebSocket connection is broken **Environment Tested:** - jupyter-mcp-server 0.17.0 (Jupyter Server Extension mode) - jupyterlab 4.4.1 - jupyter-collaboration 4.0.2 - Python 3.13.7 - Claude Code as MCP client **MCP Configuration (.mcp.json):** ```json { "mcpServers": { "jupyter": { "command": "npx", "args": ["mcp-remote", "http://127.0.0.1:8888/mcp"] } } } ``` **Server logs show:** ``` [INFO] Notebook <id> not open, using file mode [INFO] Wrote outputs to cell <n> in <path> [INFO] Out-of-band changes. Overwriting the content in room json:notebook:<id> ``` The "Out-of-band changes" message indicates MCP wrote to the file directly, bypassing the Y.js collaboration layer, which breaks the UI's WebSocket connection. ## Root Cause 1. MCP tools access collaboration via `serverapp.web_app.settings.get("yroom_manager")` 2. jupyter-collaboration never adds `yroom_manager` to web_app.settings 3. YDocExtension stores it as `self.ywebsocket_server` but doesn't expose it in settings 4. When yroom_manager lookup returns None, tools fall back to direct file writes 5. Direct file writes bypass Y.js CRDT layer → "Out-of-band changes" → WebSocket disconnect ## Solution Access ywebsocket_server via the extension_manager: ```python serverapp.extension_manager.extension_points['jupyter_server_ydoc'].app.ywebsocket_server ``` Also fixed DocumentRoom API - access document via `room._document` instead of calling non-existent `get_jupyter_ydoc()` method. ## Impact ✅ MCP now uses true RTC mode when jupyter-collaboration is available ✅ No more WebSocket disconnections after MCP cell modifications ✅ UI stays responsive - no browser refresh required ✅ Enables true simultaneous editing between MCP clients and human users ✅ Works with Claude Code, VS Code, Cursor, and other MCP clients ## Files Changed - execute_cell_tool.py - overwrite_cell_source_tool.py - insert_cell_tool.py - insert_execute_code_cell_tool.py - delete_cell_tool.py ## Testing Verified with: - JupyterLab 4.4.1 + jupyter-collaboration 4.0.2 + datalayer-pycrdt 0.12.17 - jupyter-mcp-server 0.17.0 as Jupyter Server Extension - Claude Code as MCP client (npx mcp-remote transport) - Confirmed MCP cell modifications no longer break UI WebSocket - Confirmed execution from both MCP and UI works simultaneously without refresh 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude <[email protected]>
1 parent 88f914d commit 22dafb7

File tree

5 files changed

+130
-45
lines changed

5 files changed

+130
-45
lines changed

jupyter_mcp_server/tools/delete_cell_tool.py

Lines changed: 26 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -30,20 +30,37 @@ async def _get_jupyter_ydoc(self, serverapp: Any, file_id: str):
3030
YNotebook instance or None if not in a collaborative session
3131
"""
3232
try:
33-
yroom_manager = serverapp.web_app.settings.get("yroom_manager")
34-
if yroom_manager is None:
33+
# Access ywebsocket_server from YDocExtension via extension_manager
34+
# jupyter-collaboration doesn't add yroom_manager to web_app.settings
35+
ywebsocket_server = None
36+
37+
if hasattr(serverapp, 'extension_manager'):
38+
extension_points = serverapp.extension_manager.extension_points
39+
if 'jupyter_server_ydoc' in extension_points:
40+
ydoc_ext_point = extension_points['jupyter_server_ydoc']
41+
if hasattr(ydoc_ext_point, 'app') and ydoc_ext_point.app:
42+
ydoc_app = ydoc_ext_point.app
43+
if hasattr(ydoc_app, 'ywebsocket_server'):
44+
ywebsocket_server = ydoc_app.ywebsocket_server
45+
46+
if ywebsocket_server is None:
3547
return None
36-
48+
3749
room_id = f"json:notebook:{file_id}"
38-
39-
if yroom_manager.has_room(room_id):
40-
yroom = yroom_manager.get_room(room_id)
41-
notebook = await yroom.get_jupyter_ydoc()
42-
return notebook
50+
51+
# Get room and access document via room._document
52+
# DocumentRoom stores the YNotebook as room._document, not via get_jupyter_ydoc()
53+
try:
54+
yroom = await ywebsocket_server.get_room(room_id)
55+
if yroom and hasattr(yroom, '_document'):
56+
return yroom._document
57+
except Exception:
58+
pass
59+
4360
except Exception:
4461
# YDoc not available, will fall back to file operations
4562
pass
46-
63+
4764
return None
4865

4966
def _get_cell_index_from_id(self, ydoc, cell_id: str) -> Optional[int]:

jupyter_mcp_server/tools/execute_cell_tool.py

Lines changed: 23 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -24,16 +24,33 @@ class ExecuteCellTool(BaseTool):
2424
async def _get_jupyter_ydoc(self, serverapp, file_id: str):
2525
"""Get the YNotebook document if it's currently open in a collaborative session."""
2626
try:
27-
yroom_manager = serverapp.web_app.settings.get("yroom_manager")
28-
if yroom_manager is None:
27+
# Access ywebsocket_server from YDocExtension via extension_manager
28+
# jupyter-collaboration doesn't add yroom_manager to web_app.settings
29+
ywebsocket_server = None
30+
31+
if hasattr(serverapp, 'extension_manager'):
32+
extension_points = serverapp.extension_manager.extension_points
33+
if 'jupyter_server_ydoc' in extension_points:
34+
ydoc_ext_point = extension_points['jupyter_server_ydoc']
35+
if hasattr(ydoc_ext_point, 'app') and ydoc_ext_point.app:
36+
ydoc_app = ydoc_ext_point.app
37+
if hasattr(ydoc_app, 'ywebsocket_server'):
38+
ywebsocket_server = ydoc_app.ywebsocket_server
39+
40+
if ywebsocket_server is None:
2941
return None
3042

3143
room_id = f"json:notebook:{file_id}"
3244

33-
if yroom_manager.has_room(room_id):
34-
yroom = yroom_manager.get_room(room_id)
35-
notebook = await yroom.get_jupyter_ydoc()
36-
return notebook
45+
# Get room and access document via room._document
46+
# DocumentRoom stores the YNotebook as room._document, not via get_jupyter_ydoc()
47+
try:
48+
yroom = await ywebsocket_server.get_room(room_id)
49+
if yroom and hasattr(yroom, '_document'):
50+
return yroom._document
51+
except Exception:
52+
pass
53+
3754
except Exception:
3855
pass
3956

jupyter_mcp_server/tools/insert_cell_tool.py

Lines changed: 29 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -19,32 +19,49 @@ class InsertCellTool(BaseTool):
1919

2020
async def _get_jupyter_ydoc(self, serverapp: Any, file_id: str):
2121
"""Get the YNotebook document if it's currently open in a collaborative session.
22-
22+
2323
This follows the jupyter_ai_tools pattern of accessing YDoc through the
2424
yroom_manager when the notebook is actively being edited.
25-
25+
2626
Args:
2727
serverapp: The Jupyter ServerApp instance
2828
file_id: The file ID for the document
29-
29+
3030
Returns:
3131
YNotebook instance or None if not in a collaborative session
3232
"""
3333
try:
34-
yroom_manager = serverapp.web_app.settings.get("yroom_manager")
35-
if yroom_manager is None:
34+
# Access ywebsocket_server from YDocExtension via extension_manager
35+
# jupyter-collaboration doesn't add yroom_manager to web_app.settings
36+
ywebsocket_server = None
37+
38+
if hasattr(serverapp, 'extension_manager'):
39+
extension_points = serverapp.extension_manager.extension_points
40+
if 'jupyter_server_ydoc' in extension_points:
41+
ydoc_ext_point = extension_points['jupyter_server_ydoc']
42+
if hasattr(ydoc_ext_point, 'app') and ydoc_ext_point.app:
43+
ydoc_app = ydoc_ext_point.app
44+
if hasattr(ydoc_app, 'ywebsocket_server'):
45+
ywebsocket_server = ydoc_app.ywebsocket_server
46+
47+
if ywebsocket_server is None:
3648
return None
37-
49+
3850
room_id = f"json:notebook:{file_id}"
39-
40-
if yroom_manager.has_room(room_id):
41-
yroom = yroom_manager.get_room(room_id)
42-
notebook = await yroom.get_jupyter_ydoc()
43-
return notebook
51+
52+
# Get room and access document via room._document
53+
# DocumentRoom stores the YNotebook as room._document, not via get_jupyter_ydoc()
54+
try:
55+
yroom = await ywebsocket_server.get_room(room_id)
56+
if yroom and hasattr(yroom, '_document'):
57+
return yroom._document
58+
except Exception:
59+
pass
60+
4461
except Exception:
4562
# YDoc not available, will fall back to file operations
4663
pass
47-
64+
4865
return None
4966

5067
async def _insert_cell_ydoc(

jupyter_mcp_server/tools/insert_execute_code_cell_tool.py

Lines changed: 26 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -23,19 +23,36 @@ class InsertExecuteCodeCellTool(BaseTool):
2323
async def _get_jupyter_ydoc(self, serverapp: Any, file_id: str):
2424
"""Get the YNotebook document if it's currently open in a collaborative session."""
2525
try:
26-
yroom_manager = serverapp.web_app.settings.get("yroom_manager")
27-
if yroom_manager is None:
26+
# Access ywebsocket_server from YDocExtension via extension_manager
27+
# jupyter-collaboration doesn't add yroom_manager to web_app.settings
28+
ywebsocket_server = None
29+
30+
if hasattr(serverapp, 'extension_manager'):
31+
extension_points = serverapp.extension_manager.extension_points
32+
if 'jupyter_server_ydoc' in extension_points:
33+
ydoc_ext_point = extension_points['jupyter_server_ydoc']
34+
if hasattr(ydoc_ext_point, 'app') and ydoc_ext_point.app:
35+
ydoc_app = ydoc_ext_point.app
36+
if hasattr(ydoc_app, 'ywebsocket_server'):
37+
ywebsocket_server = ydoc_app.ywebsocket_server
38+
39+
if ywebsocket_server is None:
2840
return None
29-
41+
3042
room_id = f"json:notebook:{file_id}"
31-
32-
if yroom_manager.has_room(room_id):
33-
yroom = yroom_manager.get_room(room_id)
34-
notebook = await yroom.get_jupyter_ydoc()
35-
return notebook
43+
44+
# Get room and access document via room._document
45+
# DocumentRoom stores the YNotebook as room._document, not via get_jupyter_ydoc()
46+
try:
47+
yroom = await ywebsocket_server.get_room(room_id)
48+
if yroom and hasattr(yroom, '_document'):
49+
return yroom._document
50+
except Exception:
51+
pass
52+
3653
except Exception:
3754
pass
38-
55+
3956
return None
4057

4158
async def _insert_execute_ydoc(

jupyter_mcp_server/tools/overwrite_cell_source_tool.py

Lines changed: 26 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -20,19 +20,36 @@ class OverwriteCellSourceTool(BaseTool):
2020
async def _get_jupyter_ydoc(self, serverapp: Any, file_id: str):
2121
"""Get the YNotebook document if it's currently open in a collaborative session."""
2222
try:
23-
yroom_manager = serverapp.web_app.settings.get("yroom_manager")
24-
if yroom_manager is None:
23+
# Access ywebsocket_server from YDocExtension via extension_manager
24+
# jupyter-collaboration doesn't add yroom_manager to web_app.settings
25+
ywebsocket_server = None
26+
27+
if hasattr(serverapp, 'extension_manager'):
28+
extension_points = serverapp.extension_manager.extension_points
29+
if 'jupyter_server_ydoc' in extension_points:
30+
ydoc_ext_point = extension_points['jupyter_server_ydoc']
31+
if hasattr(ydoc_ext_point, 'app') and ydoc_ext_point.app:
32+
ydoc_app = ydoc_ext_point.app
33+
if hasattr(ydoc_app, 'ywebsocket_server'):
34+
ywebsocket_server = ydoc_app.ywebsocket_server
35+
36+
if ywebsocket_server is None:
2537
return None
26-
38+
2739
room_id = f"json:notebook:{file_id}"
28-
29-
if yroom_manager.has_room(room_id):
30-
yroom = yroom_manager.get_room(room_id)
31-
notebook = await yroom.get_jupyter_ydoc()
32-
return notebook
40+
41+
# Get room and access document via room._document
42+
# DocumentRoom stores the YNotebook as room._document, not via get_jupyter_ydoc()
43+
try:
44+
yroom = await ywebsocket_server.get_room(room_id)
45+
if yroom and hasattr(yroom, '_document'):
46+
return yroom._document
47+
except Exception:
48+
pass
49+
3350
except Exception:
3451
pass
35-
52+
3653
return None
3754

3855
def _generate_diff(self, old_source: str, new_source: str) -> str:

0 commit comments

Comments
 (0)