Extensions
An extension adds tools, replaces tools, provides a kernel, or declares a
capability — without a fork. jupyter-mcp-sandboxes, which adds the sandbox
lifecycle tools, is one.
The shape
Subclass JupyterMCPExtension and override only what you need. Every hook has
a default.
from typing import Any
from reactor import PluginCompatibility, PluginManifest
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"),
)
def register_tools(self, mcp: Any) -> None:
@mcp.tool()
async def my_tool(argument: str) -> str:
"""What it does."""
return "…"
Publish it on the entry-point group:
[project.entry-points."jupyter_mcp_server.extensions"]
my-extension = "my_package:MyExtension"
Being installed is all it takes. The server discovers extensions at startup.
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 register_tools gives you the truth. If
you read sys.argv to work around it, you can stop.
In name order. This is not cosmetic. Registration is not independent: an extension may replace a tool another registered, and the SDK keeps the original when a name is registered twice. So a replacement that happens to run first silently does nothing — no error, no log line, the old tool still there.
importlib.metadata returns entry points in whatever order the installation
produced, which varies between a wheel and an editable install. Sorting by
name makes it something you can rely on: an extension that must run after
another can be named to.
sandboxes registers launch_sandbox
sandboxes-datalayer sorts after it, so it can replace it
Replacing a tool rather than adding one
Remove it first. Registering over a live name does nothing:
def register_tools(self, mcp: Any) -> None:
manager = getattr(mcp, "_tool_manager", None)
if manager is not None:
try:
manager.remove_tool("list_notebooks")
except Exception:
pass # never registered; an upstream rename must not stop startup
@mcp.tool()
async def list_notebooks() -> list[dict]:
"""Yours instead."""
To wrap rather than replace — keeping everything the original does after some argument is resolved — take its function before removing it:
original = manager._tools["use_notebook"].fn
manager.remove_tool("use_notebook")
@mcp.tool()
async def use_notebook(notebook_name: str, notebook_path: str = "", **rest):
"""Accepts a name as well as a uid."""
return await original(notebook_name=notebook_name,
notebook_path=resolve(notebook_path), **rest)
Wrapping is usually the better bargain: everything after the part you care about stays upstream's, and does not rot when upstream changes it.
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 other hooks
| Hook | For |
|---|---|
register_tools(mcp) | Add, replace or wrap tools |
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 |
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.