2026-09-06 JupyterLab as a Supervised Service
Second session of the day on Jupyter, separate topic from 2026-09-06 JupyterLab Overrides Were Never Read. Advances JupyterLab Claude Code, specifically its “manage multi claude code instances” action.
What I set out to do
Turn the hand-started JupyterLab into a launchd unit, wrap FastMCP so any Claude Code session could connect and multiplex onto it, reach it from the phone over Tailscale, and drop authentication so the phone needs no token.
What I actually found
The FastMCP wrapper did not need writing. jupyter_server_mcp 0.2.2 arrives
transitively with jupyter-ai 3.1.3, was already enabled, and was already
listening on 127.0.0.1:3001 inside the running server. It serves 18 tools over
streamable HTTP at /mcp: 16 from jupyter_ai’s notebook and jupyterlab
toolkits, 2 from jupyterlab_commands_toolkit (list_all_commands,
execute_command). Three concurrent FastMCP clients multiplexed onto it without
complaint, all seeing the same open notebook. The whole proposal was already
half-built and unwired.
So the work collapsed to three declarative pieces: a launchd agent, a
type: "http" entry in mcp.nix, and pinning the ports.
What was striking
PID liveness is not a staleness test. The runtime dir held 1211 files, 4.8M,
going back to July 2025. Testing jpserver-<pid>.json by whether that PID is
alive reported seven of eleven as ALIVE, because macOS had recycled those PIDs
onto siriknowledged, cfprefsd, ospredictiond and friends. The real
discriminator is whether the live process is the jupyterlab python. Exactly one
was. See A Recycled PID Fakes a Live Process.
The IPC transport has a 103-character ceiling. Yesterday’s
c.KernelManager.transport = "ipc" was never actually exercised: the running
server predated the deploy by two hours and its kernels were still TCP. Testing
it from a scratch runtime dir failed with zmq.error.ZMQError: ipc path ... is longer than 103 characters (sizeof(sockaddr_un.sun_path)). That was my long
scratch path, not the real one, but the real one has only 15 characters of
headroom. Moving the runtime dir would break every kernel start with an obscure
ZMQ error.
Jupyter warns about exactly what we asked for: All authentication is disabled. Anyone who can connect to this server will be able to run code. That
is the correct posture here only because ServerApp.ip stays loopback and
tailscale serve is the sole route in. Since Jupyter ties those two traits to
nothing, tests/jupyter-server-auth now does: widen the bind address while the
token is empty and the flake check fails. Same test pins port_retries = 0, so
a taken port fails loudly rather than leaving the server alive on a port nothing
points at.
What went wrong
I said I would verify on a scratch port so the live server stayed up, and the
live server went down anyway, taking two kernels with it. I cannot prove the
mechanism: no crash report, nothing in the scratch logs, a clean exit. The
candidates are my temporarily moving its two kernel connection files (restored a
minute later, and it survived that check) and the terminal that owned it ending.
The notebook file itself was intact at 35 cells. The isolation I claimed was
port-level; the shared per-user state under ~/Library/Jupyter was not isolated
at all.
Outcome
Committed as 0d26025. launchctl shows org.jupyter.lab running, never
exited, both ports loopback-only, 18 MCP tools live, and
https://atlas.tail0cef90.ts.net:8449/lab returning 200 over the tailnet with
no token.
Addendum: reaching it without typing the tailnet
atlas already resolves bare, since MagicDNS puts the tailnet in the search
domain, which is why ssh atlas works. What fails is TLS: Tailscale’s
certificate covers atlas.tail0cef90.ts.net only, so https://atlas dies with
tlsv1 alert internal error. Serving the same backend over --http removes the
certificate from the equation, and http://atlas reaches Lab with no tailnet
name, port or path. WireGuard still encrypts the wire; what an http:// origin
gives up is the browser secure-context APIs, so the HTTPS mapping on :8449 stays
alongside for anything needing them.
Declared as a new programs.tailscaleServe.httpPorts. Committed 10bb477.
Then reversed the target. Giving http://atlas to JupyterLab spent the
node’s root namespace on whichever service happened to want a short name first,
and atlas also runs Open WebUI, SigNoz, Seerr, SillyTavern, LiteLLM, Suwayomi
and a media gateway. The node name should answer “what is on this machine”, so
it went to Glance, the dashboard that indexes the eight tailnet ports nobody
remembers, with Jupyter added to it as a monitor site (89a966f). That is the
problem the short name was reaching for anyway.
Chased first, and worth not re-chasing: a real jupyter.<tailnet> needs either
a Tailscale Service (tagged hosts only, and per Tailscale’s own docs a device
cannot hold both a tag and a user, so tagging a laptop strips its user and
breaks Taildrop) or a second tailnet node. The second node is a legitimate,
Tailscale-sanctioned pattern — tsnet, tsnsrv, caddy-tailscale all do it,
and a userspace tailscaled from nixpkgs runs fine beside the macOS system
extension, which I verified as far as the login URL before cancelling. It is
still a whole daemon bought to make one URL read better. Not worth it for one
service; worth revisiting if owui, signoz and glance all want names.
Two routes rejected, both tested rather than assumed:
tailscale serve --service=svc:jupyterwould give a realjupyter.tail0cef90.ts.netwith no port. It fails withservice hosts must be tagged nodes; atlas is user-owned, and tagging means an admin-consoletagOwnersentry plus a node re-auth that transfers ownership to the tailnet.- Path routing on the existing :443.
--set-path=/jupyterstrips the prefix before proxying, so/jupyter/api/statusreturned 200 — but Lab’s HTML emitssrc="/static/lab/main.js", which 404s through the prefix. The API answers and the UI does not load. Settingbase_urlto fix the asset paths then collides with the strip. Checking the rendered page rather than the endpoint is what caught this.