Completion, documentation, definitions and the rest, from a language server that sees the environment the code will run in.

The server next to the kernel

emjupy talks LSP to the language server that jupyter-lsp runs on the Jupyter server, over that server's own WebSocket – the same transport the notebook already uses, and the arrangement JupyterLab's jupyterlab-lsp uses.

The consequences are worth stating plainly:

  • Nothing written on the remote. The code is sent in-band with didChange, which is what LSP is for. The local shadow file is written once, when it is created, for eglot to attach to.
  • No second connection. Completion and documentation use no TRAMP, no ssh and no directory probing: measured, zero remote filesystem operations. Only opening a definition that lives on the kernel's machine goes over TRAMP, since that is a file there.
  • The right environment. The server runs beside the kernel, so completions describe the packages the code will actually import, and M-. finds your own modules because the server resolves them against the notebook's own directory.

Requirements

Two packages must be installed in the Python environment that runs jupyter server:

bash
pip install jupyter-lsp 'python-lsp-server[all]'

In a conda environment file:

yaml
dependencies:
  - jupyter-lsp
  - python-lsp-server

They serve different purposes:

Package Purpose
jupyter-lsp adds the /lsp/ endpoints to Jupyter
jupyterlab-lsp JupyterLab front end; requires jupyter-lsp
python-lsp-server the Python language server

Installing jupyterlab-lsp does not install a language server. An environment containing it alone provides the endpoints with no server behind them.

Locating the language server

jupyter-lsp detects a server by importing it: importlib.util.find_spec("pylsp"), evaluated in the interpreter running jupyter server, and started as [sys.executable, "-m", "pylsp"]. The PATH variable is not consulted.

A pylsp installed elsewhere on the machine is therefore not found. It must be importable by the interpreter running jupyter server.

This is a property of the Jupyter server, not of a project: one server requires one installation regardless of how many notebooks it serves.

eglot over the Jupyter WebSocket

Every eglot command works against the language server running beside the kernel – rename, formatting, code actions, find-implementation, and whatever eglot gains next. Nothing is wired up per command.

eglot reaches a server through three jsonrpc generics, and jsonrpc-connection-send dispatches on the connection class, so a subclass can carry the traffic anywhere. emjupy-eglot-server carries it over the same WebSocket the notebook already uses. jsonrpc-process-connection type-checks for a process object, so one is stood up and then ignored: it satisfies the check and carries nothing.

The document has two names – the shadow file on this machine, which is what eglot manages, and the path the kernel would see, which is what the server knows – so every message is rewritten in one direction or the other as it passes through. That is the single place the two have to agree.

Completion, eldoc, M-. and every other eglot command go the same way: to the shadow buffer, where eglot is attached.

With this transport the shadow file is always local: it is something for eglot to attach to, not something the language server reads – the server is sent its contents over the socket. emjupy-shadow-host plays no part; it applies only to the SSH arrangement described in Working with a remote server.

eglot commands in a notebook

eglot's own commands look for the server of the buffer they run in, and a notebook buffer is not the one eglot manages – run there unassisted, M-x eglot-rename fails with "No current JSON-RPC connection". So the commands in emjupy-eglot-delegated-commands – eglot-rename, eglot-code-actions, eglot-format and the eglot-find-* family – run in the shadow buffer at the position matching point, with any edits pulled back into the cells. Because that buffer holds every cell at once, a rename fixes references in all of them, not only the cell point was in.

The advice does nothing outside emjupy-mode; M-x emjupy-eglot-remove-advice takes it off entirely.

Jumping to definitions

Because the shadow buffer holds every cell at once, a jump crosses cells: M-. on a call in one cell lands on the definition in another.

M-. works inside a notebook. eglot installs its xref backend in the buffer it manages – the hidden shadow buffer, not the notebook – so emjupy adds a backend that forwards there and maps the answer back.

Remote M-. needs remote LSP

The language server that answers decides what can be found, because it can only read the files on the machine it runs on.

Language server Your own modules Standard library and packages
beside the kernel (remote) found found – the kernel's own
on this machine (fallback) not found found – but THIS machine's, not the kernel's

So for a notebook whose kernel runs elsewhere, M-. into your own code needs jupyter-lsp and a language server installed where the kernel runs – see Requirements. Without them emjupy falls back to a language server on this machine and says so when it starts; the standard library still resolves, but to this machine's copy, which can be a different Python. For a kernel on this machine the two are the same machine, and either works.

M-x emjupy-lsp-diagnose says which server a notebook is using: "over the Jupyter WebSocket" is the one beside the kernel, "a local process" is the fallback.

Jumping into modules beside a remote notebook

A definition the server beside the kernel reports is a path on the kernel's machine, and emjupy opens it there, over TRAMP. The host comes from emjupy-remote-root if it names one, and otherwise from the SSH tunnel the server is reached through – found by reading the ssh process on this machine, so nothing needs configuring when the server is behind ssh -L. Every path is sent there, the standard library's included: the kernel's Python is what the code runs against.

When there is no way to reach that machine – no tunnel to read, nothing configured – emjupy says which file it could not open and that emjupy-remote-root is what would reach it, rather than opening a file of the same name on this machine.

Paths from the fallback server are left as they are: they name this machine's files.

Eglot is part of the package

eglot ships with Emacs 30.1, which this package requires, and it is loaded with emjupy: the WebSocket transport inherits from Eglot's own connection class. Loaded is not started – nothing connects to a language server until a notebook asks for completion or documentation, and emjupy works without one.

Language server requests

Requests are asynchronous. eldoc runs after every command, so a blocking request would cost one round trip per keystroke. Completion answers from the previous reply and requests the next in the background.

The handshake is also asynchronous. jupyter-lsp starts the language server on demand, so connecting may take several seconds; until it completes, no answers are available. If the socket cannot be opened, another attempt is not made for emjupy-lsp-retry-interval seconds.

Per-command cost, measured on a twelve-cell notebook containing figures:

Operation Time
cursor motion 0.0 ms
completion 0.1 ms
eldoc 1.2 ms

When a shadow file would describe the wrong machine

If the kernel runs somewhere this Emacs cannot reach, and emjupy-shadow-host does not say how to get there, the shadow file – and so the language server – is local. What it describes is this machine: its interpreter, its packages, and none of the modules beside the notebook. For ordinary Python that is mostly right and worth having, which is why it is the default; for anything that depends on the environment the code will actually run in, it is confidently wrong, so emjupy says so once per notebook.

Set emjupy-shadow-when-kernel-unreachable to nil to have no language support at all in that case rather than approximate support. Either way, emjupy-shadow-host or jupyter-lsp on the server gives answers from the right machine.

When the language server cannot be reached

In the SSH arrangement, where the shadow file sits on the remote over TRAMP, completion and eldoc ask for the shadow buffer after every command. If the host is slow or unreachable each attempt blocks Emacs while TRAMP waits – so one failure repeated per keystroke would be indistinguishable from a hang.

emjupy gives setup emjupy-shadow-timeout seconds (2 by default) and, if it fails, stops trying for emjupy-shadow-retry-interval seconds (30), saying so once. That covers both halves: creating the file, and starting the language server – the second being the expensive one, and the one that otherwise repeats after every command when the host has no pylsp. Editing and running cells never depend on the shadow buffer, so they keep working: only completion, eldoc and M-. are lost until the host answers again.

Falling back

Set emjupy-lsp-enabled to nil for the older arrangement, which needs no jupyter-lsp on the server: the language server runs on this machine, or over SSH beside the kernel – set up as in Working with a remote server.

Checking it from the shell

bash
curl -s "http://localhost:9999/lsp/status?token=YOUR_REAL_TOKEN"

Substitute the real token; the string YOUR_REAL_TOKEN will be refused. The three answers mean three different things:

Answer Meaning
404 (an HTML page) jupyter-lsp is not installed
{"message": "Forbidden"} (403) the token was wrong, nothing more
{"version": 2, "sessions": {...}} the plumbing works; read what it lists

On a working setup sessions names the language servers actually installed:

{"version": 2, "sessions": {"pylsp": {"status": "not_started", ...}}}

An empty sessions is the giveaway that the plumbing is there and the language server is not. emjupy-lsp-server (default "pylsp") names which of the listed servers to ask for.

emjupy makes the same check for you: when it cannot reach a language server it asks /lsp/status itself and says which of these three situations it found, rather than failing silently.