From 2fbfbf65a46f6e0db32e0d05e4c2d02fa0a5b971 Mon Sep 17 00:00:00 2001 From: Abigayle-Mercer Date: Thu, 8 May 2025 09:58:56 -0700 Subject: [PATCH 1/8] added tools property to ExtensionPoint, get_tools to ExtensionManager and ServerApp --- jupyter_server/extension/manager.py | 72 +++++++++++++++++++++++++++++ jupyter_server/serverapp.py | 3 ++ 2 files changed, 75 insertions(+) diff --git a/jupyter_server/extension/manager.py b/jupyter_server/extension/manager.py index 2b18573c9..38e2dd4ce 100644 --- a/jupyter_server/extension/manager.py +++ b/jupyter_server/extension/manager.py @@ -9,11 +9,43 @@ from traitlets import Any, Bool, Dict, HasTraits, Instance, List, Unicode, default, observe from traitlets import validate as validate_trait from traitlets.config import LoggingConfigurable +import jsonschema + from .config import ExtensionConfigManager from .utils import ExtensionMetadataError, ExtensionModuleNotFound, get_loader, get_metadata +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 point defined by metadata and importable from a Python package. @@ -96,6 +128,28 @@ def name(self): 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(): + jsonschema.validate(instance=tool["metadata"], schema=MCP_TOOL_SCHEMA) + tools[name] = tool + except Exception as e: + # You could also `self.log.warning(...)` if you pass the log around + print(f"[tool-discovery] Failed to load tools from {self.module_name}: {e}") + return tools def _get_linker(self): """Get a linker.""" @@ -111,6 +165,7 @@ def _get_linker(self): ) return linker + def _get_loader(self): """Get a loader.""" loc = self.app @@ -443,6 +498,23 @@ 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 diff --git a/jupyter_server/serverapp.py b/jupyter_server/serverapp.py index 75143b6be..2851f4d5e 100644 --- a/jupyter_server/serverapp.py +++ b/jupyter_server/serverapp.py @@ -2540,6 +2540,9 @@ def load_server_extensions(self) -> None: """ self.extension_manager.load_all_extensions() + 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. From d44f68ca05c1068f2ac31918a428061c68d97fc8 Mon Sep 17 00:00:00 2001 From: Abigayle-Mercer Date: Thu, 8 May 2025 16:18:02 -0700 Subject: [PATCH 2/8] added an API for listing tools in serveres/contents/handlers.py --- jupyter_server/services/contents/handlers.py | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/jupyter_server/services/contents/handlers.py b/jupyter_server/services/contents/handlers.py index ae160e670..6269db2d7 100644 --- a/jupyter_server/services/contents/handlers.py +++ b/jupyter_server/services/contents/handlers.py @@ -423,6 +423,12 @@ async def post(self, path=""): self.set_status(201) self.finish() +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 @@ -441,4 +447,6 @@ 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), + ] From f6a5d4261519594db4a3ba17f58fb29f653eafe4 Mon Sep 17 00:00:00 2001 From: Abigayle-Mercer Date: Fri, 9 May 2025 09:53:24 -0700 Subject: [PATCH 3/8] added some comments about uncertainties in the logic / layout --- jupyter_server/extension/manager.py | 6 ++++-- jupyter_server/serverapp.py | 1 + jupyter_server/services/contents/handlers.py | 3 ++- 3 files changed, 7 insertions(+), 3 deletions(-) diff --git a/jupyter_server/extension/manager.py b/jupyter_server/extension/manager.py index 38e2dd4ce..c0f8aa3b3 100644 --- a/jupyter_server/extension/manager.py +++ b/jupyter_server/extension/manager.py @@ -16,6 +16,7 @@ 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": { @@ -144,10 +145,11 @@ def tools(self): 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) tools[name] = tool except Exception as e: - # You could also `self.log.warning(...)` if you pass the log around + # 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 @@ -508,7 +510,7 @@ def get_tools(self) -> Dict[str, Any]: continue for point in ext_pkg.extension_points.values(): - for name, tool in point.tools.items(): # 🔥 <— new property! + 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 diff --git a/jupyter_server/serverapp.py b/jupyter_server/serverapp.py index 2851f4d5e..220df43af 100644 --- a/jupyter_server/serverapp.py +++ b/jupyter_server/serverapp.py @@ -2540,6 +2540,7 @@ 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() diff --git a/jupyter_server/services/contents/handlers.py b/jupyter_server/services/contents/handlers.py index 6269db2d7..208c767a7 100644 --- a/jupyter_server/services/contents/handlers.py +++ b/jupyter_server/services/contents/handlers.py @@ -423,11 +423,12 @@ async def post(self, path=""): self.set_status(201) 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}) + self.finish({"discovered_tools": tools}) # ----------------------------------------------------------------------------- From 56b1bfdaf2710f44b53b06383a9c7dbe21d8e2c1 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Sat, 10 May 2025 18:39:34 +0000 Subject: [PATCH 4/8] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- jupyter_server/extension/manager.py | 25 +++++++++----------- jupyter_server/serverapp.py | 4 ++-- jupyter_server/services/contents/handlers.py | 6 ++--- 3 files changed, 16 insertions(+), 19 deletions(-) diff --git a/jupyter_server/extension/manager.py b/jupyter_server/extension/manager.py index c0f8aa3b3..b1f91495a 100644 --- a/jupyter_server/extension/manager.py +++ b/jupyter_server/extension/manager.py @@ -5,17 +5,15 @@ 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 from traitlets.config import LoggingConfigurable -import jsonschema - 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", @@ -27,9 +25,9 @@ "properties": { "type": {"type": "string", "enum": ["object"]}, "properties": {"type": "object"}, - "required": {"type": "array", "items": {"type": "string"}} + "required": {"type": "array", "items": {"type": "string"}}, }, - "required": ["type", "properties"] + "required": ["type", "properties"], }, "annotations": { "type": "object", @@ -38,15 +36,16 @@ "readOnlyHint": {"type": "boolean"}, "destructiveHint": {"type": "boolean"}, "idempotentHint": {"type": "boolean"}, - "openWorldHint": {"type": "boolean"} + "openWorldHint": {"type": "boolean"}, }, - "additionalProperties": True - } + "additionalProperties": True, + }, }, "required": ["name", "inputSchema"], - "additionalProperties": False + "additionalProperties": False, } + class ExtensionPoint(HasTraits): """A simple API for connecting to a Jupyter Server extension point defined by metadata and importable from a Python package. @@ -129,9 +128,9 @@ def name(self): def module(self): """The imported module (using importlib.import_module)""" return self._module - + @property - def tools(self): + def tools(self): """Structured tools exposed by this extension point, if any.""" loc = self.app or self.module if not loc: @@ -149,7 +148,7 @@ def tools(self): jsonschema.validate(instance=tool["metadata"], schema=MCP_TOOL_SCHEMA) tools[name] = tool except Exception as e: - # not sure if this should fail quietly, raise an error, or log it? + # 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 @@ -167,7 +166,6 @@ def _get_linker(self): ) return linker - def _get_loader(self): """Get a loader.""" loc = self.app @@ -500,7 +498,6 @@ 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 = {} diff --git a/jupyter_server/serverapp.py b/jupyter_server/serverapp.py index 220df43af..2e1d2fa4f 100644 --- a/jupyter_server/serverapp.py +++ b/jupyter_server/serverapp.py @@ -2540,8 +2540,8 @@ 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): + # 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: diff --git a/jupyter_server/services/contents/handlers.py b/jupyter_server/services/contents/handlers.py index 208c767a7..a987ecee2 100644 --- a/jupyter_server/services/contents/handlers.py +++ b/jupyter_server/services/contents/handlers.py @@ -423,12 +423,13 @@ async def post(self, path=""): self.set_status(201) self.finish() -# Somehow this doesn't feel like the right service for this to go in? + +# 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}) + self.finish({"discovered_tools": tools}) # ----------------------------------------------------------------------------- @@ -449,5 +450,4 @@ async def get(self): (r"/api/contents%s" % path_regex, ContentsHandler), (r"/api/notebooks/?(.*)", NotebooksRedirectHandler), (r"/api/tools", ListToolInfoHandler), - ] From 715e76e893037a2d7dc42aaa85e07f52387991c0 Mon Sep 17 00:00:00 2001 From: Abigayle-Mercer Date: Mon, 12 May 2025 12:48:57 -0700 Subject: [PATCH 5/8] moved ListToolInforHandler to separate service, fixed doc strings, cleaned up comments --- jupyter_server/extension/manager.py | 11 +++++++---- jupyter_server/serverapp.py | 5 +++-- jupyter_server/services/contents/handlers.py | 8 -------- jupyter_server/services/tools/handlers.py | 14 ++++++++++++++ 4 files changed, 24 insertions(+), 14 deletions(-) create mode 100644 jupyter_server/services/tools/handlers.py diff --git a/jupyter_server/extension/manager.py b/jupyter_server/extension/manager.py index b1f91495a..27f3def1f 100644 --- a/jupyter_server/extension/manager.py +++ b/jupyter_server/extension/manager.py @@ -130,8 +130,11 @@ def module(self): return self._module @property - def tools(self): - """Structured tools exposed by this extension point, if any.""" + 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 {} @@ -499,7 +502,7 @@ def load_all_extensions(self): self.load_extension(name) def get_tools(self) -> Dict[str, Any]: - """Aggregate tools from all extensions that expose them.""" + """Aggregate and return structured tools (with metadata) from all enabled extensions.""" all_tools = {} for ext_name, ext_pkg in self.extensions.items(): @@ -507,7 +510,7 @@ def get_tools(self) -> Dict[str, Any]: continue for point in ext_pkg.extension_points.values(): - for name, tool in point.tools.items(): # <— new property! + for name, tool in point.tools.items(): if name in all_tools: raise ValueError(f"Duplicate tool name detected: '{name}'") all_tools[name] = tool diff --git a/jupyter_server/serverapp.py b/jupyter_server/serverapp.py index 2e1d2fa4f..a22f464fe 100644 --- a/jupyter_server/serverapp.py +++ b/jupyter_server/serverapp.py @@ -2540,8 +2540,9 @@ 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): + + def get_tools(self): + """Aggregate and return tools + tool metadata from all the extensions that expose them.""" return self.extension_manager.get_tools() def init_mime_overrides(self) -> None: diff --git a/jupyter_server/services/contents/handlers.py b/jupyter_server/services/contents/handlers.py index a987ecee2..961b7ae1a 100644 --- a/jupyter_server/services/contents/handlers.py +++ b/jupyter_server/services/contents/handlers.py @@ -424,13 +424,6 @@ 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 @@ -449,5 +442,4 @@ async def get(self): (r"/api/contents%s/trust" % path_regex, TrustNotebooksHandler), (r"/api/contents%s" % path_regex, ContentsHandler), (r"/api/notebooks/?(.*)", NotebooksRedirectHandler), - (r"/api/tools", ListToolInfoHandler), ] 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 From f4fe4482f55603be989031b0640c545c8500ba43 Mon Sep 17 00:00:00 2001 From: Abigayle-Mercer Date: Mon, 12 May 2025 12:49:53 -0700 Subject: [PATCH 6/8] moved ListToolInforHandler to separate service, fixed doc strings, cleaned up comments --- jupyter_server/serverapp.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/jupyter_server/serverapp.py b/jupyter_server/serverapp.py index a22f464fe..9c2a49c70 100644 --- a/jupyter_server/serverapp.py +++ b/jupyter_server/serverapp.py @@ -2542,7 +2542,7 @@ def load_server_extensions(self) -> None: def get_tools(self): - """Aggregate and return tools + tool metadata from all the extensions that expose them.""" + """Return tools exposed by all extensions.""" return self.extension_manager.get_tools() def init_mime_overrides(self) -> None: From 24116cd544b65239b6878bc71276b7ca6703671c Mon Sep 17 00:00:00 2001 From: Abigayle-Mercer Date: Mon, 12 May 2025 13:03:21 -0700 Subject: [PATCH 7/8] the tools property now allows extensions to show up with their own validation schema, but still uses MCP by default --- jupyter_server/extension/manager.py | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/jupyter_server/extension/manager.py b/jupyter_server/extension/manager.py index 27f3def1f..516212a89 100644 --- a/jupyter_server/extension/manager.py +++ b/jupyter_server/extension/manager.py @@ -145,10 +145,16 @@ def tools(self): 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) + 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? From 9b235906e5e62575330a3d5630af44e5329cea8b Mon Sep 17 00:00:00 2001 From: Abigayle-Mercer Date: Tue, 13 May 2025 14:33:27 -0700 Subject: [PATCH 8/8] added some preliminary unit testing for ListToolHandler, tool property, and get_tool() functions --- .../extension/mockextensions/mockext_tool.py | 22 +++++++++++ .../mockextensions/mockext_tool_dupes.py | 21 ++++++++++ .../mockextensions/mockext_tool_schema.py | 39 +++++++++++++++++++ tests/extension/test_manager.py | 25 ++++++++++++ tests/services/tools/test_api.py | 29 ++++++++++++++ tests/test_serverapp.py | 18 +++++++++ 6 files changed, 154 insertions(+) create mode 100644 tests/extension/mockextensions/mockext_tool.py create mode 100644 tests/extension/mockextensions/mockext_tool_dupes.py create mode 100644 tests/extension/mockextensions/mockext_tool_schema.py create mode 100644 tests/services/tools/test_api.py 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