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 overstdioor 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
/mcpendpoint. 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-variantit can be any engine from thecode-sandboxespackage.
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-urland--jupyter-tokenset both sides at once.connectβ point a running Streamable HTTP server at a document and code sandbox, through itsPUT /api/connectroute.stopβ release the server's code sandbox, throughDELETE /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).
MCPServerWithCORSsubclasses the SDK'sMCPServer. Itsstreamable_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'sAuthenticationMiddlewarewith the SDK'sBearerAuthBackendwhen a token verifier is configured, and CORS.- Its
call_tool()keeps the reason when a tool fails. mcp 2 would otherwise reduce any exception toError 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(...)(withToolAnnotations) and wrapped in@with_hooks, and delegates to a tool class throughsafe_notebook_operation(), which retries on a dropped WebSocket. - Custom routes:
PUT /api/connect,DELETE /api/stop,GET /api/healthz. - The
jupyter_citeprompt, andget_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.
| Category | Tools |
|---|---|
| Server | list_files, list_kernels, connect_to_jupyter |
| Notebook management | use_notebook, list_notebooks, restart_notebook, unuse_notebook, read_notebook |
| Cells | insert_cell, insert_execute_code_cell, overwrite_cell_source, edit_cell_source, execute_cell, read_cell, delete_cell, clear_cell_output, move_cell, execute_code |
| Prompt | jupyter_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
ServerAppmanagers βcontents_manager,kernel_manager,kernel_spec_manager,session_managerβ registered by the extension throughjupyter_extension/context.py. This mode is selected only when the extension's document configuration is local; - in MCP_SERVER mode, two
JupyterServerClientinstances,sandbox_server_clientanddocument_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:
| Route | Handler | Purpose |
|---|---|---|
/mcp | MCPSSEHandler | MCP over HTTP: initialize, tools/list, tools/call, β¦ as JSON-RPC |
/mcp/healthz | MCPHealthHandler | Health check |
/mcp/tools/list | MCPToolsListHandler | The tool list, from get_registered_tools() |
/mcp/tools/call | MCPToolsCallHandler | Call 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 inJUPYTER_MCP_TOKEN_VERIFIER_CLASSβ an OAuth resource server, a platform identity β and nothing else changes.IdentityMiddlewarepublishes 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 thesandboxestoolset, routing ofexecute_codeto 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 configureddocument_idas the current notebook at startup, in either mode.models.pyβ Pydantic models:NotebookandCell(the nbformat shapes the tools read),DocumentCodeSandbox(whatconnectsends).utils.pyβ output extraction and formatting (text andImageContent),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β
- The client
POSTs a JSON-RPCtools/callto/mcpwith a bearer token. - CORS, then
AuthenticationMiddlewareverifies the token with the configuredTokenVerifier;IdentityMiddlewarerecords the caller. - The SDK dispatches to the
@mcp.toolwrapper;@with_hooksfiresBEFORE_TOOL_CALL. - The wrapper calls the tool class with
mode=MCP_SERVER, thesandbox_server_clientand thenotebook_manager. - The tool edits the notebook through its
NotebookConnection(Y.js) or runs code through the notebook'sCodeSandboxClient, reporting progress through the MCPContextso long executions keep the client alive. AFTER_TOOL_CALLfires; the result is returned as MCP content blocks β text, andImageContentfor figures. A failure comes back as anisErrorresult carrying the tool's own message.
The Jupyter Server extensionβ
- The client
POSTs the same JSON-RPC to Jupyter's/mcp, authenticated with a Jupyter token. MCPSSEHandler.prepare()requires a user and sets the request's identity fromcurrent_user.- For
tools/call, the handler either forwards the call to JupyterLab (ajupyter-mcp-toolstool) or callsmcp.call_tool()on the sameMCPServerinstance, so the very same wrapper and tool class run. - The tool gets
mode=JUPYTER_SERVERand theServerAppmanagers, and works on the file or the collaborative YDoc directly. - The
CallToolResultis 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β
- The sandboxes extension's
intercept_execute_code()is asked first; when a sandbox is selected withuse_sandboxor--sandbox-variant, the code runs there and the core path is skipped. - Otherwise the core runs the code on the notebook's kernel,
BEFORE_EXECUTE/AFTER_EXECUTEfiring around it.
Startupβ
jupyter-mcp-server startparses the options and fillsJupyterMCPConfig.- Importing
server.pycreates theMCPServer, registers the core tools, then discovers extensions and lets them register theirs. - In MCP_SERVER mode the code sandbox is started or attached, and the configured notebook enrolled; the OpenTelemetry hook is registered when requested.
- For Streamable HTTP, the token verifier is resolved
(
JUPYTER_MCP_TOKEN_VERIFIER_CLASS, else--mcp-token) and uvicorn servesmcp.streamable_http_app(); for stdio,mcp.run()takes over. - In JUPYTER_SERVER mode the extension app does the equivalent when Jupyter
Server loads it: it fills the configuration from its traits, registers the
ServerAppin the context, and mounts the/mcphandlers.
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