Skip to main content

Extensions

Everything this server serves arrives as an extension: the notebook tools it ships, the sandbox lifecycle tools in extensions/sandboxes, and whatever a deployment installs beside them. There is no privileged core registering functions on a server object and a plugin mechanism bolted on beside it β€” there is one mechanism, and the server uses it for itself.

That mechanism is reactor_mcp_server, a foundation for building MCP servers out of plugins. Three of its ideas decide the shape of this page:

  • a tool is a contribution β€” an extension offers a ToolSpec, and a host builds a server from what has been offered;
  • a contribution can be extended β€” an extension may narrow, wrap or re-describe a tool another extension offered, by name;
  • the URL decides what is served β€” tools belong to named toolsets, and a client picks them in the URL it connects to.

This page is both halves: how to write an extension, and how the ones in this repository are built β€” because they are the same mechanism, and the shipped ones are the worked examples.

The shape of it​

Two things are served, and they are the same tools either way:

  • the module-level server (mcp in server.py) β€” what the CLI and the Jupyter Server extension run. register_extension_tools() puts every extension's tools on it;
  • a server built per toolset selection β€” what a host serving toolsets by URL uses. The Datalayer gateway does this, so /mcp?only=notebooks is the notebook tools and nothing else.

Writing one​

Subclass JupyterMCPExtension and override only what you need. Every hook has a default.

from reactor import PluginCompatibility, PluginManifest
from reactor_mcp_server import tool

from jupyter_mcp_server.extensions import JupyterMCPExtension


class MyExtension(JupyterMCPExtension):
def manifest(self) -> PluginManifest:
return PluginManifest(
name="my-extension",
version="1.0.0",
description="What it adds.",
author="You",
compatibility=PluginCompatibility(api_version="v1"),
)

@tool()
async def my_tool(self, argument: str) -> str:
"""What it does."""
return "…"

A tool is declared, not registered: the server collects what extensions offer and builds a server from it. The function stays an ordinary method, so a test can call it without a server. The docstring is what a model reads.

Publish it on the entry-point group:

[project.entry-points."reactor.mcp.extensions"]
my-extension = "my_package:MyExtension"

Being installed is all it takes. The server discovers extensions at startup.

The hooks​

HookFor
manifest()What the extension says about itself before anything runs it
tools()The tools this extension offers
tool_extensions()What it does to another extension's tools
toolsets()The named sets its tools belong to, and whether they are on by default
resources() / prompts()Callables that register MCP resources or prompts on a server
capabilities()Declare what the server can now do
create_code_sandbox(config, logger)Provide a kernel for a sandbox variant
intercept_execute_code(code, timeout)Handle execute_code yourself
on_start() / on_stop()Acquire and release resources
on_server(server)Act on a built server, once its tools are on it

The first six are reactor_mcp_server's and work on any host built on it; the two in the middle are this server's own.

For observing calls rather than changing them β€” logging, metrics, auditing β€” use Hooks instead: they fire around every tool call and kernel execution without an extension having to own a tool.

When your extension runs, and why it matters​

After the server is configured. Extensions used to register at import, which is earlier than it sounds: the CLI imports the server module in order to start it, so extensions registered while the command line was still being parsed. An extension asking what the server was pointed at was told the default however the server had been invoked β€” and the only place the intent existed yet was sys.argv.

That is fixed. get_config() inside a tool, or inside tools(), gives you the truth. If you read sys.argv to work around it, you can stop.

In name order, for reproducibility rather than for precedence. importlib.metadata returns entry points in whatever order the installation produced, which varies between a wheel and an editable install; sorting by name makes two runs on two machines build the same server.

on_start() is called where the tools are registered, so an extension with work to do once β€” registering a hook, opening a client β€” does it with the command line already read. on_stop() is called when the server shuts down.

on_server(server) is different and rarer: it is handed the built server itself, after every extension's tools are on it, for the things a tool list cannot express β€” taking a tool off, say, because the deployment it runs on has nothing for it to talk to. It is called once per server built, and a host that builds one per toolset selection calls it for each.

Extending another extension's tool​

Order no longer decides which extension wins. It used to: an extension narrowing another's tool had to register a tool of the same name after it, because the SDK keeps the original when a name is registered twice β€” so a replacement that ran first silently did nothing, and whether it ran first depended on how the names happened to sort. Extending a tool is something an extension says, not something it has to be named for:

class DatalayerSandboxes(JupyterMCPExtension):
def tool_extensions(self):
def on_datalayer(handler):
async def narrowed(sandbox_name: str, environment: str = "ai-env"):
return await handler(sandbox_name, variant="datalayer")

return narrowed

return (("launch_sandbox", ToolExtension(
wrap=on_datalayer,
description="Launch a sandbox on Datalayer.",
)),)

tool_extensions() names the tool and says what to do to it: wrap the handler, replace what a model reads, replace the annotations. They are applied in a declared order, whichever order the extensions loaded in, and the result is one tool rather than two with the same name. An extension of a tool nobody offered simply never applies β€” which is the right behaviour when the thing being extended is not installed.

The server's own tools are extended the same way, which the next section is about.

Declaring a capability​

If your extension genuinely lets the server do something, say so, rather than leaving the core to guess from what is installed:

from jupyter_mcp_server.capabilities import Capability


def capabilities(self) -> list[Capability]:
return [
Capability(
name="myext.snapshots",
description="Snapshot and restore the sandbox filesystem.",
enabled=True,
source="my-extension",
)
]

Namespace the name; the registry is shared. It then appears in the server's advertised capabilities and at capabilities://, where a client finds it. See Capabilities.

One extension raising in capabilities() costs only its own declarations β€” the others are still collected, and the failure is logged.

The server's own tools are an extension​

The eighteen tools in server.py are written as they always were, with @mcp.tool decorators on ordinary functions. What changed is that they are also offered β€” by a built-in extension that reads them back off the server the decorators registered them on:

# jupyter_mcp_server/server.py β€” after the last decorator
SCAFFOLD_TOOLS: tuple[str, ...] = tuple(mcp._tool_manager._tools)
# jupyter_mcp_server/core_tools.py
class CoreToolsExtension(McpExtension):
def toolsets(self):
return (Toolset(name=CORE_TOOLSET, description="Read, edit and run notebooks…"),)

def tools(self):
# lifted once, from SCAFFOLD_TOOLS, as ToolSpec values

The snapshot is taken at import, after the decorators and before any extension registers, so it names what is ours: an extension's tools land on the same server later, and lifting those back into contributions would offer each of them twice. The lift happens once and is kept, for the same reason.

CoreToolsExtension is registered by ExtensionManager.discover() itself rather than published on the entry-point group β€” it contributes what this distribution ships, and a distribution cannot be missing itself.

Why it matters​

An extension can narrow one of them, by name, the way the section above describes β€” because extending a tool is declared against a contribution, and a decorated function is not one. An extension that addresses notebooks by uid rather than by path used to have to reach into the running server's tool manager, take use_notebook off and register a replacement; it writes this instead:

def tool_extensions(self):
return (("use_notebook", ToolExtension(wrap=by_name, description="…")),)

A host can build a server that has them. A server built from contributions alone would carry every extension's tools and none of the notebook ones β€” the notebook tools missing from the notebook server. /mcp?only=notebooks is those eighteen and nothing else.

A built server is not only its tools​

Everything else the module-level server holds is registered on it at import too: the capabilities:// resource, the notebook resource templates, the jupyter_cite prompt, the request handlers for resources/subscribe, logging/setLevel and the task methods, and the management routes. None of it is a contribution and some of it could not be one.

So a built server is furnished with them, on on_server β€” the hook for what a tool list cannot express:

def on_server(self, server) -> None:
if server is mcp:
return # it already has its own
furnish(server, mcp)

Leaving them out is worse than losing a tool. initialize computes resources.subscribe from whether the handler is served, so a built server without it tells every client this deployment does not do subscriptions β€” and a client that reads the capability never asks.

The extension manager​

ExtensionManager is a thin layer over McpHost, which owns the reactor platform: manifests, compatibility, activation events, enablement and disposal are all its. What this class adds is the three hooks that are about this server rather than about MCP.

MethodWhat it does
discover()Registers CoreToolsExtension, then every entry point on reactor.mcp.extensions, sorted by name. JUPYTER_MCP_EXTENSIONS narrows it to the names it lists.
register(extension)Adds one to the platform. Any McpExtension is welcome, not only a JupyterMCPExtension.
get(name)One registered extension by manifest name β€” None when it is not installed, which is an ordinary configuration.
tools()Every tool on offer, extensions applied.
register_tools(mcp, once=…)Puts them on a server, runs each extension's on_server, and starts the platform.
collect_capabilities(registry)Asks each extension once what it adds.
start() / stop()Starts the platform and fires on_start / on_stop.
create_code_sandbox(...) / intercept_execute_code(...)The Jupyter hooks, dispatched to the extensions in turn.

register_tools replaces a tool of the same name rather than adding a second one: the SDK keeps the first registration and warns, so what the host resolved β€” the tool with its extensions applied β€” would otherwise lose to the unextended original, in a log nobody reads.

Registering is also what starts the extensions, so on_start fires where the tools have just been put on a server and the command line has been read. register_extension_tools() in server.py is the one entry point the CLI, the Jupyter Server extension and a tool listing all reach:

def register_extension_tools() -> None:
extension_manager.register_tools(mcp, once=True)
extension_manager.collect_capabilities(get_capabilities())

The sandboxes extension​

extensions/sandboxes is a distribution of its own β€” jupyter-mcp-sandboxes β€” that ships in this repository and installs separately. It is the worked example of every hook this server offers.

[project.entry-points."reactor.mcp.extensions"]
sandboxes = "jupyter_mcp_sandboxes:SandboxesExtension"

What it declares​

class SandboxesExtension(JupyterMCPExtension):
def __init__(self) -> None:
self._manager = CodeSandboxManager()

def manifest(self) -> PluginManifest:
return PluginManifest(name="jupyter-mcp-sandboxes", version="0.1.0", …)

def toolsets(self) -> tuple[Toolset, ...]:
return (Toolset(name="sandboxes", description="Launch, use and terminate code sandboxes."),)

The toolset is named sandboxes β€” for its subject, not for the package. A tool that names no toolset goes in one named after its plugin, and that is what this used to do: a deployment adding its own sandbox tools under sandboxes (a snapshot, an attached dataset, an environment catalogue) ended up with the two split, and ?only=sandboxes answered with tools that each need a sandbox and no way to launch one. One name for one subject, and both distributions declare it.

The four tools​

tools() returns them as ToolSpec values, each with the annotations a client reads before calling it:

ToolAnnotations say
launch_sandboxdestructive, not idempotent (each call costs another sandbox), open world (it runs arbitrary code)
list_sandboxesread-only, idempotent, closed world
use_sandboxdestructive, idempotent β€” selecting the same one again leaves the same selection
terminate_sandboxdestructive, idempotent β€” terminating one already gone leaves it gone, which is what a client needs to know when a call times out

The handlers are built inside tools() so they close over the extension's own CodeSandboxManager and the ServerContext, and each wears the same decorators a core tool does β€” @structured(...) for the result shape and @with_hooks(...) so a call fires the hook pair like any other.

def tools(self) -> list[ToolSpec]:
manager = self._manager
server_context = ServerContext.get_instance()

@structured("sandbox.launch")
@with_hooks("launch_sandbox")
async def launch_sandbox(sandbox_name: str, variant: … = None, …) -> ToolAnswer:
"""Launch a sandbox for this session to run code in."""

return [
ToolSpec(name="launch_sandbox", handler=launch_sandbox,
annotations=LAUNCH_SANDBOX_ANNOTATIONS),
…
]

Declared rather than registered: the host collects them, applies whatever other extensions have contributed to them, and puts the result on a server. launch_sandbox is the one downstream extensions narrow β€” which they now do by name instead of by loading second.

The two Jupyter hooks​

This is the part that is about this server rather than about MCP, and it is what makes a sandbox more than a set of tools: it becomes where code runs.

create_code_sandbox(config, logger) β€” asked when a notebook needs a kernel. It answers None unless the configuration names a non-jupyter-server variant; then it hands back the sandbox the caller selected with use_sandbox, if there is a live one, and a fresh one otherwise. Reusing the selection is what makes "assign the sandbox to the notebook" true β€” creating a new one here would ignore the choice, pay for a second runtime, and run the cell somewhere other than where the caller pointed. A selection that fails its liveness check is not handed back: a dead client as the notebook's backend is one the recovery path can never replace.

intercept_execute_code(code, timeout) β€” asked before the kernel-backed path runs. It answers None when no sandbox is selected, and otherwise runs the code on the active one.

Lifecycle, and what it lends downstream​

@property
def sandboxes(self) -> CodeSandboxManager:
"""The sandboxes this extension launched, for extensions built on it."""
return self._manager

def on_stop(self) -> None:
self._manager.terminate_all()

The property is public on purpose. A downstream extension adding a sandbox tool β€” a snapshot, a mount β€” has to act on this registry, the one launch_sandbox wrote to and use_sandbox points into; building its own would be a second set of sandboxes with the same names, where "the active one" means two different things depending on which tool you called. Reaching for _manager instead works until this class holds its sandboxes some other way, so it is reached through the manager:

extension = get_extension_manager().get("jupyter-mcp-sandboxes")
registry = getattr(extension, "sandboxes", None)

An extension somebody else ships​

Nothing in this repository knows the difference between an extension shipped alongside it and one installed from elsewhere β€” which is the point of the entry-point group. The Datalayer gateway is the worked example of the far side: it publishes three extensions on the same group and, between them,

  • offers its own tools (spaces, the content catalogue, snapshots, benchmarks), declaring sandboxes so they join the ones above;
  • extends three of this server's: use_notebook takes a name where the scaffold takes a path, list_notebooks answers with the caller's spaces, launch_sandbox takes an environment and a GPU rather than a provider;
  • acts on the built server with on_server, taking off the three tools that assume a local Jupyter β€” list_files, list_kernels, connect_to_jupyter β€” because a deployment pointed at spaces has nothing for them to talk to.

None of that is in this repository, and none of it needs to be.

Where the code is​

PathWhat it holds
jupyter_mcp_server/extensions.pyJupyterMCPExtension, ExtensionManager, discovery
jupyter_mcp_server/core_tools.pyCoreToolsExtension and furnish()
jupyter_mcp_server/server.pyThe tools themselves, SCAFFOLD_TOOLS, register_extension_tools()
extensions/sandboxes/The jupyter-mcp-sandboxes distribution

See also​

  • Capabilities β€” the registry an extension declares into.
  • Hooks β€” observing calls rather than changing them.
  • Code sandboxes β€” the engines behind the sandbox variants.