Skip to content

Commit d587f84

Browse files
authored
Add move_cell MCP tool on prerelease track (#22)
* chore: bump runtimed to 2.0.1 prerelease * feat: add move_cell MCP tool * chore: split stable and prerelease release tracks * ci: validate release track metadata
1 parent 281553c commit d587f84

6 files changed

Lines changed: 145 additions & 11 deletions

File tree

.github/workflows/publish.yml

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,39 @@ jobs:
1616
- name: Set up Python
1717
run: uv python install 3.12
1818

19+
- name: Validate release track
20+
env:
21+
TARGET_COMMITISH: ${{ github.event.release.target_commitish }}
22+
IS_PRERELEASE: ${{ github.event.release.prerelease }}
23+
run: |
24+
python - <<'PY'
25+
import os
26+
import re
27+
import tomllib
28+
29+
target = os.environ["TARGET_COMMITISH"]
30+
is_prerelease = os.environ["IS_PRERELEASE"].lower() == "true"
31+
32+
with open("pyproject.toml", "rb") as f:
33+
version = tomllib.load(f)["project"]["version"]
34+
35+
prerelease_match = re.match(r"^(\d+)\.(\d+)\.(\d+)a\d+$", version)
36+
stable_match = re.match(r"^(\d+)\.(\d+)\.(\d+)$", version)
37+
38+
if target == "main":
39+
assert is_prerelease, "Releases from main must be GitHub prereleases"
40+
assert prerelease_match, "Versions on main must be prereleases like 2.0.1a<timestamp>"
41+
assert int(prerelease_match.group(1)) >= 2, "Versions on main must be on the 2.x prerelease line"
42+
elif target == "release/1.9.x":
43+
assert not is_prerelease, "Releases from release/1.9.x must be stable"
44+
assert stable_match, "release/1.9.x must publish stable versions like 1.9.x"
45+
assert stable_match.group(1) == "1" and stable_match.group(2) == "9", "release/1.9.x must stay on 1.9.x"
46+
else:
47+
raise AssertionError(
48+
f"Unsupported release target '{target}'. Use main for prereleases or release/1.9.x for stable releases."
49+
)
50+
PY
51+
1952
- name: Build package
2053
run: uv build
2154

README.md

Lines changed: 23 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -23,10 +23,16 @@ We're in the prelimiary stages of hooking up the realtime system from nteract/de
2323
#### Claude Code
2424

2525
```bash
26-
# Add to Claude Code
26+
# Stable desktop 1.4.x / stable MCP
2727
claude mcp add nteract -- uvx nteract
2828
```
2929

30+
For desktop nightly / the upcoming 2.x transition, use the prerelease MCP package and nightly socket:
31+
32+
```bash
33+
claude mcp add nteract-nightly -- env RUNTIMED_SOCKET_PATH="$HOME/Library/Caches/runt-nightly/runtimed.sock" uvx --prerelease allow nteract
34+
```
35+
3036
That's it. Now Claude can execute Python code, create visualizations, and work with your data.
3137

3238
## What is this?
@@ -54,13 +60,21 @@ You can open the same notebook in the [nteract desktop app](https://github.com/n
5460

5561
## Installation
5662

63+
Stable line for desktop `1.4.x`:
64+
5765
```bash
5866
uvx nteract
5967
```
6068

69+
Prerelease line for desktop nightly / 2.x transition:
70+
71+
```bash
72+
uvx --prerelease allow nteract
73+
```
74+
6175
## Claude Code Setup
6276

63-
Add nteract as an MCP server:
77+
Add stable `nteract` as an MCP server:
6478

6579
```bash
6680
claude mcp add nteract -- uvx nteract
@@ -81,12 +95,17 @@ Or manually add to your Claude configuration:
8195

8296
### Using with Nightly
8397

84-
If you're using nteract desktop nightly builds, point at the nightly socket:
98+
If you're using nteract desktop nightly builds, point at the nightly socket and allow prereleases:
8599

86100
```bash
87101
claude mcp add nteract -- env RUNTIMED_SOCKET_PATH="$HOME/Library/Caches/runt-nightly/runtimed.sock" uvx --prerelease allow nteract
88102
```
89103

104+
### Release Tracks
105+
106+
- `main` publishes prerelease `nteract` builds for the 2.x transition and tracks `runtimed 2.x` prereleases.
107+
- `release/1.9.x` is the stable maintenance line for desktop `1.4.x` and stays on `runtimed 1.9.0`.
108+
90109
## Available Tools
91110

92111
| Tool | Description |
@@ -102,6 +121,7 @@ claude mcp add nteract -- env RUNTIMED_SOCKET_PATH="$HOME/Library/Caches/runt-ni
102121
| `get_cell` | Get a cell by ID with outputs |
103122
| `get_all_cells` | View all cells in the notebook |
104123
| `set_cell_source` | Update a cell's source code |
124+
| `move_cell` | Reorder a cell within the notebook |
105125
| `clear_outputs` | Clear a cell's outputs |
106126
| `delete_cell` | Remove a cell from the notebook |
107127
| `start_kernel` | Start a Python kernel |

pyproject.toml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[project]
22
name = "nteract"
3-
version = "1.9.1"
3+
version = "2.0.1a202603110913"
44
description = "Bring AI to Jupyter notebooks. MCP server for Claude, ChatGPT, Gemini, OpenCode and any agent."
55
readme = "README.md"
66
license = "BSD-3-Clause"
@@ -11,7 +11,7 @@ requires-python = ">=3.10"
1111
dependencies = [
1212
"mcp>=1.26.0",
1313
"httpx>=0.27.0,<1.0",
14-
"runtimed>=1.9.0,<2.0",
14+
"runtimed>=2.0.1a202603110913,<3.0",
1515
]
1616

1717
[project.scripts]

src/nteract/_mcp_server.py

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -967,6 +967,29 @@ async def delete_cell(cell_id: str) -> dict[str, Any]:
967967
return {"cell_id": cell_id, "deleted": True}
968968

969969

970+
@mcp.tool(annotations=ToolAnnotations(destructiveHint=True))
971+
async def move_cell(cell_id: str, after_cell_id: str | None = None) -> dict[str, Any]:
972+
"""Move a cell to a new position in the notebook.
973+
974+
Reorders the shared document and syncs the change to all connected clients.
975+
976+
Args:
977+
cell_id: The cell to move.
978+
after_cell_id: Move after this cell. Use None to move to the start.
979+
980+
Returns:
981+
Confirmation of the move, including the new internal position token.
982+
"""
983+
session = await _get_session()
984+
new_position = await session.move_cell(cell_id=cell_id, after_cell_id=after_cell_id)
985+
return {
986+
"cell_id": cell_id,
987+
"after_cell_id": after_cell_id,
988+
"new_position": new_position,
989+
"moved": True,
990+
}
991+
992+
970993
@mcp.tool(annotations=ToolAnnotations(destructiveHint=True))
971994
async def clear_outputs(cell_id: str) -> dict[str, Any]:
972995
"""Clear a cell's outputs.
@@ -1010,6 +1033,19 @@ async def collect_events() -> None:
10101033
with contextlib.suppress(asyncio.TimeoutError):
10111034
await asyncio.wait_for(collect_events(), timeout=timeout_secs)
10121035

1036+
if complete:
1037+
# Prefer the synced document as the final source of truth once execution
1038+
# finishes. This is more robust across runtimed output transport changes.
1039+
session = await _get_session()
1040+
with contextlib.suppress(Exception):
1041+
cell = await session.get_cell(cell_id=cell_id)
1042+
has_error_output = any(output.output_type == "error" for output in cell.outputs)
1043+
status = "error" if has_error_output else "idle"
1044+
header = _format_header(cell.id, status=status, execution_count=cell.execution_count)
1045+
items: list[ContentItem] = [TextContent(type="text", text=header)]
1046+
items.extend(_outputs_to_content(cell.outputs))
1047+
return items
1048+
10131049
return _execution_result_to_content(cell_id, events, complete)
10141050

10151051

tests/test_mcp_integration.py

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -131,6 +131,7 @@ async def test_list_tools(mcp_client: ClientSession):
131131
assert "get_cell" in tool_names
132132
assert "get_all_cells" in tool_names
133133
assert "delete_cell" in tool_names
134+
assert "move_cell" in tool_names
134135
assert "execute_cell" in tool_names
135136

136137
# run_code should be removed
@@ -349,3 +350,47 @@ async def test_delete_cell(mcp_client: ClientSession):
349350
text = _get_text(result)
350351
# The full cell_id shouldn't appear (we only show first 8 chars in header)
351352
assert cell_id not in text
353+
354+
355+
@pytest.mark.asyncio
356+
async def test_move_cell(mcp_client: ClientSession):
357+
"""Test cell reordering."""
358+
await mcp_client.call_tool("connect_notebook", {})
359+
360+
result = await mcp_client.call_tool("create_cell", {"source": "first"})
361+
first_id = _extract_cell_id(_get_text(result))
362+
assert first_id is not None
363+
364+
result = await mcp_client.call_tool("create_cell", {"source": "second"})
365+
second_id = _extract_cell_id(_get_text(result))
366+
assert second_id is not None
367+
368+
result = await mcp_client.call_tool("create_cell", {"source": "third"})
369+
third_id = _extract_cell_id(_get_text(result))
370+
assert third_id is not None
371+
372+
result = await mcp_client.call_tool(
373+
"move_cell",
374+
{"cell_id": third_id, "after_cell_id": first_id},
375+
)
376+
data = _parse_json(result)
377+
assert data["moved"] is True
378+
assert data["cell_id"] == third_id
379+
assert data["after_cell_id"] == first_id
380+
381+
result = await mcp_client.call_tool("get_all_cells", {})
382+
text = _get_text(result)
383+
assert text.index("first") < text.index("third") < text.index("second")
384+
385+
result = await mcp_client.call_tool(
386+
"move_cell",
387+
{"cell_id": second_id, "after_cell_id": None},
388+
)
389+
data = _parse_json(result)
390+
assert data["moved"] is True
391+
assert data["cell_id"] == second_id
392+
assert data["after_cell_id"] is None
393+
394+
result = await mcp_client.call_tool("get_all_cells", {})
395+
text = _get_text(result)
396+
assert text.index("second") < text.index("first") < text.index("third")

uv.lock

Lines changed: 6 additions & 6 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)