Skip to main content

Architecture

Jupyter MCP Server has two deployment entry points and two backend access modes. They usually line up, but the Jupyter Server extension deployment can use either access mode:

  • A standalone deployment, started with jupyter-mcp-server start, serves MCP over stdio or Streamable HTTP and uses MCP_SERVER access: remote HTTP and WebSocket clients for documents and execution.
  • A Jupyter Server extension deployment serves MCP at the host server's /mcp endpoint. With local URLs it uses JUPYTER_SERVER access and calls the host managers directly. With a non-local document URL it switches to MCP_SERVER access and connects to the configured remote services.

Two things are deliberately kept apart, because a deployment can host them on different servers:

  • the document side β€” where the notebook files live (--document-url, a Jupyter server or Datalayer spaces, selected by --document-provider), edited through the collaborative document so that what the agent changes is what a person editing the same notebook sees;
  • the execution side β€” the code sandbox that runs the cells (--code-sandbox-url). By default that is a Jupyter kernel; with --sandbox-variant it can be any engine from the code-sandboxes package.

At a glance​

Both deployments expose the core notebook and cell tools, but their complete tool lists can differ. A local extension omits connect_to_jupyter, and an extension can add configured tools from jupyter-mcp-tools. The extension's location therefore does not by itself determine either its backend access mode or its exposed tool set.

High-level view​

Whichever way a call comes in, it ends in the same place: a tool's execute() method, told which ServerMode it is running in and handed the clients or managers that mode provides.

The access mode changes how tools reach Jupyter. In MCP_SERVER mode, the document server and execution server can be different remote services. In JUPYTER_SERVER mode, the extension uses the managers of the Jupyter Server process it is running inside. Tool discovery is handled separately: the local extension removes connect_to_jupyter and can append its configured JupyterLab tools.

How a notebook gets an execution backend​

The notebook document and the process that executes its code are separate. NotebookManager records the association between a notebook opened with use_notebook and its execution backend. That backend may be a kernel on the document's Jupyter Server, a kernel on another Jupyter Server, or a non-Jupyter sandbox.

A Jupyter session is metadata maintained by a Jupyter Server: it associates a notebook path with a kernel id so frontends such as JupyterLab can find the kernel for that notebook. It is not the notebook document and it is not the kernel itself. Non-Jupyter sandboxes do not need a Jupyter session.

For a Jupyter-backed kernel, registering a new notebook-to-kernel binding also creates the corresponding Jupyter session when one does not already exist. An existing session is left unchanged. This rule does not apply to non-Jupyter sandboxes.

use_notebook first determines whether it is opening a notebook or returning to one that is already managed:

For a newly managed notebook, an explicit kernel_id takes precedence over both the current sandbox selection and any existing notebook session.

When kernel_id names an existing kernel, validation uses the appropriate Jupyter kernel list or local kernel manager. kernel_id=NEW deliberately bypasses the active use_sandbox selection. Without a kernel_id, the exact live sandbox most recently selected by use_sandbox wins; otherwise an existing notebook session is reused through the normal sandbox-selection path. If neither exists, use_notebook does not create a kernelβ€”the first execution attaches one.

Components​

CLI (cli/)​

A Typer application with three commands, installed as jupyter-mcp-server and runnable as python -m jupyter_mcp_server:

  • start β€” run the server. Its options fall into families: the transport (--transport stdio|streamable-http, --port), the document side (--document-url, --document-id, --document-token, --document-provider), the execution side (--code-sandbox-url, --sandbox-variant, --start-new-code-sandbox, --code-sandbox-id, …), MCP authentication (--mcp-token, --insecure-mcp-noauth), execution limits (--execution-timeout, --max-execution-timeout) and observability (--otel-file). --jupyter-url and --jupyter-token set both sides at once.
  • connect β€” point a running Streamable HTTP server at a document and code sandbox, through its PUT /api/connect route.
  • stop β€” release the server's code sandbox, through DELETE /api/stop.

The CLI fills the JupyterMCPConfig singleton, starts (or attaches to) the code sandbox, auto-enrolls the configured notebook, registers the OpenTelemetry hook when asked to, and then runs the transport.

Server layer (server.py)​

The MCP protocol implementation, on the MCP Python SDK (mcp 2).

  • MCPServerWithCORS subclasses the SDK's MCPServer. Its streamable_http_app() builds the ASGI application stateless and wraps it in, innermost first: IdentityMiddleware (publishes the verified caller to the tools), ManagementRouteSecurityMiddleware (Host/Origin checks and bearer authentication on the management routes), Starlette's AuthenticationMiddleware with the SDK's BearerAuthBackend when a token verifier is configured, and CORS.
  • Its call_tool() keeps the reason when a tool fails. mcp 2 would otherwise reduce any exception to Error executing tool <name>; the tools raise plain errors carrying exactly what the agent needs to recover β€” which notebook is not connected, which index is out of range β€” so the message is put back.
  • Every tool is registered with @mcp.tool(...) (with ToolAnnotations) and wrapped in @with_hooks, and delegates to a tool class through safe_notebook_operation(), which retries on a dropped WebSocket.
  • Custom routes: PUT /api/connect, DELETE /api/stop, GET /api/healthz.
  • The jupyter_cite prompt, and get_registered_tools(), which the Jupyter extension uses to expose the tool list without hardcoding it.

Transports are described in MCP Transports.

Tools (tools/)​

Each tool is a class deriving from BaseTool with one method, execute(mode, sandbox_server_client, contents_manager, kernel_manager, notebook_manager, ...). The ServerMode argument (MCP_SERVER or JUPYTER_SERVER) selects the execution path; the other arguments are whatever that mode can provide.

CategoryTools
Serverlist_files, list_kernels, connect_to_jupyter
Notebook managementuse_notebook, list_notebooks, restart_notebook, unuse_notebook, read_notebook
Cellsinsert_cell, insert_execute_code_cell, overwrite_cell_source, edit_cell_source, execute_cell, read_cell, delete_cell, clear_cell_output, move_cell, execute_code
Promptjupyter_cite

Extensions add tools of their own (see Extensions); the bundled sandboxes extension contributes launch_sandbox, list_sandboxes, use_sandbox and terminate_sandbox.

Configuration (config.py)​

JupyterMCPConfig, a Pydantic model held as a singleton (get_config() / set_config()), is the one place the CLI options, the extension traits and PUT /api/connect all write to. It carries the transport, the document side (document_provider, document_url, document_id, document_token), the execution side (code_sandbox_url, sandbox_variant, code_sandbox_id, tokens, GPU and environment hints), the execution timeouts, and the JupyterLab tools allowlist.

Its resolved_document_token and resolved_code_sandbox_token prefer the caller's credential when a request carries one (see Identity), and fall back to the configured token β€” the single-user case.

Every option is listed in Configuration.

Server context (server_context.py, jupyter_extension/context.py)​

ServerContext is a singleton that works out the mode at startup and holds what the tools need in that mode:

  • in JUPYTER_SERVER mode, the ServerApp managers β€” contents_manager, kernel_manager, kernel_spec_manager, session_manager β€” registered by the extension through jupyter_extension/context.py. This mode is selected only when the extension's document configuration is local;
  • in MCP_SERVER mode, two JupyterServerClient instances, sandbox_server_client and document_server_client, since the two halves may be different servers, together with the authentication headers for each. A standalone deployment always uses this mode, and an extension deployment also uses it when configured with a non-local document URL. Password-protected Jupyter servers are handled by logging in for a session cookie (auth.py) and logging in again when a request is refused.

Notebooks and code sandboxes (notebook_manager.py, sandbox_client.py)​

NotebookManager tracks the notebooks the agent has opened with use_notebook, which one is current, and the code sandbox attached to each. "Code sandbox" is the generic name for whatever executes a notebook's cells: a Jupyter kernel reached over HTTP/WebSocket, an engine from the code-sandboxes package (jupyter-server, datalayer, daytona, e2b, coreweave, cloudflare, google-colab, kaggle, modal, monty, eval, docker), or, inside the Jupyter extension, a plain handle to a kernel the local kernel manager owns.

use_notebook follows the execution context the caller already established. Without kernel_id, a code sandbox already selected with use_sandbox is bound to the notebook. If no sandbox is selected but the notebook has a live Jupyter session, use_notebook triggers use_sandbox for that session's kernel and says so in its reply, so the agent and JupyterLab share one kernel. If neither exists, no kernel is created; the notebook opens without a backend.

An explicit kernel_id is verified to be alive. If the notebook has no session for that kernel, use_notebook creates one so JupyterLab can find it. Pass kernel_id=NEW when an isolated kernel is wanted instead of an existing one.

In MCP_SERVER mode NotebookConnection opens the notebook's collaborative document with NbModelClient (jupyter-nbmodel-client over Y.js), so cell edits land in the same document the user's JupyterLab is showing. Execution goes through a CodeSandboxClient built by sandbox_client.py.

By default that client drives the kernel from this process and writes each output into the document as it arrives, so a cell's outputs stop if this process stops β€” a gateway rollout, an idle reap, a crash. Set execute_via_http (env JUPYTER_MCP_EXECUTE_VIA_HTTP) and, on the default jupyter-server variant, execute_cell instead POSTs to the runtime's own /api/kernels/{id}/execute route (jupyter-server-nbmodel) and polls the request it returns. The runtime runs the cell and writes the outputs into the collaborative document server-side, so a run β€” and its outputs β€” survive the loss of this process; what this process loses when it stops is only the ability to keep reporting progress. There is no fallback: once execute_via_http is on for a variant that has the route, this is the execution path for execute_cell β€” a run whose document or cell id cannot be resolved is raised as an error (so it is fixed), not quietly downgraded to the worker-driven WebSocket path where the outputs would be lost. Variants with no such route (Modal, E2B, …) still execute through their sandbox client, and execute_code, which targets no cell, always uses the WebSocket path β€” the durability is a property of cell execution, where there is a document cell to write into.

Backends (jupyter_extension/backends/)​

Backend is the abstract interface for notebook and kernel operations β€” reading and writing cells, creating notebooks, starting, restarting and interrupting kernels, executing a cell. Two implementations:

  • LocalBackend β€” the Jupyter Server this process is embedded in: serverapp.contents_manager, serverapp.kernel_manager, and the collaborative YDoc when the notebook is open, with no network in between.
  • RemoteBackend β€” a Jupyter server reached over HTTP and WebSocket, with the document and execution halves addressed separately. Cell operations go through the collaborative document rather than the contents API, so they never overwrite what another client has in flight. Starting and driving execution is left to the sandbox layer, which knows which variant a deployment runs.

Jupyter Server extension (jupyter_extension/)​

JupyterMCPServerExtensionApp registers the server as a Jupyter Server extension (enabled by the shipped jupyter-config/ file). Its traits mirror the CLI options β€” document_url, code_sandbox_url, document_id, start_new_code_sandbox, document_provider, allowed_jupyter_mcp_tools, otel_file, … β€” with "local" meaning "this server".

Its Tornado handlers, all behind Jupyter's own authentication, are:

RouteHandlerPurpose
/mcpMCPSSEHandlerMCP over HTTP: initialize, tools/list, tools/call, … as JSON-RPC
/mcp/healthzMCPHealthHandlerHealth check
/mcp/tools/listMCPToolsListHandlerThe tool list, from get_registered_tools()
/mcp/tools/callMCPToolsCallHandlerCall one tool by name

The user Jupyter authenticated becomes the request's identity, so a tool sees the same Identity here as in MCP_SERVER mode.

When JupyterLab is running, the extension can also expose JupyterLab tools from the jupyter-mcp-tools package, filtered by allowed_jupyter_mcp_tools and cached by tool_cache.py; calls to those are routed to JupyterLab rather than to a tool class. See Streamable HTTP with the Jupyter Server extension.

Identity and authentication​

Each mode authenticates with the stack it is embedded in β€” a token verifier in MCP_SERVER mode, Jupyter's identity provider in JUPYTER_SERVER mode β€” and identity.py gives both one shape:

  • Identity β€” who the caller is: username, client_id, scopes, and the credential to present to the document and sandbox servers on their behalf.
  • TokenVerifier β€” the protocol the MCP SDK expects (verify_token(token)). The default, CodeSandboxTokenVerifier, checks the shared secret from --mcp-token; a deployment names its own in JUPYTER_MCP_TOKEN_VERIFIER_CLASS β€” an OAuth resource server, a platform identity β€” and nothing else changes.
  • IdentityMiddleware publishes the verified caller for the duration of one request, which is why the Streamable HTTP transport runs stateless: a stateful session would pin every later call to whoever opened it.
  • current_identity() is how a tool, or the configuration, reaches the caller.

The security pages cover this in depth: Identity and the rest of Security.

Extensions​

extensions.py lets separate packages plug into the server without the core knowing them β€” and the server uses the same mechanism for its own tools, so there is one way in rather than a core and a plugin system beside it.

Discovery, contributions and lifecycle run on reactor_mcp_server, an extensible MCP server foundation built on reactor. An extension is published on the reactor.mcp.extensions entry-point group, subclasses JupyterMCPExtension, and overrides what it offers β€” manifest(), tools(), tool_extensions(), toolsets(), resources(), on_server(server), on_start() / on_stop() β€” plus the two hooks that are about this server rather than about MCP: create_code_sandbox(config, logger) and intercept_execute_code(code, timeout). A tool is offered rather than registered, which is what lets another extension narrow it by name instead of registering the same name and hoping to load second.

This server's own eighteen tools are offered the same way, by a built-in CoreToolsExtension in the notebooks toolset, and one Python extension ships in extensions/:

  • jupyter-mcp-sandboxes (extensions/sandboxes) β€” the sandbox lifecycle tools (launch_sandbox, list_sandboxes, use_sandbox, terminate_sandbox) in the sandboxes toolset, routing of execute_code to the selected sandbox, and sandbox-backed kernels for the non-Jupyter variants.

The extension for the datalayer document provider β€” listing the notebooks in a user's spaces, resolving a notebook by name β€” lives in the Datalayer gateway that serves it, not here. It is the same entry-point group: nothing about this repository knows the difference between an extension shipped alongside it and one installed from elsewhere, which is the point of the group.

How both are implemented, with the diagrams: Extensions.

extensions/ also holds packaging that is not Python β€” the MCPB bundle for one-click installation in Claude Desktop (extensions/mcpb), a Claude plugin (extensions/claude-plugin) and prompt templates (extensions/prompt-templates).

Hooks and observability (hooks.py, otel_hook.py)​

HookRegistry is a singleton dispatching five events β€” BEFORE_TOOL_CALL, AFTER_TOOL_CALL, BEFORE_EXECUTE, AFTER_EXECUTE, KERNEL_LIFECYCLE β€” to registered HookHandlers. @with_hooks fires the tool-call pair around every tool; the execution tools and the shared execution helpers in utils.py fire the execute pair around each kernel run; use_notebook, restart_notebook and unuse_notebook report kernel lifecycle.

The built-in OTelHookHandler turns these into OpenTelemetry spans written as JSON lines by FileSpanExporter; it is enabled by --otel-file, the JUPYTER_MCP_OTEL_FILE variable, or the extension's otel_file trait, and never propagates errors into a tool call. See Hooks and Observability.

Supporting modules​

  • enroll.py β€” auto-enrolls the configured document_id as the current notebook at startup, in either mode.
  • models.py β€” Pydantic models: Notebook and Cell (the nbformat shapes the tools read), DocumentCodeSandbox (what connect sends).
  • utils.py β€” output extraction and formatting (text and ImageContent), safe_notebook_operation(), code sandbox helpers for MCP_SERVER mode, and the local execution helpers for JUPYTER_SERVER mode.
  • jupyter_extension/protocol/messages.py β€” the request and response models of the extension's HTTP API.

A tool call, end to end​

Streamable HTTP, MCP_SERVER mode​

  1. The client POSTs a JSON-RPC tools/call to /mcp with a bearer token.
  2. CORS, then AuthenticationMiddleware verifies the token with the configured TokenVerifier; IdentityMiddleware records the caller.
  3. The SDK dispatches to the @mcp.tool wrapper; @with_hooks fires BEFORE_TOOL_CALL.
  4. The wrapper calls the tool class with mode=MCP_SERVER, the sandbox_server_client and the notebook_manager.
  5. The tool edits the notebook through its NotebookConnection (Y.js) or runs code through the notebook's CodeSandboxClient, reporting progress through the MCP Context so long executions keep the client alive.
  6. AFTER_TOOL_CALL fires; the result is returned as MCP content blocks β€” text, and ImageContent for figures. A failure comes back as an isError result carrying the tool's own message.

The Jupyter Server extension​

  1. The client POSTs the same JSON-RPC to Jupyter's /mcp, authenticated with a Jupyter token.
  2. MCPSSEHandler.prepare() requires a user and sets the request's identity from current_user.
  3. For tools/call, the handler either forwards the call to JupyterLab (a jupyter-mcp-tools tool) or calls mcp.call_tool() on the same MCPServer instance, so the very same wrapper and tool class run.
  4. The tool gets mode=JUPYTER_SERVER and the ServerApp managers, and works on the file or the collaborative YDoc directly.
  5. The CallToolResult is serialized in wire form and returned as the JSON-RPC response.

prompts/list, prompts/get, resources/list, resources/templates/list and resources/read take the same route: the handler asks the same MCPServer instance, so the extension serves the prompts and resources the standalone server serves. resources/subscribe is the exception β€” it is driven over the SDK's own session bus, which a Tornado request does not have, so a client that wants notifications about a resource uses the standalone server.

execute_code with a sandbox variant​

  1. The sandboxes extension's intercept_execute_code() is asked first; when a sandbox is selected with use_sandbox or --sandbox-variant, the code runs there and the core path is skipped.
  2. Otherwise the core runs the code on the notebook's kernel, BEFORE_EXECUTE / AFTER_EXECUTE firing around it.

Startup​

  1. jupyter-mcp-server start parses the options and fills JupyterMCPConfig.
  2. Importing server.py creates the MCPServer, registers the core tools, then discovers extensions and lets them register theirs.
  3. In MCP_SERVER mode the code sandbox is started or attached, and the configured notebook enrolled; the OpenTelemetry hook is registered when requested.
  4. For Streamable HTTP, the token verifier is resolved (JUPYTER_MCP_TOKEN_VERIFIER_CLASS, else --mcp-token) and uvicorn serves mcp.streamable_http_app(); for stdio, mcp.run() takes over.
  5. In JUPYTER_SERVER mode the extension app does the equivalent when Jupyter Server loads it: it fills the configuration from its traits, registers the ServerApp in the context, and mounts the /mcp handlers.

Source layout​

jupyter_mcp_server/
β”œβ”€β”€ cli/ Typer CLI: start, connect, stop
β”‚ └── commands/
β”œβ”€β”€ server.py MCPServerWithCORS, tool registration, custom routes
β”œβ”€β”€ tools/ One class per tool, plus the jupyter_cite prompt
β”‚ └── _base.py BaseTool, ServerMode
β”œβ”€β”€ config.py JupyterMCPConfig singleton
β”œβ”€β”€ server_context.py Mode detection, clients and managers
β”œβ”€β”€ server_modes.py Mode helpers shared by the tools
β”œβ”€β”€ notebook_manager.py Open notebooks and their code sandboxes
β”œβ”€β”€ sandbox_client.py CodeSandboxClient construction
β”œβ”€β”€ identity.py Identity, TokenVerifier, IdentityMiddleware
β”œβ”€β”€ auth.py Password login to Jupyter servers
β”œβ”€β”€ extensions.py JupyterMCPExtension, ExtensionManager (reactor)
β”œβ”€β”€ hooks.py HookEvent, HookRegistry, @with_hooks
β”œβ”€β”€ otel_hook.py OpenTelemetry spans to a JSONL file
β”œβ”€β”€ enroll.py Auto-enrollment of the configured notebook
β”œβ”€β”€ tool_cache.py Cache of the JupyterLab tools
β”œβ”€β”€ models.py Notebook, Cell, DocumentCodeSandbox
β”œβ”€β”€ utils.py Output handling, execution helpers
└── jupyter_extension/
β”œβ”€β”€ extension.py JupyterMCPServerExtensionApp
β”œβ”€β”€ handlers.py /mcp, /mcp/healthz, /mcp/tools/list, /mcp/tools/call
β”œβ”€β”€ context.py ServerApp registration for JUPYTER_SERVER mode
β”œβ”€β”€ backends/ Backend, LocalBackend, RemoteBackend
└── protocol/ Request and response models

extensions/
β”œβ”€β”€ sandboxes/ jupyter-mcp-sandboxes extension
β”œβ”€β”€ mcpb/ MCPB bundle for Claude Desktop
β”œβ”€β”€ claude-plugin/ Claude plugin
└── prompt-templates/ Prompt templates

References​