diff --git a/jupyter_server/extension/manager.py b/jupyter_server/extension/manager.py index 2b18573c9..516212a89 100644 --- a/jupyter_server/extension/manager.py +++ b/jupyter_server/extension/manager.py @@ -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 @@ -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 @@ -97,6 +129,38 @@ 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. + + Searches for a `jupyter_server_extension_tools` function on the extension module or app. + """ + 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: + result = tools_func() + # Support (tools_dict, schema) or just tools_dict + if isinstance(result, tuple) and len(result) == 2: + tools_dict, schema = result + else: + tools_dict = result + schema = MCP_TOOL_SCHEMA + + for name, tool in tools_dict.items(): + jsonschema.validate(instance=tool["metadata"], schema=schema) + 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}") + return tools + def _get_linker(self): """Get a linker.""" if self.app: @@ -443,6 +507,22 @@ def load_all_extensions(self): for name in self.sorted_extensions: self.load_extension(name) + def get_tools(self) -> Dict[str, Any]: + """Aggregate and return structured tools (with metadata) from all enabled extensions.""" + 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(): + 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 diff --git a/jupyter_server/serverapp.py b/jupyter_server/serverapp.py index 75143b6be..9c2a49c70 100644 --- a/jupyter_server/serverapp.py +++ b/jupyter_server/serverapp.py @@ -2540,6 +2540,11 @@ def load_server_extensions(self) -> None: """ self.extension_manager.load_all_extensions() + + def get_tools(self): + """Return tools exposed by all extensions.""" + 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. diff --git a/jupyter_server/services/contents/handlers.py b/jupyter_server/services/contents/handlers.py index ae160e670..961b7ae1a 100644 --- a/jupyter_server/services/contents/handlers.py +++ b/jupyter_server/services/contents/handlers.py @@ -424,6 +424,7 @@ async def post(self, path=""): self.finish() + # ----------------------------------------------------------------------------- # URL to handler mappings # ----------------------------------------------------------------------------- diff --git a/jupyter_server/services/tools/handlers.py b/jupyter_server/services/tools/handlers.py new file mode 100644 index 000000000..ab9e7fcb4 --- /dev/null +++ b/jupyter_server/services/tools/handlers.py @@ -0,0 +1,14 @@ +from tornado import web +from jupyter_server.base.handlers import APIHandler + +class ListToolInfoHandler(APIHandler): + @web.authenticated + async def get(self): + tools = self.serverapp.extension_manager.discover_tools() + self.finish({"discovered_tools": tools}) + + + +default_handlers = [ + (r"/api/tools", ListToolInfoHandler), +] \ No newline at end of file diff --git a/tests/extension/mockextensions/mockext_tool.py b/tests/extension/mockextensions/mockext_tool.py new file mode 100644 index 000000000..c0fb58a90 --- /dev/null +++ b/tests/extension/mockextensions/mockext_tool.py @@ -0,0 +1,22 @@ +"""A mock extension exposing a structured tool.""" + +def jupyter_server_extension_tools(): + return { + "mock_tool": { + "metadata": { + "name": "mock_tool", + "description": "A mock tool for testing.", + "inputSchema": { + "type": "object", + "properties": { + "input": {"type": "string"} + }, + "required": ["input"] + } + }, + "callable": lambda input: f"Echo: {input}" + } + } + +def _load_jupyter_server_extension(serverapp): + serverapp.log.info("Loaded mock tool extension.") diff --git a/tests/extension/mockextensions/mockext_tool_dupes.py b/tests/extension/mockextensions/mockext_tool_dupes.py new file mode 100644 index 000000000..844aa2eff --- /dev/null +++ b/tests/extension/mockextensions/mockext_tool_dupes.py @@ -0,0 +1,21 @@ +"""A mock extension that defines a duplicate tool name to test conflict handling.""" + +def jupyter_server_extension_tools(): + return { + "mock_tool": { # <-- duplicate on purpose + "metadata": { + "name": "mock_tool", + "description": "Conflicting tool name.", + "inputSchema": { + "type": "object", + "properties": { + "input": {"type": "string"} + } + } + }, + "callable": lambda input: f"Echo again: {input}" + } + } + +def _load_jupyter_server_extension(serverapp): + serverapp.log.info("Loaded dupe tool extension.") diff --git a/tests/extension/mockextensions/mockext_tool_schema.py b/tests/extension/mockextensions/mockext_tool_schema.py new file mode 100644 index 000000000..5813546dd --- /dev/null +++ b/tests/extension/mockextensions/mockext_tool_schema.py @@ -0,0 +1,39 @@ +"""A mock extension that provides a custom validation schema.""" + +OPENAI_TOOL_SCHEMA = { + "type": "object", + "properties": { + "name": {"type": "string"}, + "description": {"type": "string"}, + "parameters": { + "type": "object", + "properties": { + "input": {"type": "string"} + }, + "required": ["input"] + } + }, + "required": ["name", "parameters"] +} + +def jupyter_server_extension_tools(): + tools = { + "openai_style_tool": { + "metadata": { + "name": "openai_style_tool", + "description": "Tool using OpenAI-style parameters", + "parameters": { + "type": "object", + "properties": { + "input": {"type": "string"} + }, + "required": ["input"] + } + }, + "callable": lambda input: f"Got {input}" + } + } + return (tools, OPENAI_TOOL_SCHEMA) + +def _load_jupyter_server_extension(serverapp): + serverapp.log.info("Loaded mock custom-schema extension.") diff --git a/tests/extension/test_manager.py b/tests/extension/test_manager.py index 88c78e545..bbe76c39b 100644 --- a/tests/extension/test_manager.py +++ b/tests/extension/test_manager.py @@ -167,3 +167,28 @@ def test_disable_no_import(jp_serverapp, has_app): assert ext_pkg.extension_points == {} assert ext_pkg.version == "" assert ext_pkg.metadata == [] + + +def test_extension_point_tools_default_schema(): + ep = ExtensionPoint(metadata={"module": "tests.extension.mockextensions.mockext_tool"}) + assert "mock_tool" in ep.tools + + +def test_extension_point_tools_custom_schema(): + ep = ExtensionPoint(metadata={"module": "tests.extension.mockextensions.mockext_customschema"}) + assert "openai_style_tool" in ep.tools + metadata = ep.tools["openai_style_tool"]["metadata"] + assert "parameters" in metadata + + +def test_extension_manager_duplicate_tool_name_raises(jp_serverapp): + from jupyter_server.extension.manager import ExtensionManager + + manager = ExtensionManager(serverapp=jp_serverapp) + manager.add_extension("tests.extension.mockextensions.mockext_tool", enabled=True) + manager.add_extension("tests.extension.mockextensions.mockext_dupes", enabled=True) + manager.link_all_extensions() + + with pytest.raises(ValueError, match="Duplicate tool name detected: 'mock_tool'"): + manager.get_tools() + diff --git a/tests/services/tools/test_api.py b/tests/services/tools/test_api.py new file mode 100644 index 000000000..6f4b2a581 --- /dev/null +++ b/tests/services/tools/test_api.py @@ -0,0 +1,29 @@ +import json +import pytest + +@pytest.fixture +def jp_server_config(): + return { + "ServerApp": { + "jpserver_extensions": { + "tests.extension.mockextensions.mockext_tool": True, + "tests.extension.mockextensions.mockext_customschema": True, + } + } + } + +@pytest.mark.asyncio +async def test_multiple_tools_present(jp_fetch): + response = await jp_fetch("api", "tools", method="GET") + assert response.code == 200 + + body = json.loads(response.body.decode()) + tools = body["discovered_tools"] + + # Check default schema tool + assert "mock_tool" in tools + assert "inputSchema" in tools["mock_tool"] + + # Check custom schema tool + assert "openai_style_tool" in tools + assert "parameters" in tools["openai_style_tool"] diff --git a/tests/test_serverapp.py b/tests/test_serverapp.py index eb137b12d..ced0e8408 100644 --- a/tests/test_serverapp.py +++ b/tests/test_serverapp.py @@ -644,6 +644,24 @@ def test_immutable_cache_trait(): assert serverapp.web_app.settings["static_immutable_cache"] == ["/test/immutable"] +# testing get_tools +def test_serverapp_get_tools_empty(jp_serverapp): + # testing the default empty state + tools = jp_serverapp.get_tools() + assert tools == {} + +def test_serverapp_get_tools(jp_serverapp): + jp_serverapp.extension_manager.add_extension( + "tests.extension.mockextensions.mockext_tool", enabled=True + ) + jp_serverapp.extension_manager.link_all_extensions() + + tools = jp_serverapp.get_tools() + assert "mock_tool" in tools + metadata = tools["mock_tool"]["metadata"] + assert metadata["name"] == "mock_tool" + + def test(): pass