af-jupyterlab-mcp
MCP server that lets AF users create, inspect, and delete their own per-user JupyterLab servers on the UChicago ATLAS Analysis Facility Kubernetes cluster — the same notebooks af-portal deploys today, exposed as tools for LLMs.
Architecture
LLM <--MCP/HTTP--> af-jupyterlab-mcp <--k8s API--> notebook namespace (Pod/Service/Secret/Ingress)
^
| Authorization: Bearer <broker-issued JWT>
|
af-mcp-platform credential broker
This repo ships three groups of tools: six that manage the Pod/Service/
Secret/Ingress quadruple for a notebook, ported from af-portal's
portal/jupyterlab.py and its four Jinja templates; seventeen nb_* tools that
proxy calls into the Datalayer jupyter-mcp-server running inside the notebook
itself (4ea435f), so a session can drive code execution inside the user's own
notebook without the notebook token ever entering LLM context — see
maniaclab/af-mcp-platform#189;
and 52 nb_ui_* tools that drive the user's open JupyterLab tab through the
jupyter-mcp-tools extension.
Tool surface
75 tools total. Every tool's MCP annotations declare its
read-only/mutating/destructive status (see CLAUDE.md's "Tool registration
pattern"); the column below mirrors that.
Notebook server management (k8s/notebooks.py, k8s/gpu.py)
| Tool | Does | Kind |
|---|---|---|
create_jupyter_server |
Create a per-user JupyterLab server (pod+service+secret+ingress) | mutating |
list_jupyter_servers |
List the caller's own JupyterLab servers | read-only |
get_jupyter_server |
Get rich status for one of the caller's own JupyterLab servers | read-only |
delete_jupyter_server |
Delete one of the caller's own JupyterLab servers (all four objects) | destructive |
get_gpu_availability |
Get cluster-wide GPU availability, optionally filtered by product | read-only |
list_supported_images |
List the CPU and GPU images allowed by create_jupyter_server |
read-only |
The owner of every server is always claims.unixname from the verified broker
JWT — no tool takes an owner/username argument.
Notebook content proxy (k8s/proxy.py, upstream: jupyter-mcp-server)
Every nb_* tool takes notebook_server_id first, verifies the caller owns
that pod, checks it is Ready, and forwards the call to the notebook's own
jupyter-mcp-server with the notebook token injected server-side (never
returned to the caller).
| Tool | Does | Kind |
|---|---|---|
nb_list_files |
List files on the notebook server's filesystem | read-only |
nb_list_kernels |
List all running kernels on the notebook server | read-only |
nb_list_notebooks |
List notebooks open on the notebook server | read-only |
nb_use_notebook |
Connect to or create a notebook on the notebook server | mutating |
nb_unuse_notebook |
Disconnect from a notebook on the notebook server | mutating |
nb_restart_notebook |
Restart a notebook's kernel on the notebook server | destructive |
nb_read_notebook |
Read a notebook's cells from the notebook server | read-only |
nb_read_cell |
Read a cell from the active notebook | read-only |
nb_insert_cell |
Insert a cell at a given index in the active notebook | mutating |
nb_overwrite_cell_source |
Overwrite the source of a cell in the active notebook | destructive |
nb_edit_cell_source |
Edit part of a cell's source in the active notebook | mutating |
nb_delete_cell |
Delete one or more cells from the active notebook | destructive |
nb_move_cell |
Move a cell to a different index in the active notebook | mutating |
nb_execute_cell |
Execute a specific cell in the active notebook | destructive |
nb_insert_execute_code_cell |
Insert a code cell and immediately execute it | destructive |
nb_execute_code |
Execute arbitrary code in the notebook server's kernel | destructive |
nb_clear_cell_output |
Clear a code cell's outputs, keeping the cell | destructive |
The three code-execution tools (nb_execute_cell,
nb_insert_execute_code_cell, nb_execute_code) are marked destructive even
though "execute" isn't literally a delete: they can mutate anything the kernel
can reach, which is what the annotation communicates to a client.
Cell-addressing tools accept either a positional index or the cell's stable
cell_id (given both, the id wins); prefer ids, since an index goes stale as
soon as a cell is inserted above it.
JupyterLab UI proxy (tools/nb_ui.py, upstream: jupyter-mcp-tools)
Every nb_ui_* tool proxies one JupyterLab command through the same
ownership/readiness/token path as the nb_* tools, but the command runs
inside the user's open JupyterLab browser tab (relayed over a websocket by
the jupyter-mcp-tools extension) and acts on the active notebook, console, or
selection there. With no tab open, the call fails. The notebook image must
allowlist each command
(maniaclab/ml_platform#14).
Tool names are nb_ui_ + the upstream id with - → _ (upstream ids are
JupyterLab command ids with : → _, e.g. notebook:run-all-cells →
notebook_run-all-cells → nb_ui_notebook_run_all_cells). Tools that take
arguments beyond notebook_server_id are noted.
| Area | Tools (nb_ui_ prefix omitted) |
Kind |
|---|---|---|
| Notebook | notebook_get_selected_cell, notebook_move_cursor_down, notebook_move_cursor_up, notebook_extend_marked_cells_below, notebook_extend_marked_cells_above, notebook_copy_cell |
read-only |
| Notebook | notebook_insert_cell_below, notebook_insert_cell_above, notebook_paste_cell_below, notebook_paste_cell_above, notebook_move_cell_up, notebook_move_cell_down, notebook_split_cell_at_cursor, notebook_change_cell_to_code, notebook_change_cell_to_markdown, notebook_change_cell_to_raw |
mutating |
| Notebook | notebook_delete_cell, notebook_cut_cell, notebook_merge_cell_above, notebook_merge_cell_below, notebook_run_all_cells, notebook_run_cell, notebook_run_cell_and_select_next, notebook_run_cell_and_insert_below, notebook_append_execute (source, cell_type) |
destructive |
| Console | console_create (path, insert_mode, activate) |
mutating |
| Console | console_clear, console_interrupt_kernel, console_inject (code, path, activate) |
destructive |
| Documents | docmanager_open (path, factory), docmanager_new_untitled (content_type, path, ext), docmanager_save, docmanager_duplicate |
mutating |
| File browser | filebrowser_go_to_path (path), filebrowser_refresh, filebrowser_toggle_hidden_files |
read-only |
| File browser | filebrowser_create_new_directory |
mutating |
| Kernel | kernelmenu_reconnect_to_kernel |
mutating |
| Kernel | kernelmenu_interrupt, kernelmenu_shutdown |
destructive |
| UI | application_toggle_left_area, application_toggle_right_area, application_toggle_presentation_mode, apputils_change_theme (theme), editmenu_open, filemenu_open, helpmenu_open |
read-only |
| Search | documentsearch_start (search_text), documentsearch_highlightNext, documentsearch_highlightPrevious |
read-only |
| Terminal | terminal_create_new, terminal_refresh |
mutating |
"Read-only" here includes commands that only change view state (cursor,
selection, layout, search, theme). Commands that execute code are destructive,
like the nb_execute_* tools.
Deliberately not proxied (and not allowlisted in the image):
filebrowser_upload/filebrowser_download— they open a file picker or save into the human's browser; no bytes ever reach the MCP client.docmanager_delete/docmanager_rename/docmanager_save-as,kernelmenu_change,kernelmenu_restart,console_restart-kernel— they block on a modal dialog that needs a human click. Usenb_restart_notebookto restart a kernel without the UI.
Images with different tool sets
The tool list above is fixed, but the notebook behind a call can run any
allowlisted image, and older images offer fewer tools (e.g. ml-platform:2026.3
has no jupyter-mcp-server at all; images with jupyter-mcp-server 1.x lack
clear_cell_output and all but two UI commands). The chart value
notebook.images.toolOverrides maps an exact image string to the upstream
jupyter-mcp-server tool ids that image offers (e.g. read_cell,
notebook_run-all-cells). Images without an entry are assumed to support every
tool, matching the latest ml-platform image. Calling a tool a notebook's image
does not offer returns a clear "not supported by image" error naming the image,
without contacting the notebook.
On a jupyter-mcp-server 1.x image, the 2.x cell_id-style arguments are not
understood: 1.x silently drops unknown arguments, so passing both
cell_index and cell_id uses the index (2.x would use the id), and passing
cell_id alone fails upstream because cell_index is required there.
See Contributing for development setup.