Skip to content

[Feature] New API to discover, fetch, and call tools from server extensions #1521

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Draft
wants to merge 8 commits into
base: main
Choose a base branch
from
Draft
71 changes: 71 additions & 0 deletions jupyter_server/extension/manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
import importlib
from itertools import starmap

import jsonschema
from tornado.gen import multi
from traitlets import Any, Bool, Dict, HasTraits, Instance, List, Unicode, default, observe
from traitlets import validate as validate_trait
Expand All @@ -13,6 +14,37 @@
from .config import ExtensionConfigManager
from .utils import ExtensionMetadataError, ExtensionModuleNotFound, get_loader, get_metadata

# probably this should go in it's own file? Not sure where though
MCP_TOOL_SCHEMA = {
"type": "object",
"properties": {
"name": {"type": "string"},
"description": {"type": "string"},
"inputSchema": {
"type": "object",
"properties": {
"type": {"type": "string", "enum": ["object"]},
"properties": {"type": "object"},
"required": {"type": "array", "items": {"type": "string"}},
},
"required": ["type", "properties"],
},
"annotations": {
"type": "object",
"properties": {
"title": {"type": "string"},
"readOnlyHint": {"type": "boolean"},
"destructiveHint": {"type": "boolean"},
"idempotentHint": {"type": "boolean"},
"openWorldHint": {"type": "boolean"},
},
"additionalProperties": True,
},
},
"required": ["name", "inputSchema"],
"additionalProperties": False,
}


class ExtensionPoint(HasTraits):
"""A simple API for connecting to a Jupyter Server extension
Expand Down Expand Up @@ -97,6 +129,29 @@ def module(self):
"""The imported module (using importlib.import_module)"""
return self._module

@property
def tools(self):
"""Structured tools exposed by this extension point, if any."""
loc = self.app or self.module
if not loc:
return {}

tools_func = getattr(loc, "jupyter_server_extension_tools", None)
if not callable(tools_func):
return {}

tools = {}
try:
definitions = tools_func()
for name, tool in definitions.items():
# not sure if we should just pick MCP schema or make the schema something the user can pass
jsonschema.validate(instance=tool["metadata"], schema=MCP_TOOL_SCHEMA)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No, let's not require MCP here. Maybe MCP is the default (for now), but we should let server extensions arrive with their own schema that the server can validate against.

Maybe the returned value of the jupyter_server_extension_tools hook is a tuple with the tool definition and the schema to validate against?

Copy link
Author

@Abigayle-Mercer Abigayle-Mercer May 12, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sounds good — so just to clarify, you're thinking one validation schema per extension (covering all of its exposed tools), rather than per tool?

Also, should we assume each tool will include both metadata and a callable? If so, does that remove the need to build something like a server-exposed endpoint for tool execution? I’m thinking specifically of my run function prototype: https://github.com/Abigayle-Mercer/list_ai_tools/blob/main/list_ai_tools/list_ai_tools.py#L103, which takes structured tool calls and executes them dynamically.

Just wondering if you envision those callables staying internal to the server, or whether we’d eventually want something like POST /api/tools/:name to run them.

tools[name] = tool
except Exception as e:
# not sure if this should fail quietly, raise an error, or log it?
print(f"[tool-discovery] Failed to load tools from {self.module_name}: {e}")
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Instead of using print, we should make this a self.log.error.

Before we do that, though, we'll need to make ExtensionPoint a LoggingConfigurable and set the parent trait to ExtensionPackage. I can work on this in a separate PR and we can rebase here once that's merged.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

See #1522. If/when that gets merged, we can rebase your PR to use the logger created there.

return tools

def _get_linker(self):
"""Get a linker."""
if self.app:
Expand Down Expand Up @@ -443,6 +498,22 @@ def load_all_extensions(self):
for name in self.sorted_extensions:
self.load_extension(name)

def get_tools(self) -> Dict[str, Any]:
"""Aggregate tools from all extensions that expose them."""
all_tools = {}

for ext_name, ext_pkg in self.extensions.items():
if not ext_pkg.enabled:
continue

for point in ext_pkg.extension_points.values():
for name, tool in point.tools.items(): # <— new property!
if name in all_tools:
raise ValueError(f"Duplicate tool name detected: '{name}'")
all_tools[name] = tool

return all_tools

async def start_all_extensions(self):
"""Start all enabled extensions."""
# Sort the extension names to enforce deterministic loading
Expand Down
4 changes: 4 additions & 0 deletions jupyter_server/serverapp.py
Original file line number Diff line number Diff line change
Expand Up @@ -2540,6 +2540,10 @@ def load_server_extensions(self) -> None:
"""
self.extension_manager.load_all_extensions()

# is this how I would expose it? and Is this a good name?
def get_tools(self):
return self.extension_manager.get_tools()

def init_mime_overrides(self) -> None:
# On some Windows machines, an application has registered incorrect
# mimetypes in the registry.
Expand Down
9 changes: 9 additions & 0 deletions jupyter_server/services/contents/handlers.py
Original file line number Diff line number Diff line change
Expand Up @@ -424,6 +424,14 @@ async def post(self, path=""):
self.finish()


# Somehow this doesn't feel like the right service for this to go in?
class ListToolInfoHandler(APIHandler):
@web.authenticated
async def get(self):
tools = self.serverapp.extension_manager.discover_tools()
self.finish({"discovered_tools": tools})


# -----------------------------------------------------------------------------
# URL to handler mappings
# -----------------------------------------------------------------------------
Expand All @@ -441,4 +449,5 @@ async def post(self, path=""):
(r"/api/contents%s/trust" % path_regex, TrustNotebooksHandler),
(r"/api/contents%s" % path_regex, ContentsHandler),
(r"/api/notebooks/?(.*)", NotebooksRedirectHandler),
(r"/api/tools", ListToolInfoHandler),
]
Loading