jcollab 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
jcollab/__init__.py ADDED
@@ -0,0 +1,2 @@
1
+ """jcollab: MCP server for live co-editing of JupyterLab notebooks."""
2
+ __version__ = "0.1.0"
jcollab/discovery.py ADDED
@@ -0,0 +1,133 @@
1
+ """Find running Jupyter servers of the current user via Jupyter's own runtime files.
2
+
3
+ Jupyter Server writes ``jpserver-<pid>.json`` (url, port, root_dir, pid, token; mode 600) into the
4
+ runtime dir for every server and removes it on clean shutdown. Crashed servers leave stale files, so
5
+ every candidate is checked for a live pid and probed on ``/api/status``.
6
+ """
7
+ from __future__ import annotations
8
+
9
+ import asyncio
10
+ import json
11
+ import os
12
+ from dataclasses import dataclass
13
+ from pathlib import Path
14
+
15
+ import httpx
16
+ from jupyter_core.paths import jupyter_runtime_dir
17
+
18
+
19
+ @dataclass(frozen=True)
20
+ class Instance:
21
+ url: str # always ends with "/" (includes base_url)
22
+ token: str
23
+ root_dir: str
24
+ pid: int
25
+ port: int
26
+
27
+ @property
28
+ def name(self) -> str:
29
+ return f"{Path(self.root_dir).name or self.root_dir}:{self.port}"
30
+
31
+ def public(self) -> dict:
32
+ return {"name": self.name, "url": self.url, "root_dir": self.root_dir, "pid": self.pid, "port": self.port,
33
+ "token_protected": bool(self.token)}
34
+
35
+
36
+ def _pid_alive(pid: int) -> bool:
37
+ try:
38
+ os.kill(pid, 0)
39
+ except ProcessLookupError:
40
+ return False
41
+ except PermissionError:
42
+ return True
43
+ return True
44
+
45
+
46
+ def _candidates() -> list[Instance]:
47
+ out = []
48
+ for f in sorted(Path(jupyter_runtime_dir()).glob("jpserver-*.json")):
49
+ try:
50
+ d = json.loads(f.read_text())
51
+ if not _pid_alive(int(d["pid"])):
52
+ continue
53
+ url = d["url"] if d["url"].endswith("/") else d["url"] + "/"
54
+ out.append(Instance(url=url, token=d.get("token") or "", root_dir=d.get("root_dir") or d.get("notebook_dir") or "",
55
+ pid=int(d["pid"]), port=int(d["port"])))
56
+ except (OSError, ValueError, KeyError):
57
+ continue
58
+ return out
59
+
60
+
61
+ async def _probe(inst: Instance) -> bool:
62
+ """Reach the server on loopback first: the advertised hostname (e.g. 0.0.0.0 or the host name) may not resolve."""
63
+ try:
64
+ async with httpx.AsyncClient(timeout=2) as c:
65
+ r = await c.get(local_url(inst) + "api/status", headers=auth_headers(inst))
66
+ return r.status_code == 200
67
+ except httpx.HTTPError:
68
+ return False
69
+
70
+
71
+ def local_url(inst: Instance) -> str:
72
+ """URL on 127.0.0.1 with the instance's base_url path (servers bound to 0.0.0.0 or a host name are still reachable here)."""
73
+ from urllib.parse import urlparse
74
+ p = urlparse(inst.url)
75
+ return f"http://127.0.0.1:{inst.port}{p.path}"
76
+
77
+
78
+ def auth_headers(inst: Instance) -> dict:
79
+ return {"Authorization": f"token {inst.token}"} if inst.token else {}
80
+
81
+
82
+ async def discover() -> list[Instance]:
83
+ cands = _candidates()
84
+ ok = await asyncio.gather(*[_probe(i) for i in cands])
85
+ live = [i for i, good in zip(cands, ok) if good]
86
+ # A stale file can share a port with a live server only if the pid is alive but is a different process; keep the
87
+ # entry that answered, drop duplicates by port.
88
+ seen, out = set(), []
89
+ for i in live:
90
+ if i.port not in seen:
91
+ seen.add(i.port)
92
+ out.append(i)
93
+ return out
94
+
95
+
96
+ async def resolve(selector: str | int | None) -> Instance:
97
+ """Pick an instance by port, name (``Trading:8889``), root_dir basename or substring. Optional if exactly one is running."""
98
+ live = await discover()
99
+ if not live:
100
+ raise RuntimeError("No running Jupyter server found (runtime dir has no live jpserver-*.json).")
101
+ if selector in (None, ""):
102
+ if len(live) == 1:
103
+ return live[0]
104
+ raise ValueError("Several Jupyter servers are running, pass `instance`: " + ", ".join(i.name for i in live))
105
+ s = str(selector)
106
+ exact = [i for i in live if s in (i.name, str(i.port), i.root_dir, Path(i.root_dir).name)]
107
+ hits = exact or [i for i in live if s.lower() in i.name.lower() or s in i.root_dir]
108
+ if len(hits) == 1:
109
+ return hits[0]
110
+ raise ValueError(f"Instance {s!r} " + ("is ambiguous: " if hits else "not found. Running: ") + ", ".join(i.name for i in (hits or live)))
111
+
112
+
113
+ _VERSIONS: dict[int, dict] = {}
114
+
115
+
116
+ def server_versions(inst: Instance) -> dict | None:
117
+ """jupyter-ydoc / pycrdt versions of the Jupyter server process (same machine only), via its own interpreter."""
118
+ import subprocess
119
+ if inst.pid in _VERSIONS:
120
+ return _VERSIONS[inst.pid]
121
+ try:
122
+ py = os.readlink(f"/proc/{inst.pid}/exe")
123
+ out = subprocess.run([py, "-c", "import importlib.metadata as m;print(m.version('jupyter-ydoc'),m.version('pycrdt'))"],
124
+ capture_output=True, text=True, timeout=15).stdout.split()
125
+ _VERSIONS[inst.pid] = {"jupyter-ydoc": out[0], "pycrdt": out[1], "python": py}
126
+ except (OSError, IndexError, subprocess.SubprocessError):
127
+ return None
128
+ return _VERSIONS[inst.pid]
129
+
130
+
131
+ def client_versions() -> dict:
132
+ import importlib.metadata as m
133
+ return {"jupyter-ydoc": m.version("jupyter-ydoc"), "pycrdt": m.version("pycrdt")}
jcollab/doc.py ADDED
@@ -0,0 +1,325 @@
1
+ """Live notebook document: one Yjs connection per (server, notebook), with health checks and verified writes.
2
+
3
+ Why the care: a Yjs peer can silently stop being a peer (websocket closed, session expired, document room recreated)
4
+ while its in-memory copy keeps accepting edits that nobody ever sees. ``DocSession.ensure`` therefore re-checks the
5
+ connection before every operation, and ``DocSession.persisted`` confirms that an edit reached the file on the server.
6
+ """
7
+ from __future__ import annotations
8
+
9
+ import asyncio
10
+ import copy
11
+ import difflib
12
+ import time
13
+ from typing import Any
14
+
15
+ from jupyter_nbmodel_client import NbModelClient, get_jupyter_notebook_websocket_url
16
+ from pycrdt import Assoc, YMessageType, read_message
17
+
18
+ from .discovery import client_versions, server_versions
19
+ from .rest import Rest
20
+
21
+ CLIENT_NAME = "metis"
22
+
23
+
24
+ class DocError(RuntimeError):
25
+ pass
26
+
27
+
28
+ def set_text(ytext, new: str) -> None:
29
+ """Replace a Yjs text with ``new`` using minimal edits, so open editors keep cursor and scroll position."""
30
+ old = str(ytext)
31
+ if old == new:
32
+ return
33
+ if len(old) + len(new) > 20000:
34
+ ytext.clear()
35
+ ytext += new
36
+ return
37
+ ops = difflib.SequenceMatcher(None, old, new, autojunk=False).get_opcodes()
38
+ for tag, i1, i2, j1, j2 in reversed(ops):
39
+ if tag == "equal":
40
+ continue
41
+ if i2 > i1:
42
+ del ytext[i1:i2]
43
+ if j2 > j1:
44
+ ytext.insert(i1, new[j1:j2])
45
+
46
+
47
+ def youtput(o: dict):
48
+ """One nbformat output as the Yjs value JupyterLab expects (stream text is a shared Text)."""
49
+ from pycrdt import Map, Text
50
+ o = copy.deepcopy(o)
51
+ if o.get("output_type") == "stream":
52
+ t = o.get("text", "")
53
+ o["text"] = Text(t if isinstance(t, str) else "".join(t))
54
+ return Map(o)
55
+
56
+
57
+ def set_outputs(yc, outputs: list[dict]) -> None:
58
+ from pycrdt import Array
59
+ if not isinstance(yc.get("outputs"), Array):
60
+ yc["outputs"] = Array()
61
+ youts = yc["outputs"]
62
+ youts.clear()
63
+ youts.extend([youtput(o) for o in outputs])
64
+
65
+
66
+ class LiveClient(NbModelClient):
67
+ """NbModelClient that also applies incoming awareness messages (the stock client drops them), so the
68
+ cursors/selections JupyterLab tabs publish are visible."""
69
+
70
+ async def _on_message(self, websocket, message: bytes) -> None:
71
+ if message and message[0] == YMessageType.AWARENESS:
72
+ try:
73
+ self._doc.awareness.apply_awareness_update(read_message(message[1:]), "remote")
74
+ except Exception: # noqa: BLE001 - awareness is advisory, never break syncing over it
75
+ pass
76
+ return
77
+ await super()._on_message(websocket, message)
78
+
79
+
80
+ class DocSession:
81
+ def __init__(self, rest: Rest, path: str):
82
+ self.rest = rest
83
+ self.path = path
84
+ self._nb: NbModelClient | None = None
85
+ self.lock = asyncio.Lock()
86
+ self.reconnects = 0
87
+ self._pointer: asyncio.Task | None = None
88
+
89
+ # -- connection ------------------------------------------------------------------------------------------------
90
+
91
+ @staticmethod
92
+ def _running(nb: NbModelClient) -> bool:
93
+ task = getattr(nb, "_NbModelClient__run", None)
94
+ return task is not None and not task.done()
95
+
96
+ async def ensure(self) -> NbModelClient:
97
+ nb = self._nb
98
+ if nb is not None and nb.synced and self._running(nb):
99
+ return nb
100
+ await self.close()
101
+ hdr = await self.rest.xsrf_headers()
102
+ token = self.rest.inst.token or None
103
+ url = await asyncio.to_thread(get_jupyter_notebook_websocket_url, self.rest.base.rstrip("/"), self.path, token,
104
+ 10.0, None, hdr or None)
105
+ nb = LiveClient(url, path=self.path, username=CLIENT_NAME, additional_headers=hdr or None)
106
+ await nb.start()
107
+ if not nb.synced:
108
+ await nb.stop()
109
+ raise DocError(f"Could not sync the shared document for {self.path} (is jupyter-collaboration installed and enabled?)")
110
+ self._nb = nb
111
+ self.reconnects += 1
112
+ return nb
113
+
114
+ async def close(self) -> None:
115
+ nb, self._nb = self._nb, None
116
+ if nb is not None:
117
+ try:
118
+ await nb.stop()
119
+ except Exception:
120
+ pass
121
+
122
+ async def doc(self):
123
+ """The ``jupyter_ydoc.YNotebook`` behind the live connection."""
124
+ return (await self.ensure())._doc
125
+
126
+ # -- addressing ------------------------------------------------------------------------------------------------
127
+
128
+ async def index(self, ref: int | str) -> int:
129
+ """Resolve a cell reference: index (negative counts from the end), digit string, or cell id."""
130
+ doc = await self.doc()
131
+ n = doc.cell_number
132
+ if isinstance(ref, str): # an exact id wins, also when it looks like a number
133
+ for i in range(n):
134
+ if doc.ycells[i].get("id") == ref:
135
+ return i
136
+ if not ref.lstrip("-").isdigit():
137
+ raise DocError(f"No cell with id {ref!r} in {self.path}")
138
+ i = int(ref)
139
+ if i < 0:
140
+ i += n
141
+ if not 0 <= i < n:
142
+ raise DocError(f"Cell index {ref} out of range (notebook has {n} cells)")
143
+ return i
144
+
145
+ async def ycell(self, ref: int | str):
146
+ return (await self.doc()).ycells[await self.index(ref)]
147
+
148
+ # -- reading ---------------------------------------------------------------------------------------------------
149
+
150
+ async def cell(self, ref: int | str, outputs: bool = False) -> dict[str, Any]:
151
+ doc = await self.doc()
152
+ i = await self.index(ref)
153
+ return self.describe(doc.ycells[i], i, outputs)
154
+
155
+ @staticmethod
156
+ def describe(yc, i: int, outputs: bool = False) -> dict[str, Any]:
157
+ d = {"index": i, "id": yc.get("id"), "cell_type": yc.get("cell_type"), "source": str(yc["source"]),
158
+ "metadata": yc["metadata"].to_py() if yc.get("metadata") is not None else {}}
159
+ if d["cell_type"] == "code":
160
+ ec = yc.get("execution_count")
161
+ d["execution_count"] = int(ec) if ec is not None else None
162
+ d["execution_state"] = yc.get("execution_state")
163
+ outs = yc.get("outputs")
164
+ d["n_outputs"] = len(outs) if outs is not None else 0
165
+ if outputs:
166
+ d["outputs"] = outs.to_py() if outs is not None else []
167
+ return d
168
+
169
+ async def cells(self, outputs: bool = False) -> list[dict[str, Any]]:
170
+ doc = await self.doc()
171
+ return [self.describe(doc.ycells[i], i, outputs) for i in range(doc.cell_number)]
172
+
173
+ # -- verification ----------------------------------------------------------------------------------------------
174
+
175
+ async def persisted(self, timeout: float = 4.0) -> tuple[bool, str]:
176
+ """Wait until the file on the server shows the same cell sources as the live document."""
177
+ doc = await self.doc()
178
+ ids = [doc.ycells[i].get("id") for i in range(doc.cell_number)]
179
+ if len(set(ids)) != len(ids):
180
+ return False, ("live document has duplicate cell ids: client/server Yjs version skew. "
181
+ + self.skew_hint())
182
+ want = [str(doc.ycells[i]["source"]) for i in range(doc.cell_number)]
183
+ deadline = asyncio.get_running_loop().time() + timeout
184
+ have: list[str] = []
185
+ while True:
186
+ try:
187
+ nb = (await self.rest.contents(self.path))["content"]
188
+ have = ["".join(c["source"]) if isinstance(c["source"], list) else c["source"] for c in nb["cells"]]
189
+ except Exception as e: # noqa: BLE001 - report, do not hide
190
+ return False, f"could not read file back: {e}"
191
+ if have == want:
192
+ return True, "ok"
193
+ if asyncio.get_running_loop().time() > deadline:
194
+ diff = [i for i in range(max(len(have), len(want)))
195
+ if i >= len(have) or i >= len(want) or have[i] != want[i]][:5]
196
+ ids = [str(doc.ycells[i].get("id")) for i in range(doc.cell_number)]
197
+ return False, f"file on server differs from the live document (cells {diff}, file has {len(have)} cells, live has {len(want)}: ids {ids})"
198
+ await asyncio.sleep(0.25)
199
+
200
+ async def point_at(self, ref: int | str, seconds: float = 30.0, select: bool = True, scroll: bool = True,
201
+ force_scroll: bool = False, label: str | None = None) -> dict[str, Any]:
202
+ """Show a labelled "Metis" cursor/selection in a cell for a while (visible in every open tab, and reachable via
203
+ the Collaborators sidebar of JupyterLab)."""
204
+ nb = await self.ensure()
205
+ doc = nb._doc
206
+ i = await self.index(ref)
207
+ t = doc.ycells[i]["source"]
208
+ n = len(t)
209
+ if n == 0:
210
+ raise DocError(f"Cell {i} is empty, nothing to point at (put some text in it first)")
211
+ anchor = t.sticky_index(0, Assoc.AFTER).to_json()
212
+ head = t.sticky_index(n - 1 if select else 0, Assoc.AFTER).to_json()
213
+ nb.set_local_state_field("user", {"username": "metis", "name": "Metis", "display_name": "Metis", "initials": "M",
214
+ "avatar_url": None, "color": "#d97757"})
215
+ nb.set_local_state_field("cursors", [{"anchor": anchor, "head": head, "primary": True, "empty": not (select and n > 1)}])
216
+ if scroll:
217
+ nb.set_local_state_field("scrollTo", {"cell": doc.ycells[i].get("id"), "nonce": str(time.time_ns()), "force": force_scroll, "label": label})
218
+ if self._pointer is not None:
219
+ self._pointer.cancel()
220
+
221
+ async def clear():
222
+ await asyncio.sleep(seconds)
223
+ try:
224
+ nb.set_local_state_field("cursors", [])
225
+ nb.set_local_state_field("scrollTo", None)
226
+ except Exception: # noqa: BLE001
227
+ pass
228
+ self._pointer = asyncio.create_task(clear())
229
+ return {"pointing_at": i, "id": doc.ycells[i].get("id"), "seconds": seconds}
230
+
231
+ async def user_focus(self) -> list[dict[str, Any]]:
232
+ """Where other people (browser tabs) have their cursor/selection: cell, line, selected text.
233
+
234
+ JupyterLab publishes cursors through Yjs awareness as relative positions naming a character item. The offset
235
+ is resolved with pycrdt; the cell is the one whose text holds the same item at that offset."""
236
+ from pycrdt import StickyIndex
237
+ nb = await self.ensure()
238
+ doc, aw = nb._doc, nb._doc.awareness
239
+ out: list[dict[str, Any]] = []
240
+ for cid, st in list(aw.states.items()):
241
+ if cid == aw.client_id or not st.get("cursors"):
242
+ continue
243
+ user = st.get("user", {})
244
+ who = user.get("display_name") or user.get("name") or user.get("owner") or str(cid)
245
+ for cur in st["cursors"]:
246
+ try:
247
+ a = StickyIndex.from_json(cur["anchor"]).get_index()
248
+ h = StickyIndex.from_json(cur["head"]).get_index()
249
+ except Exception: # noqa: BLE001
250
+ continue
251
+ item = cur["anchor"].get("item")
252
+ cands = []
253
+ for i in range(doc.cell_number):
254
+ t = doc.ycells[i]["source"]
255
+ if a is None or len(t) < a:
256
+ continue
257
+ if item is None:
258
+ cands.append(i) # cursor at the very end/start: no item to match
259
+ continue
260
+ if a >= len(t):
261
+ continue
262
+ try:
263
+ if t.sticky_index(a, Assoc(cur["anchor"].get("assoc", 0))).to_json().get("item") == item:
264
+ cands.append(i)
265
+ except BaseException: # noqa: BLE001 - pycrdt panics on some positions
266
+ continue
267
+ if not cands:
268
+ continue
269
+ i = cands[0]
270
+ src = str(doc.ycells[i]["source"])
271
+ lo, hi = sorted((a, h if h is not None else a))
272
+ out.append({"user": who, "cell_index": i, "cell_id": doc.ycells[i].get("id"),
273
+ "cell_type": doc.ycells[i].get("cell_type"),
274
+ "line": src.count("\n", 0, lo), "column": lo - (src.rfind("\n", 0, lo) + 1),
275
+ "selection": src[lo:hi] if hi > lo else None,
276
+ **({"ambiguous_cells": cands} if len(cands) > 1 else {})})
277
+ return out
278
+
279
+ def skew_hint(self) -> str:
280
+ srv, cli = server_versions(self.rest.inst), client_versions()
281
+ if srv is None:
282
+ return f"Client has {cli}; install the same jupyter-ydoc and pycrdt versions as the Jupyter server."
283
+ fix = f"{self.rest.inst.name} runs jupyter-ydoc {srv['jupyter-ydoc']}, pycrdt {srv['pycrdt']}; client has {cli}."
284
+ return fix + f" Fix: pip install jupyter-ydoc=={srv['jupyter-ydoc']} pycrdt=={srv['pycrdt']} in the jcollab venv."
285
+
286
+ async def health(self) -> dict[str, Any]:
287
+ nb = await self.ensure()
288
+ peers = []
289
+ try:
290
+ for cid in nb.get_connected_peers():
291
+ u = (nb.get_peer_state(cid) or {}).get("user", {})
292
+ peers.append(u.get("display_name") or u.get("agent") or u.get("owner") or str(cid))
293
+ except Exception: # noqa: BLE001
294
+ pass
295
+ viewers = None
296
+ sess = await self.rest.session_for(self.path)
297
+ if sess: # awareness peers are not reliably reported; open frontends show up as kernel websocket connections
298
+ viewers = (await self.rest.request("GET", f"api/kernels/{sess['kernel']['id']}")).json().get("connections")
299
+ ok, detail = await self.persisted()
300
+ srv, cli = server_versions(self.rest.inst), client_versions()
301
+ return {"yjs_versions": {"client": cli, "server": srv and {k: srv[k] for k in ("jupyter-ydoc", "pycrdt")},
302
+ "match": bool(srv) and all(srv[k] == cli[k] for k in cli)},
303
+ "synced": nb.synced, "connections_made": self.reconnects, "awareness_peers": peers,
304
+ "frontends_attached_to_kernel": viewers, "persisted": ok, "detail": detail}
305
+
306
+
307
+ class Sessions:
308
+ """Cache of DocSession per (instance, notebook path)."""
309
+
310
+ def __init__(self):
311
+ self._rest: dict[int, Rest] = {}
312
+ self._docs: dict[tuple[int, str], DocSession] = {}
313
+
314
+ def rest(self, inst) -> Rest:
315
+ r = self._rest.get(inst.port)
316
+ if r is None or r.inst != inst:
317
+ r = self._rest[inst.port] = Rest(inst)
318
+ return r
319
+
320
+ def doc(self, inst, path: str) -> DocSession:
321
+ key = (inst.port, path)
322
+ d = self._docs.get(key)
323
+ if d is None or d.rest.inst != inst:
324
+ d = self._docs[key] = DocSession(self.rest(inst), path)
325
+ return d
jcollab/kernel.py ADDED
@@ -0,0 +1,236 @@
1
+ """Kernel execution that leaves cells exactly as if the user had run them in JupyterLab.
2
+
3
+ A cell run by the user shows: ``[*]`` while busy (shared cell field ``execution_state``), outputs streaming in, the
4
+ kernel's own ``execution_count``, and ``metadata.execution`` with the iopub/shell timestamps that
5
+ jupyterlab-execute-time displays. ``run_cell`` writes all of that into the shared document while the kernel runs.
6
+
7
+ Code that is only inspected (``run_code``) uses ``store_history=False`` so it never consumes an execution count.
8
+ """
9
+ from __future__ import annotations
10
+
11
+ import asyncio
12
+ import json
13
+ import time
14
+ import uuid
15
+ from typing import Any
16
+
17
+ import websockets
18
+
19
+ from .doc import DocError, DocSession, set_outputs, youtput
20
+ from .discovery import auth_headers
21
+ from .rest import Rest
22
+
23
+
24
+ class KernelError(RuntimeError):
25
+ pass
26
+
27
+
28
+ def _ws_url(rest: Rest, kernel_id: str) -> str:
29
+ base = rest.base.replace("http://", "ws://", 1)
30
+ return f"{base}api/kernels/{kernel_id}/channels"
31
+
32
+
33
+ def _request(code: str, store_history: bool, session: str) -> tuple[str, dict]:
34
+ msg_id = uuid.uuid4().hex
35
+ return msg_id, {
36
+ "header": {"msg_id": msg_id, "msg_type": "execute_request", "username": "metis", "session": session,
37
+ "version": "5.3", "date": time.strftime("%Y-%m-%dT%H:%M:%S.000Z", time.gmtime())},
38
+ "parent_header": {}, "metadata": {}, "channel": "shell", "buffers": [],
39
+ "content": {"code": code, "silent": False, "store_history": store_history, "user_expressions": {},
40
+ "allow_stdin": False, "stop_on_error": True},
41
+ }
42
+
43
+
44
+ def _nb_output(mt: str, ct: dict) -> dict | None:
45
+ if mt == "stream":
46
+ return {"output_type": "stream", "name": ct.get("name", "stdout"), "text": ct.get("text", "")}
47
+ if mt == "display_data":
48
+ return {"output_type": "display_data", "data": ct.get("data", {}), "metadata": ct.get("metadata", {})}
49
+ if mt == "execute_result":
50
+ return {"output_type": "execute_result", "data": ct.get("data", {}), "metadata": ct.get("metadata", {}),
51
+ "execution_count": ct.get("execution_count")}
52
+ if mt == "error":
53
+ return {"output_type": "error", "ename": ct.get("ename", ""), "evalue": ct.get("evalue", ""),
54
+ "traceback": ct.get("traceback", [])}
55
+ return None
56
+
57
+
58
+ class _Sink:
59
+ """Receives kernel messages for one execute_request; optionally mirrors them into a shared-document cell."""
60
+
61
+ def __init__(self, doc=None, ycell=None):
62
+ self.doc, self.yc = doc, ycell
63
+ self.outputs: list[dict] = []
64
+ self.timing: dict[str, str] = {}
65
+ self.count: int | None = None
66
+ self.status = "unknown"
67
+ self.display_ids: dict[str, int] = {}
68
+ self._clear_next = False
69
+
70
+ # ydoc helpers -----------------------------------------------------------------------------------------------
71
+ def _youts(self):
72
+ return self.yc["outputs"]
73
+
74
+ def _push_timing(self):
75
+ if self.yc is not None:
76
+ with self.doc._ydoc.transaction():
77
+ self.yc["metadata"]["execution"] = dict(self.timing)
78
+
79
+ def _add(self, out: dict):
80
+ if self._clear_next:
81
+ self._clear_next = False
82
+ self.outputs.clear()
83
+ if self.yc is not None:
84
+ with self.doc._ydoc.transaction():
85
+ self._youts().clear()
86
+ last = self.outputs[-1] if self.outputs else None
87
+ merged = out["output_type"] == "stream" and last and last["output_type"] == "stream" and last["name"] == out["name"]
88
+ if merged:
89
+ last["text"] += out["text"]
90
+ else:
91
+ self.outputs.append(out)
92
+ if self.yc is None:
93
+ return
94
+ with self.doc._ydoc.transaction():
95
+ youts = self._youts()
96
+ if merged:
97
+ youts[len(youts) - 1]["text"] += out["text"]
98
+ else:
99
+ youts.append(youtput(out))
100
+
101
+ def _replace(self, idx: int, out: dict):
102
+ self.outputs[idx] = out
103
+ if self.yc is not None:
104
+ with self.doc._ydoc.transaction():
105
+ self._youts()[idx] = youtput(out)
106
+
107
+ def clear(self):
108
+ self.outputs.clear()
109
+ if self.yc is not None:
110
+ with self.doc._ydoc.transaction():
111
+ self._youts().clear()
112
+
113
+ # message handling -------------------------------------------------------------------------------------------
114
+ def on_iopub(self, msg: dict):
115
+ mt, ct = msg["msg_type"], msg.get("content", {})
116
+ date = msg.get("header", {}).get("date")
117
+ if mt == "execute_input":
118
+ self.count = ct.get("execution_count")
119
+ if date:
120
+ self.timing["iopub.execute_input"] = date
121
+ self._push_timing()
122
+ elif mt == "status":
123
+ state = ct.get("execution_state")
124
+ if state == "busy" and date and "iopub.status.busy" not in self.timing:
125
+ self.timing["iopub.status.busy"] = date
126
+ self._push_timing()
127
+ elif state == "idle":
128
+ if date:
129
+ self.timing["iopub.status.idle"] = date
130
+ self.status = "idle"
131
+ elif mt == "clear_output":
132
+ if ct.get("wait"):
133
+ self._clear_next = True
134
+ else:
135
+ self.clear()
136
+ elif mt == "update_display_data":
137
+ did = ct.get("transient", {}).get("display_id")
138
+ if did in self.display_ids:
139
+ self._replace(self.display_ids[did], {"output_type": "display_data", "data": ct.get("data", {}),
140
+ "metadata": ct.get("metadata", {})})
141
+ else:
142
+ out = _nb_output(mt, ct)
143
+ if out:
144
+ if mt == "execute_result":
145
+ self.count = ct.get("execution_count", self.count)
146
+ did = ct.get("transient", {}).get("display_id")
147
+ self._add(out)
148
+ if did:
149
+ self.display_ids[did] = len(self.outputs) - 1
150
+
151
+ def on_reply(self, msg: dict):
152
+ date = msg.get("header", {}).get("date")
153
+ if date:
154
+ self.timing["shell.execute_reply"] = date
155
+ started = msg.get("metadata", {}).get("started")
156
+ if started:
157
+ self.timing["shell.execute_reply.started"] = started
158
+ ct = msg.get("content", {})
159
+ self.reply_status = ct.get("status")
160
+ if ct.get("execution_count") is not None:
161
+ self.count = ct["execution_count"]
162
+
163
+
164
+ async def _execute(rest: Rest, kernel_id: str, code: str, sink: _Sink, store_history: bool, timeout: float) -> bool:
165
+ """Run code, feed messages into ``sink``. Returns False if the timeout hit (the kernel is interrupted then)."""
166
+ msg_id, req = _request(code, store_history, uuid.uuid4().hex)
167
+ sink.reply_status = None
168
+ async with websockets.connect(_ws_url(rest, kernel_id), additional_headers=auth_headers(rest.inst) or None,
169
+ max_size=64 * 1024 * 1024) as ws:
170
+ await ws.send(json.dumps(req))
171
+ deadline = time.monotonic() + timeout
172
+ got_reply = False
173
+ while not (got_reply and sink.status == "idle"):
174
+ left = deadline - time.monotonic()
175
+ if left <= 0:
176
+ await rest.kernel_action(kernel_id, "interrupt")
177
+ return False
178
+ try:
179
+ msg = json.loads(await asyncio.wait_for(ws.recv(), min(left, 5)))
180
+ except asyncio.TimeoutError:
181
+ continue
182
+ if msg.get("parent_header", {}).get("msg_id") != msg_id:
183
+ continue
184
+ if msg.get("channel") == "shell" or msg["msg_type"] == "execute_reply":
185
+ sink.on_reply(msg)
186
+ got_reply = True
187
+ else:
188
+ sink.on_iopub(msg)
189
+ return True
190
+
191
+
192
+ async def kernel_for(rest: Rest, ds_path: str, doc=None) -> str:
193
+ name = "python3"
194
+ if doc is not None:
195
+ try:
196
+ name = doc._ymeta["metadata"].to_py().get("kernelspec", {}).get("name") or name
197
+ except Exception: # noqa: BLE001
198
+ pass
199
+ sess = await rest.ensure_session(ds_path, name)
200
+ return sess["kernel"]["id"]
201
+
202
+
203
+ async def run_cell(ds: DocSession, ref: int | str, timeout: float = 300) -> dict[str, Any]:
204
+ doc = await ds.doc()
205
+ i = await ds.index(ref)
206
+ yc = doc.ycells[i]
207
+ if yc.get("cell_type") != "code":
208
+ raise DocError(f"Cell {i} is a {yc.get('cell_type')} cell, only code cells can run")
209
+ code = str(yc["source"])
210
+ kid = await kernel_for(ds.rest, ds.path, doc)
211
+ sink = _Sink(doc, yc)
212
+ t0 = time.monotonic()
213
+ with doc._ydoc.transaction(): # same state a user click produces: [*], no outputs, no old timing
214
+ yc["execution_state"] = "running"
215
+ yc["execution_count"] = None
216
+ set_outputs(yc, [])
217
+ if "execution" in yc["metadata"]:
218
+ del yc["metadata"]["execution"]
219
+ finished = False
220
+ try:
221
+ finished = await _execute(ds.rest, kid, code, sink, True, timeout)
222
+ finally:
223
+ with doc._ydoc.transaction():
224
+ yc["execution_count"] = sink.count
225
+ yc["execution_state"] = "idle"
226
+ sink._push_timing()
227
+ return {"index": i, "id": yc.get("id"), "execution_count": int(sink.count) if sink.count is not None else None, "status": sink.reply_status,
228
+ "timed_out": not finished, "seconds": round(time.monotonic() - t0, 2), "outputs": sink.outputs}
229
+
230
+
231
+ async def run_code(rest: Rest, path: str, code: str, timeout: float = 60, doc=None) -> dict[str, Any]:
232
+ """Inspection run: not stored in kernel history, no execution count consumed, nothing written to the notebook."""
233
+ kid = await kernel_for(rest, path, doc)
234
+ sink = _Sink()
235
+ finished = await _execute(rest, kid, code, sink, False, timeout)
236
+ return {"status": sink.reply_status, "timed_out": not finished, "outputs": sink.outputs}
jcollab/rest.py ADDED
@@ -0,0 +1,58 @@
1
+ """Async REST access to one Jupyter server (contents, sessions, kernels), XSRF-safe for token-less servers."""
2
+ from __future__ import annotations
3
+
4
+ import httpx
5
+
6
+ from .discovery import Instance, auth_headers, local_url
7
+
8
+
9
+ class Rest:
10
+ def __init__(self, inst: Instance):
11
+ self.inst = inst
12
+ self.base = local_url(inst)
13
+ self._c = httpx.AsyncClient(timeout=15)
14
+
15
+ async def _headers(self, write: bool) -> dict:
16
+ h = auth_headers(self.inst)
17
+ if write and not self.inst.token:
18
+ if not self._c.cookies.get("_xsrf"):
19
+ await self._c.get(self.base + "login", follow_redirects=True) # sets the _xsrf cookie even if the page 404s
20
+ xs = self._c.cookies.get("_xsrf")
21
+ if xs:
22
+ h["X-XSRFToken"] = xs
23
+ h["Cookie"] = f"_xsrf={xs}"
24
+ return h
25
+
26
+ async def request(self, method: str, path: str, *, write: bool = False, **kw) -> httpx.Response:
27
+ r = await self._c.request(method, self.base + path.lstrip("/"), headers=await self._headers(write), **kw)
28
+ r.raise_for_status()
29
+ return r
30
+
31
+ async def xsrf_headers(self) -> dict:
32
+ return await self._headers(True)
33
+
34
+ async def contents(self, path: str, content: bool = True) -> dict:
35
+ return (await self.request("GET", f"api/contents/{path}", params={"content": int(content)})).json()
36
+
37
+ async def sessions(self) -> list[dict]:
38
+ return (await self.request("GET", "api/sessions")).json()
39
+
40
+ async def session_for(self, path: str) -> dict | None:
41
+ for s in await self.sessions():
42
+ if s.get("path") == path or s.get("notebook", {}).get("path") == path:
43
+ return s
44
+ return None
45
+
46
+ async def ensure_session(self, path: str, kernel_name: str = "python3") -> dict:
47
+ s = await self.session_for(path)
48
+ if s:
49
+ return s
50
+ r = await self.request("POST", "api/sessions", write=True, json={
51
+ "kernel": {"name": kernel_name}, "path": path, "name": path, "type": "notebook"})
52
+ return r.json()
53
+
54
+ async def kernel_action(self, kernel_id: str, action: str) -> None:
55
+ await self.request("POST", f"api/kernels/{kernel_id}/{action}", write=True)
56
+
57
+ async def aclose(self) -> None:
58
+ await self._c.aclose()
jcollab/server.py ADDED
@@ -0,0 +1,463 @@
1
+ """jcollab MCP server (stdio). Tools address a notebook by ``instance`` (optional if only one server runs) and ``path``."""
2
+ from __future__ import annotations
3
+
4
+ import base64
5
+ import copy
6
+ import re
7
+ import uuid
8
+ from pathlib import PurePosixPath
9
+ from typing import Any
10
+
11
+ from mcp.server.mcpserver import Image, MCPServer
12
+
13
+ from . import kernel
14
+ from .discovery import discover, resolve
15
+ from .doc import DocError, DocSession, Sessions, set_outputs, set_text
16
+
17
+ INSTRUCTIONS = """Live co-editing of JupyterLab notebooks. Edits go through the shared Yjs document, so an open browser
18
+ tab shows them immediately. Every mutating tool reports `persisted` (the file on the server matches the live document);
19
+ if it is false, tell the user instead of assuming the edit is visible. Cells are addressed by index or cell id.
20
+ Rules: small inspections go through `run_code` (not stored in history, no execution count used). Anything big or
21
+ worth keeping runs as a visible code cell (`insert_cell` + `run_cell`) so the user sees the code. Mentions ("@claude" in a cell): handle only when the user asks (find_mentions, answer via add_comment). Put every markdown
22
+ heading in its own cell. Pass cell_type='markdown' explicitly for prose."""
23
+
24
+ mcp = MCPServer("jcollab", instructions=INSTRUCTIONS)
25
+ S = Sessions()
26
+ MAX_TEXT = 6000
27
+
28
+
29
+ async def _ds(instance: str | None, path: str) -> DocSession:
30
+ inst = await resolve(instance)
31
+ path = path.lstrip("/")
32
+ root = inst.root_dir.rstrip("/") + "/"
33
+ if path.startswith(root):
34
+ path = path[len(root):]
35
+ return S.doc(inst, path)
36
+
37
+
38
+ def _clip(s: str) -> str:
39
+ return s if len(s) <= MAX_TEXT else s[: MAX_TEXT // 2] + f"\n…[{len(s) - MAX_TEXT} chars cut]…\n" + s[-MAX_TEXT // 2:]
40
+
41
+
42
+ _ANSI = re.compile(r"\x1b\[[0-9;]*[A-Za-z]")
43
+
44
+
45
+ def render(outputs: list[dict]) -> list[Any]:
46
+ """Outputs as MCP content: text, and real images for image/png|jpeg."""
47
+ parts: list[Any] = []
48
+ for o in outputs:
49
+ t = o.get("output_type")
50
+ if t == "stream":
51
+ parts.append(_clip(o["text"] if isinstance(o["text"], str) else "".join(o["text"])))
52
+ elif t == "error":
53
+ tb = _ANSI.sub("", "\n".join(o.get("traceback") or [])[-1500:])
54
+ parts.append(f"{o['ename']}: {o['evalue']}\n{tb}")
55
+ elif t in ("execute_result", "display_data"):
56
+ data = o.get("data", {})
57
+ for mime, fmt in (("image/png", "png"), ("image/jpeg", "jpeg")):
58
+ if mime in data:
59
+ parts.append(Image(data=base64.b64decode(data[mime]), format=fmt))
60
+ break
61
+ txt = data.get("text/plain")
62
+ if txt is not None and not any(isinstance(p, Image) for p in parts[-1:]):
63
+ parts.append(_clip("".join(txt) if isinstance(txt, list) else txt))
64
+ return parts or ["(no output)"]
65
+
66
+
67
+ async def _done(ds: DocSession, **info) -> dict:
68
+ ok, detail = await ds.persisted()
69
+ return {**info, "persisted": ok, **({} if ok else {"warning": detail})}
70
+
71
+
72
+ def _brief(c: dict, width: int = 160) -> dict:
73
+ src = c["source"]
74
+ c = {k: v for k, v in c.items() if k != "source"}
75
+ c["source"] = src if len(src) <= width else src[:width] + "…"
76
+ return c
77
+
78
+
79
+ # ── instances / files ──────────────────────────────────────────────────────────────────────────────────────────
80
+
81
+ @mcp.tool()
82
+ async def list_instances() -> list[dict]:
83
+ """Running Jupyter servers of this user (found via Jupyter's runtime files, no setup needed)."""
84
+ return [i.public() for i in await discover()]
85
+
86
+
87
+ @mcp.tool()
88
+ async def list_notebooks(instance: str | None = None, subdir: str = "") -> list[str]:
89
+ """All .ipynb paths (relative to the server root) below `subdir`."""
90
+ inst = await resolve(instance)
91
+ rest = S.rest(inst)
92
+ out: list[str] = []
93
+
94
+ async def walk(d: str):
95
+ for e in (await rest.contents(d, content=True))["content"]:
96
+ if e["type"] == "directory" and not e["name"].startswith("."):
97
+ await walk(e["path"])
98
+ elif e["name"].endswith(".ipynb"):
99
+ out.append(e["path"])
100
+ await walk(subdir.strip("/"))
101
+ return sorted(out)
102
+
103
+
104
+ # ── reading ────────────────────────────────────────────────────────────────────────────────────────────────────
105
+
106
+ @mcp.tool()
107
+ async def read_notebook(path: str, instance: str | None = None, start: int = 0, end: int | None = None,
108
+ full_source: bool = False) -> dict:
109
+ """Overview of cells (index, id, type, execution count/state, metadata, source, number of outputs)."""
110
+ ds = await _ds(instance, path)
111
+ doc = await ds.doc()
112
+ cells = (await ds.cells())[start:end]
113
+ return {"path": ds.path, "n_cells": doc.cell_number, "notebook_metadata": doc._ymeta["metadata"].to_py(),
114
+ "cells": cells if full_source else [_brief(c) for c in cells]}
115
+
116
+
117
+ @mcp.tool()
118
+ async def read_cell(path: str, cell: int | str, instance: str | None = None, include_outputs: bool = True) -> list[Any]:
119
+ """One cell with full source, metadata and (rendered, incl. images) outputs."""
120
+ ds = await _ds(instance, path)
121
+ c = await ds.cell(cell, outputs=include_outputs)
122
+ outs = c.pop("outputs", [])
123
+ return [c] + (render(outs) if include_outputs and outs else [])
124
+
125
+
126
+ # ── editing ────────────────────────────────────────────────────────────────────────────────────────────────────
127
+
128
+ def _new_ycell(doc, cell_type: str, source: str, metadata: dict | None):
129
+ cell = {"cell_type": cell_type, "source": source, "metadata": metadata or {}, "id": uuid.uuid4().hex[:8]}
130
+ if cell_type == "code":
131
+ cell.update(outputs=[], execution_count=None)
132
+ return doc.create_ycell(cell)
133
+
134
+
135
+ @mcp.tool()
136
+ async def insert_cell(path: str, source: str, cell_type: str = "code", after: int | str | None = None,
137
+ instance: str | None = None, metadata: dict | None = None) -> dict:
138
+ """Insert a cell after `after` (index or id); appends at the end if `after` is None (use insert_cell_at_start for the top).
139
+ Always pass cell_type='markdown' for prose; keep each heading in its own cell."""
140
+ if cell_type not in ("code", "markdown", "raw"):
141
+ raise DocError("cell_type must be code, markdown or raw")
142
+ ds = await _ds(instance, path)
143
+ async with ds.lock:
144
+ doc = await ds.doc()
145
+ pos = doc.cell_number if after is None else await ds.index(after) + 1
146
+ with doc._ydoc.transaction():
147
+ doc.ycells.insert(pos, _new_ycell(doc, cell_type, source, metadata))
148
+ return await _done(ds, index=pos, id=doc.ycells[pos].get("id"))
149
+
150
+
151
+ RESPONSE_MARKER = "<!-- jcollab-response -->"
152
+
153
+
154
+ @mcp.tool()
155
+ async def find_mentions(path: str, instance: str | None = None) -> list[dict]:
156
+ """List cells containing "@claude" that are not answered yet (no response cell directly below).
157
+ Only look when the user asks; never answer mentions on your own initiative."""
158
+ ds = await _ds(instance, path)
159
+ doc = await ds.doc()
160
+ out, n = [], doc.cell_number
161
+ for i in range(n):
162
+ src = str(doc.ycells[i]["source"])
163
+ if "@claude" not in src or RESPONSE_MARKER in src:
164
+ continue
165
+ nxt = str(doc.ycells[i + 1]["source"]) if i + 1 < n else ""
166
+ if RESPONSE_MARKER in nxt:
167
+ continue
168
+ head = next((str(doc.ycells[j]["source"]).splitlines()[0] for j in range(i, -1, -1)
169
+ if doc.ycells[j].get("cell_type") == "markdown" and str(doc.ycells[j]["source"]).startswith("#")), None)
170
+ out.append({"index": i, "id": doc.ycells[i].get("id"), "cell_type": doc.ycells[i].get("cell_type"),
171
+ "heading_above": head, "source": src})
172
+ return out
173
+
174
+
175
+ @mcp.tool()
176
+ async def add_comment(path: str, after: int | str, response: str, instance: str | None = None) -> dict:
177
+ """Answer a mention: insert a markdown response cell (carrying the jcollab-response marker) below cell `after`."""
178
+ ds = await _ds(instance, path)
179
+ async with ds.lock:
180
+ doc = await ds.doc()
181
+ pos = await ds.index(after) + 1
182
+ with doc._ydoc.transaction():
183
+ doc.ycells.insert(pos, _new_ycell(doc, "markdown", f"{RESPONSE_MARKER}\n{response}", None))
184
+ return await _done(ds, index=pos, id=doc.ycells[pos].get("id"))
185
+
186
+
187
+ @mcp.tool()
188
+ async def insert_cell_at_start(path: str, source: str, cell_type: str = "markdown", instance: str | None = None) -> dict:
189
+ """Insert a cell as the first cell of the notebook."""
190
+ ds = await _ds(instance, path)
191
+ async with ds.lock:
192
+ doc = await ds.doc()
193
+ with doc._ydoc.transaction():
194
+ doc.ycells.insert(0, _new_ycell(doc, cell_type, source, None))
195
+ return await _done(ds, index=0, id=doc.ycells[0].get("id"))
196
+
197
+
198
+ @mcp.tool()
199
+ async def edit_cell(path: str, cell: int | str, source: str, instance: str | None = None) -> dict:
200
+ """Replace a cell's source with minimal text edits (open editors keep cursor/scroll). Does not change the cell type."""
201
+ ds = await _ds(instance, path)
202
+ async with ds.lock:
203
+ yc = await ds.ycell(cell)
204
+ with (await ds.doc())._ydoc.transaction():
205
+ set_text(yc["source"], source)
206
+ return await _done(ds, index=await ds.index(cell))
207
+
208
+
209
+ @mcp.tool()
210
+ async def change_cell_type(path: str, cell: int | str, cell_type: str, instance: str | None = None) -> dict:
211
+ """Convert a cell to code/markdown/raw (keeps id, source and metadata; drops outputs when leaving code)."""
212
+ ds = await _ds(instance, path)
213
+ async with ds.lock:
214
+ doc = await ds.doc()
215
+ i = await ds.index(cell)
216
+ old = doc.ycells[i]
217
+ new = _new_ycell(doc, cell_type, str(old["source"]), old["metadata"].to_py())
218
+ new["id"] = old.get("id")
219
+ with doc._ydoc.transaction():
220
+ del doc.ycells[i]
221
+ doc.ycells.insert(i, new)
222
+ return await _done(ds, index=i)
223
+
224
+
225
+ @mcp.tool()
226
+ async def delete_cell(path: str, cell: int | str, instance: str | None = None) -> dict:
227
+ ds = await _ds(instance, path)
228
+ async with ds.lock:
229
+ doc = await ds.doc()
230
+ i = await ds.index(cell)
231
+ with doc._ydoc.transaction():
232
+ del doc.ycells[i]
233
+ return await _done(ds, deleted=i)
234
+
235
+
236
+ @mcp.tool()
237
+ async def move_cell(path: str, cell: int | str, to: int, instance: str | None = None) -> dict:
238
+ """Move a cell so that it ends up at index `to` (counted after removal)."""
239
+ ds = await _ds(instance, path)
240
+ async with ds.lock:
241
+ doc = await ds.doc()
242
+ i = await ds.index(cell)
243
+ data = doc.get_cell(i)
244
+ with doc._ydoc.transaction():
245
+ del doc.ycells[i]
246
+ doc.ycells.insert(max(0, min(to, doc.cell_number)), doc.create_ycell(data))
247
+ return await _done(ds, moved_from=i, to=to)
248
+
249
+
250
+ @mcp.tool()
251
+ async def split_cell(path: str, cell: int | str, at_line: int, instance: str | None = None) -> dict:
252
+ """Split a cell before line number `at_line` (0-based); the second part becomes a new cell right after."""
253
+ ds = await _ds(instance, path)
254
+ async with ds.lock:
255
+ doc = await ds.doc()
256
+ i = await ds.index(cell)
257
+ yc = doc.ycells[i]
258
+ lines = str(yc["source"]).split("\n")
259
+ head, tail = "\n".join(lines[:at_line]), "\n".join(lines[at_line:])
260
+ with doc._ydoc.transaction():
261
+ set_text(yc["source"], head)
262
+ doc.ycells.insert(i + 1, _new_ycell(doc, yc["cell_type"], tail, yc["metadata"].to_py()))
263
+ return await _done(ds, first=i, second=i + 1)
264
+
265
+
266
+ @mcp.tool()
267
+ async def merge_cells(path: str, first: int | str, last: int | str, instance: str | None = None) -> dict:
268
+ """Merge cells first..last (inclusive) into the first one, joined by blank lines."""
269
+ ds = await _ds(instance, path)
270
+ async with ds.lock:
271
+ doc = await ds.doc()
272
+ a, b = await ds.index(first), await ds.index(last)
273
+ if b <= a:
274
+ raise DocError("last must be after first")
275
+ joined = "\n\n".join(str(doc.ycells[k]["source"]) for k in range(a, b + 1))
276
+ with doc._ydoc.transaction():
277
+ set_text(doc.ycells[a]["source"], joined)
278
+ for k in range(b, a, -1):
279
+ del doc.ycells[k]
280
+ return await _done(ds, merged_into=a)
281
+
282
+
283
+ # ── metadata ───────────────────────────────────────────────────────────────────────────────────────────────────
284
+
285
+ @mcp.tool()
286
+ async def get_metadata(path: str, cell: int | str | None = None, key: str | None = None, instance: str | None = None) -> Any:
287
+ """Metadata of a cell (cell given) or of the notebook (cell=None, e.g. key='scenes_data'). Whole dict if key is None."""
288
+ ds = await _ds(instance, path)
289
+ doc = await ds.doc()
290
+ meta = doc._ymeta["metadata"].to_py() if cell is None else (await ds.cell(cell))["metadata"]
291
+ return meta if key is None else meta.get(key)
292
+
293
+
294
+ @mcp.tool()
295
+ async def set_metadata(path: str, key: str, value: Any = None, cell: int | str | None = None, delete: bool = False,
296
+ instance: str | None = None) -> dict:
297
+ """Set (or delete) one metadata key on a cell (cell given) or the notebook (cell=None). Values are JSON."""
298
+ ds = await _ds(instance, path)
299
+ async with ds.lock:
300
+ doc = await ds.doc()
301
+ meta = doc._ymeta["metadata"] if cell is None else (await ds.ycell(cell))["metadata"]
302
+ with doc._ydoc.transaction():
303
+ if delete:
304
+ if key in meta:
305
+ del meta[key]
306
+ else:
307
+ meta[key] = copy.deepcopy(value)
308
+ return await _done(ds, scope="notebook" if cell is None else f"cell {cell}", key=key, deleted=delete)
309
+
310
+
311
+ # ── execution ──────────────────────────────────────────────────────────────────────────────────────────────────
312
+
313
+ @mcp.tool()
314
+ async def run_cell(path: str, cell: int | str, instance: str | None = None, timeout: float = 300) -> list[Any]:
315
+ """Run a code cell like the user would: shows [*] while busy, streams outputs, sets the kernel's execution count
316
+ and the execute-time timing metadata. Returns the outputs (images included)."""
317
+ ds = await _ds(instance, path)
318
+ r = await kernel.run_cell(ds, cell, timeout)
319
+ head = {k: v for k, v in r.items() if k != "outputs"}
320
+ head.update(await _done(ds))
321
+ return [head] + render(r["outputs"])
322
+
323
+
324
+ @mcp.tool()
325
+ async def run_cells(path: str, start: int | str, end: int | str, instance: str | None = None, timeout: float = 300,
326
+ stop_on_error: bool = True) -> list[dict]:
327
+ """Run code cells start..end (inclusive) one after another. Returns one status line per cell (no outputs)."""
328
+ ds = await _ds(instance, path)
329
+ a, b = await ds.index(start), await ds.index(end)
330
+ res = []
331
+ for i in range(a, b + 1):
332
+ if (await ds.cell(i))["cell_type"] != "code":
333
+ continue
334
+ r = await kernel.run_cell(ds, i, timeout)
335
+ res.append({k: v for k, v in r.items() if k != "outputs"} | {"errors": [o["ename"] for o in r["outputs"] if o["output_type"] == "error"]})
336
+ if stop_on_error and (r["status"] != "ok" or r["timed_out"]):
337
+ break
338
+ return res
339
+
340
+
341
+ @mcp.tool()
342
+ async def run_code(path: str, code: str, instance: str | None = None, timeout: float = 60) -> list[Any]:
343
+ """Quick inspection in the notebook's kernel (variables, shapes, small checks). Not recorded in history, uses no
344
+ execution count, leaves the notebook untouched. Do NOT use for real computations; put those in a visible cell."""
345
+ ds = await _ds(instance, path)
346
+ r = await kernel.run_code(ds.rest, ds.path, code, timeout, await ds.doc())
347
+ return render(r["outputs"]) + (["[timed out, kernel interrupted]"] if r["timed_out"] else [])
348
+
349
+
350
+ @mcp.tool()
351
+ async def clear_outputs(path: str, cell: int | str | None = None, instance: str | None = None) -> dict:
352
+ """Clear outputs and execution count of one cell, or of all cells if `cell` is None."""
353
+ ds = await _ds(instance, path)
354
+ async with ds.lock:
355
+ doc = await ds.doc()
356
+ idx = range(doc.cell_number) if cell is None else [await ds.index(cell)]
357
+ with doc._ydoc.transaction():
358
+ for i in idx:
359
+ yc = doc.ycells[i]
360
+ if yc.get("cell_type") == "code":
361
+ set_outputs(yc, [])
362
+ yc["execution_count"] = None
363
+ if "execution" in yc["metadata"]:
364
+ del yc["metadata"]["execution"]
365
+ return await _done(ds, cleared=len(idx))
366
+
367
+
368
+ @mcp.tool()
369
+ async def inspect_variables(path: str, instance: str | None = None, pattern: str = "") -> list[Any]:
370
+ """Names, types and shapes of user variables in the kernel (silent, no history entry)."""
371
+ code = (
372
+ "import re as _re\n"
373
+ "_rows=[]\n"
374
+ "for _n,_v in list(globals().items()):\n"
375
+ " if _n.startswith('_') or _n in ('In','Out','get_ipython','exit','quit'): continue\n"
376
+ f" if not _re.search({pattern!r}, _n): continue\n"
377
+ " if type(_v).__name__ in ('module','function','builtin_function_or_method','type'): continue\n"
378
+ " _s=getattr(_v,'shape',None); _l=len(_v) if hasattr(_v,'__len__') else None\n"
379
+ " _rows.append(f'{_n}: {type(_v).__name__}'+(f' shape={_s}' if _s is not None else (f' len={_l}' if _l is not None else f' = {_v!r:.60}')))\n"
380
+ "print('\\n'.join(_rows) or '(no user variables)')\n"
381
+ "del _re,_rows")
382
+ return await run_code(path, code, instance)
383
+
384
+
385
+ # ── kernel ─────────────────────────────────────────────────────────────────────────────────────────────────────
386
+
387
+ async def _kid(ds: DocSession) -> str:
388
+ s = await ds.rest.session_for(ds.path)
389
+ if not s:
390
+ raise kernel.KernelError(f"{ds.path} has no kernel session (run a cell first)")
391
+ return s["kernel"]["id"]
392
+
393
+
394
+ @mcp.tool()
395
+ async def kernel_status(path: str, instance: str | None = None) -> dict:
396
+ ds = await _ds(instance, path)
397
+ s = await ds.rest.session_for(ds.path)
398
+ if not s:
399
+ return {"kernel": None}
400
+ k = (await ds.rest.request("GET", f"api/kernels/{s['kernel']['id']}")).json()
401
+ return {"id": k["id"], "name": k["name"], "execution_state": k["execution_state"], "connections": k["connections"],
402
+ "last_activity": k["last_activity"]}
403
+
404
+
405
+ @mcp.tool()
406
+ async def interrupt_kernel(path: str, instance: str | None = None) -> str:
407
+ ds = await _ds(instance, path)
408
+ await ds.rest.kernel_action(await _kid(ds), "interrupt")
409
+ return "interrupted"
410
+
411
+
412
+ @mcp.tool()
413
+ async def restart_kernel(path: str, instance: str | None = None, run_all: bool = False, clear: bool = False,
414
+ timeout: float = 300) -> Any:
415
+ """Restart the notebook's kernel (all in-memory state is lost). Optional: clear outputs first, run all cells after."""
416
+ ds = await _ds(instance, path)
417
+ if clear:
418
+ await clear_outputs(path, None, instance)
419
+ await ds.rest.kernel_action(await _kid(ds), "restart")
420
+ doc = await ds.doc()
421
+ with doc._ydoc.transaction(): # a restart abandons whatever was running
422
+ for i in range(doc.cell_number):
423
+ if doc.ycells[i].get("execution_state") == "running":
424
+ doc.ycells[i]["execution_state"] = "idle"
425
+ if run_all:
426
+ return await run_cells(path, 0, -1, instance, timeout)
427
+ return "restarted"
428
+
429
+
430
+ @mcp.tool()
431
+ async def point_at(path: str, cell: int | str, seconds: float = 30, select: bool = True, instance: str | None = None,
432
+ scroll: bool = True, force_scroll: bool = False, label: str | None = None) -> dict:
433
+ """Show the user which cell you mean: a labelled "Metis" selection appears in that cell in every open tab (and the
434
+ user can jump to it via JupyterLab's Collaborators sidebar). Still also name the cell in words (heading above it,
435
+ first line), the user may not have the notebook scrolled there. With the jcollab-scroll lab extension, the user's
436
+ notebook scrolls to the cell (a toast instead while they type elsewhere, unless force_scroll); label is shown in that toast."""
437
+ return await (await _ds(instance, path)).point_at(cell, seconds=seconds, select=select, scroll=scroll,
438
+ force_scroll=force_scroll, label=label)
439
+
440
+
441
+ @mcp.tool()
442
+ async def user_focus(path: str, instance: str | None = None) -> list[dict]:
443
+ """Where the user is: for every open browser tab the cell (index + id) holding the cursor, line/column and the
444
+ selected text. Use this to resolve "this cell" / "the selected code" / "here". Empty if nobody has a cursor in the
445
+ notebook (the user must have clicked into a cell; cursors expire after ~30 s without activity)."""
446
+ return await (await _ds(instance, path)).user_focus()
447
+
448
+
449
+ @mcp.tool()
450
+ async def sync_health(path: str, instance: str | None = None) -> dict:
451
+ """Is the shared document alive? Connection state, peers (e.g. browser tabs), and whether the file matches."""
452
+ return await (await _ds(instance, path)).health()
453
+
454
+
455
+ def main() -> None:
456
+ import logging
457
+ for n in ("httpx", "httpcore", "websockets", "jupyter_nbmodel_client"):
458
+ logging.getLogger(n).setLevel(logging.WARNING)
459
+ mcp.run()
460
+
461
+
462
+ if __name__ == "__main__":
463
+ main()
@@ -0,0 +1,14 @@
1
+ Metadata-Version: 2.5
2
+ Name: jcollab
3
+ Version: 0.1.0
4
+ Summary: MCP server for live co-editing and running of JupyterLab notebooks (Yjs, verified writes, multi-instance discovery)
5
+ License-Expression: MIT
6
+ Requires-Python: >=3.10
7
+ Requires-Dist: httpx>=0.27
8
+ Requires-Dist: jupyter-core
9
+ Requires-Dist: jupyter-nbmodel-client<2,>=1.5.1
10
+ Requires-Dist: jupyter-ydoc<4,>=3.0
11
+ Requires-Dist: mcp<3,>=2
12
+ Requires-Dist: nbformat
13
+ Requires-Dist: pycrdt<0.14,>=0.12.50
14
+ Requires-Dist: websockets>=13
@@ -0,0 +1,10 @@
1
+ jcollab/__init__.py,sha256=Jc1fuH5VdKyb1DjytZYAZzWqp2augtJH6xHL6zS6AVw,93
2
+ jcollab/discovery.py,sha256=gDnbAlYOumjtvhpVOUHapoFByNUzslN5t7vp749_Aq0,4996
3
+ jcollab/doc.py,sha256=Bnu7It3EWRQqtgy6YsjSGIWUWrTZrwGNmQbumUELunw,14916
4
+ jcollab/kernel.py,sha256=U00M5pmm1UI5oOBOpP22Il_n5JOHaiHjqi9NrwuAKkA,9897
5
+ jcollab/rest.py,sha256=-XorHuN-8iWo82zSinjsEXNXZ34UKXZTC-NKQAD4mMc,2293
6
+ jcollab/server.py,sha256=giveriIKclwIBuBPqsxDrTZ6X_r4_It6eyFFNvWfX0k,22222
7
+ jcollab-0.1.0.dist-info/METADATA,sha256=2C2jBid80EZxV1WX10MVtV2IavqVGxlT4OvOoeaOZ0o,479
8
+ jcollab-0.1.0.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
9
+ jcollab-0.1.0.dist-info/entry_points.txt,sha256=h-5SuMaiVTElQLLUQ9ayftx-hi8iGTYycO1eucSPE2c,48
10
+ jcollab-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.32.4
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ jcollab = jcollab.server:main