jupyter
| Version | 2026.144.1443 |
| Repo | superproject |
JupyterLab notebook server with a CRDT MCP server for agent-driven notebook editing. Installs JupyterLab into the pixi default environment (the jupyter-lab binary), adds the spaCy en_core_web_sm NLP model and the jupyterlab-quarto extension, and serves notebooks on port 8888 from the /workspace volume under supervisord. A bundled MCP server (composed from jupyter-mcp) exposes notebook_/cell_ tools at /mcp so an agent can create and edit notebooks over the Model Context Protocol.
Services
Section titled “Services”jupyter
Acceptance plan
Section titled “Acceptance plan”This candy’s plan: — the runnable spec charly check executes against a live deployment. check: steps are idempotent probes; run: steps change state.
| Intent | Step |
|---|---|
check |
JupyterLab is installed as a pixi-managed binary in the default env |
check |
the spaCy en_core_web_sm model loads for NLP notebooks |
check |
the workspace volume is mounted for notebooks |
check |
the JupyterLab service is running under supervisord |
check |
the JupyterLab REST API answers on the published port with a server version |
check |
the Jupyter MCP server responds to a protocol ping |
check |
the MCP tool catalog exposes the notebook and cell manipulation tools |
agent-check |
an agent can create a notebook and edit its cells through the MCP server end to end |
check |
command=${HOME}/.pixi/envs/default/bin/python -c “import spacy; spacy.load(‘en_core_web_sm’)” |
check |
command=${HOME}/.pixi/envs/default/bin/jupyter labextension list 2>&1 |
check |
addr=127.0.0.1:${HOST_PORT:8888} |
check |
mcp=list-tools |
check |
mcp=call |
check |
command=${HOME}/.pixi/envs/default/bin/python - <<‘PYEOF’ import asyncio, json, sys, uuid from fastmcp import Client async def main(): async with Client(“http://localhost:8888/mcp”) as c: async def call(t, a=None): r = await c.call_tool(t, a or {}) return r.data if r.data is not None else json.loads( “”.join(b.text for b in r.content if hasattr(b, “text”)) ) p = f“check_id{uuid.uuid4().hex[:8]}.ipynb“ await call(“notebook_create”, {“path”: p}) await call(“cell_insert”, {“path”: p, “index”: 0, “source”: “alpha”, “cell_type”: “markdown”}) before = (await call(“cell_get”, {“path”: p, “index”: 0}))[“id”] await call(“cell_update”, {“path”: p, “index”: 0, “source”: “beta”}) after = (await call(“cell_get”, {“path”: p, “index”: 0}))[“id”] assert before == after, f“id drift {before!r} -> {after!r}” print(“OK”) asyncio.run(main()) PYEOF |
check |
command=${HOME}/.pixi/envs/default/bin/python - <<‘PYEOF’ import asyncio, json, uuid from fastmcp import Client async def main(): async with Client(“http://localhost:8888/mcp”) as c: async def call(t, a=None): r = await c.call_tool(t, a or {}) return r.data if r.data is not None else json.loads( “”.join(b.text for b in r.content if hasattr(b, “text”)) ) p = f“check_count{uuid.uuid4().hex[:8]}.ipynb“ await call(“notebook_create”, {“path”: p}) for i, src in enumerate([“a”,“b”,“c”,“d”,“e”]): await call(“cell_insert”, {“path”: p, “index”: i, “source”: src, “cell_type”: “markdown”}) # Upstream YNotebook auto-injects a default empty code cell # on first room init, so baseline = N_inserts + 1. The # invariant we care about: cell_update preserves count. before = len((await call(“notebook_get”, {“path”: p}))[“cells”]) for idx, src in [(0,“A”),(2,“C”),(4,“E”),(0,“AA”),(2,“CC”)]: await call(“cell_update”, {“path”: p, “index”: idx, “source”: src}) after = len((await call(“notebook_get”, {“path”: p}))[“cells”]) assert before == after, f“count drift {before} -> {after}” print(“OK”) asyncio.run(main()) PYEOF |
check |
command=${HOME}/.pixi/envs/default/bin/python - <<‘PYEOF’ import asyncio, json, uuid from fastmcp import Client async def main(): async with Client(“http://localhost:8888/mcp”) as c: async def call(t, a=None): r = await c.call_tool(t, a or {}) return r.data if r.data is not None else json.loads( “”.join(b.text for b in r.content if hasattr(b, “text”)) ) stem = f“check_canon{uuid.uuid4().hex[:8]}“ p = f“{stem}.ipynb“ await call(“notebook_create”, {“path”: p}) # Three different path forms — all must converge on ONE room. await call(“cell_insert”, {“path”: p, “index”: 0, “source”: “x”}) await call(“cell_insert”, {“path”: f“./{p}“, “index”: 1, “source”: “y”}) await call(“cell_insert”, {“path”: f“/workspace/{p}“, “index”: 2, “source”: “z”}) rl = await call(“room_list”) matching = [r for r in rl if stem in r.get(“path”,“”)] assert len(matching) == 1, f“expected 1 room, got {len(matching)}: {matching}” # All three inserts landed in the SAME notebook. Upstream # YNotebook auto-injects a default empty code cell on first # room init, so 3 inserts + 1 default = 4. cells = (await call(“notebook_get”, {“path”: p}))[“cells”] assert len(cells) == 4, f“expected 4 cells (3 inserts + 1 default), got {len(cells)}” print(“OK”) asyncio.run(main()) PYEOF |
check |
command=${HOME}/.pixi/envs/default/bin/python - <<‘PYEOF’ import asyncio, json from fastmcp import Client async def main(): async with Client(“http://localhost:8888/mcp”) as c: # Host path (outside container’s /workspace) MUST be rejected. try: r = await c.call_tool(“cell_get”, { “path”: “/home/user/workspace/anything.ipynb”, “index”: 0, }) text = “”.join(b.text for b in r.content if hasattr(b, “text”)) is_error = bool(getattr(r, “is_error”, False)) if not is_error and “outside” not in text and “ValueError” not in text: raise SystemExit(f“host path accepted: {text!r}”) except Exception as e: if “outside” in str(e) or “resolves outside” in str(e): print(“OK”) return if “ValueError” in str(e): print(“OK”) return raise print(“OK”) asyncio.run(main()) PYEOF |
check |
command=${HOME}/.pixi/envs/default/bin/python - <<‘PYEOF’ import asyncio, json, uuid from fastmcp import Client async def main(): async with Client(“http://localhost:8888/mcp”) as c: async def call(t, a=None): r = await c.call_tool(t, a or {}) return r.data if r.data is not None else json.loads( “”.join(b.text for b in r.content if hasattr(b, “text”)) ) p = f“check_cold{uuid.uuid4().hex[:8]}.ipynb“ # No prior tool to “open” the room — cell_insert auto-attaches. # Upstream YNotebook auto-injects a default empty code cell, so # baseline = 1, after 1 insert = 2. await call(“notebook_create”, {“path”: p}) await call(“cell_insert”, {“path”: p, “index”: 0, “source”: “auto”}) cells = (await call(“notebook_get”, {“path”: p}))[“cells”] sources = [c.get(“source”) for c in cells] assert any(“auto” in str(s) for s in sources), \ f“inserted cell missing: {cells}” rl = await call(“room_list”) assert any(p in r.get(“path”,“”) for r in rl), \ f“room not auto-created for {p}” print(“OK”) asyncio.run(main()) PYEOF |