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 (
mcpinserver.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=notebooksis 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β
| Hook | For |
|---|---|
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.
| Method | What 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:
| Tool | Annotations say |
|---|---|
launch_sandbox | destructive, not idempotent (each call costs another sandbox), open world (it runs arbitrary code) |
list_sandboxes | read-only, idempotent, closed world |
use_sandbox | destructive, idempotent β selecting the same one again leaves the same selection |
terminate_sandbox | destructive, 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
sandboxesso they join the ones above; - extends three of this server's:
use_notebooktakes a name where the scaffold takes a path,list_notebooksanswers with the caller's spaces,launch_sandboxtakes 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β
| Path | What it holds |
|---|---|
jupyter_mcp_server/extensions.py | JupyterMCPExtension, ExtensionManager, discovery |
jupyter_mcp_server/core_tools.py | CoreToolsExtension and furnish() |
jupyter_mcp_server/server.py | The 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.