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:jupyter would give a real jupyter.tail0cef90.ts.net with no port. It fails with service hosts must be tagged nodes; atlas is user-owned, and tagging means an admin-console tagOwners entry plus a node re-auth that transfers ownership to the tailnet.
  • Path routing on the existing :443. --set-path=/jupyter strips the prefix before proxying, so /jupyter/api/status returned 200 — but Lab’s HTML emits src="/static/lab/main.js", which 404s through the prefix. The API answers and the UI does not load. Setting base_url to fix the asset paths then collides with the strip. Checking the rendered page rather than the endpoint is what caught this.