Skip to content

Commit 1764537

Browse files
committed
Add automatic tool discovery via Python entrypoints
Implements automatic discovery of MCP tools from installed Python packages using the 'jupyter_ai.tools' entrypoint group. Packages can now expose tools without requiring manual configuration. Key changes: - Add `use_tool_discovery` trait to enable/disable automatic discovery (default: True) - Implement `_discover_entrypoint_tools()` to find and load tools from entrypoints - Support both list and function-based entrypoint values - Unified registration logic in `_register_tools()` method - Add comprehensive tests for discovery and error handling - Update documentation with entrypoint usage examples Tools from entrypoints are registered first, followed by manually configured tools, allowing configuration to override discovered tools.
1 parent b6f8df6 commit 1764537

File tree

4 files changed

+236
-24
lines changed

4 files changed

+236
-24
lines changed

.gitignore

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -208,3 +208,6 @@ cython_debug/
208208
marimo/_static/
209209
marimo/_lsp/
210210
__marimo__/
211+
# pixi environments
212+
.pixi/*
213+
!.pixi/config.toml

README.md

Lines changed: 47 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,8 @@ This extension provides a simplified, trait-based approach to exposing Jupyter f
1111
## Key Features
1212

1313
- **Simplified Architecture**: Direct function registration without complex abstractions
14-
- **Configurable Tool Loading**: Register tools via string specifications (`module:function`)
14+
- **Configurable Tool Loading**: Register tools via string specifications (`module:function`)
15+
- **Automatic Tool Discovery**: Python packages can expose tools via entrypoints
1516
- **Jupyter Integration**: Seamless integration with Jupyter Server extension system
1617
- **HTTP Transport**: FastMCP-based HTTP server with proper MCP protocol support
1718
- **Traitlets Configuration**: Full configuration support through Jupyter's traitlets system
@@ -131,21 +132,58 @@ Jupyter Server extension that manages the MCP server lifecycle:
131132

132133
**Configuration Traits:**
133134
- `mcp_name` - Server name (default: "Jupyter MCP Server")
134-
- `mcp_port` - Server port (default: 3001)
135+
- `mcp_port` - Server port (default: 3001)
135136
- `mcp_tools` - List of tools to register (format: "module:function")
137+
- `use_tool_discovery` - Enable automatic tool discovery via entrypoints (default: True)
136138

137-
### Tool Loading System
139+
### Tool Registration
138140

139-
Tools are loaded using string specifications in the format `module_path:function_name`:
141+
Tools can be registered in two ways:
142+
143+
#### 1. Manual Configuration
144+
145+
Specify tools directly in your Jupyter configuration using `module:function` format:
146+
147+
```python
148+
c.MCPExtensionApp.mcp_tools = [
149+
"os:getcwd",
150+
"jupyter_ai_tools.toolkits.notebook:read_notebook",
151+
]
152+
```
153+
154+
#### 2. Automatic Discovery via Entrypoints
155+
156+
Python packages can expose tools automatically using the `jupyter_ai.tools` entrypoint group.
157+
158+
**In your package's `pyproject.toml`:**
159+
160+
```toml
161+
[project.entry-points."jupyter_ai.tools"]
162+
my_package_tools = "my_package.tools:TOOLS"
163+
```
164+
165+
**In `my_package/tools.py`:**
140166

141167
```python
142-
# Examples
143-
"os:getcwd" # Standard library
144-
"jupyter_ai_tools.toolkits.notebook:read_notebook" # External package
145-
"math:sqrt" # Built-in modules
168+
# Option 1: Define as a list
169+
TOOLS = [
170+
"my_package.operations:create_file",
171+
"my_package.operations:delete_file",
172+
]
173+
174+
# Option 2: Define as a function
175+
def get_tools():
176+
return [
177+
"my_package.operations:create_file",
178+
"my_package.operations:delete_file",
179+
]
146180
```
147181

148-
The extension dynamically imports the module and registers the function with FastMCP.
182+
Tools from entrypoints are discovered automatically when the extension starts. To disable automatic discovery:
183+
184+
```python
185+
c.MCPExtensionApp.use_tool_discovery = False
186+
```
149187

150188
## Configuration Examples
151189

jupyter_server_mcp/extension.py

Lines changed: 95 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -3,10 +3,11 @@
33
import asyncio
44
import contextlib
55
import importlib
6+
import importlib.metadata
67
import logging
78

89
from jupyter_server.extension.application import ExtensionApp
9-
from traitlets import Int, List, Unicode
10+
from traitlets import Bool, Int, List, Unicode
1011

1112
from .mcp_server import MCPServer
1213

@@ -38,6 +39,14 @@ class MCPExtensionApp(ExtensionApp):
3839
),
3940
).tag(config=True)
4041

42+
use_tool_discovery = Bool(
43+
default_value=True,
44+
help=(
45+
"Whether to automatically discover and register tools from "
46+
"Python entrypoints in the 'jupyter_ai.tools' group"
47+
),
48+
).tag(config=True)
49+
4150
mcp_server_instance: object | None = None
4251
mcp_server_task: asyncio.Task | None = None
4352

@@ -75,22 +84,94 @@ def _load_function_from_string(self, tool_spec: str):
7584
msg = f"Function '{function_name}' not found in module '{module_path}': {e}"
7685
raise AttributeError(msg) from e
7786

78-
def _register_configured_tools(self):
79-
"""Register tools specified in the mcp_tools configuration."""
80-
if not self.mcp_tools:
87+
def _register_tools(self, tool_specs: list[str], source: str = "configuration"):
88+
"""Register tools from a list of tool specifications.
89+
90+
Args:
91+
tool_specs: List of tool specifications in 'module:function' format
92+
source: Description of where tools came from (for logging)
93+
"""
94+
if not tool_specs:
8195
return
8296

83-
logger.info(f"Registering {len(self.mcp_tools)} configured tools")
97+
logger.info(f"Registering {len(tool_specs)} tools from {source}")
8498

85-
for tool_spec in self.mcp_tools:
99+
for tool_spec in tool_specs:
86100
try:
87101
function = self._load_function_from_string(tool_spec)
88102
self.mcp_server_instance.register_tool(function)
89-
logger.info(f"✅ Registered tool: {tool_spec}")
103+
logger.info(f"✅ Registered tool from {source}: {tool_spec}")
90104
except Exception as e:
91-
logger.error(f"❌ Failed to register tool '{tool_spec}': {e}")
105+
logger.error(f"❌ Failed to register tool '{tool_spec}' from {source}: {e}")
92106
continue
93107

108+
def _discover_entrypoint_tools(self) -> list[str]:
109+
"""Discover tools from Python entrypoints in the 'jupyter_ai.tools' group.
110+
111+
Returns:
112+
List of tool specifications in 'module:function' format
113+
"""
114+
if not self.use_tool_discovery:
115+
return []
116+
117+
discovered_tools = []
118+
119+
try:
120+
# Use importlib.metadata to discover entrypoints
121+
entrypoints = importlib.metadata.entry_points()
122+
123+
# Handle both Python 3.10+ and 3.9 style entrypoint APIs
124+
if hasattr(entrypoints, "select"):
125+
tools_group = entrypoints.select(group="jupyter_ai.tools")
126+
else:
127+
tools_group = entrypoints.get("jupyter_ai.tools", [])
128+
129+
for entry_point in tools_group:
130+
try:
131+
# Load the entrypoint value (can be a list or a function that returns a list)
132+
loaded_value = entry_point.load()
133+
134+
# Get tool specs from either a list or callable
135+
if isinstance(loaded_value, list):
136+
tool_specs = loaded_value
137+
elif callable(loaded_value):
138+
tool_specs = loaded_value()
139+
if not isinstance(tool_specs, list):
140+
logger.warning(
141+
f"Entrypoint '{entry_point.name}' function returned "
142+
f"{type(tool_specs).__name__} instead of list, skipping"
143+
)
144+
continue
145+
else:
146+
logger.warning(
147+
f"Entrypoint '{entry_point.name}' is neither a list nor callable, skipping"
148+
)
149+
continue
150+
151+
# Validate and collect tool specs
152+
valid_specs = [spec for spec in tool_specs if isinstance(spec, str)]
153+
invalid_count = len(tool_specs) - len(valid_specs)
154+
155+
if invalid_count > 0:
156+
logger.warning(
157+
f"Skipped {invalid_count} non-string tool specs from '{entry_point.name}'"
158+
)
159+
160+
discovered_tools.extend(valid_specs)
161+
logger.info(f"Discovered {len(valid_specs)} tools from entrypoint '{entry_point.name}'")
162+
163+
except Exception as e:
164+
logger.error(f"Failed to load entrypoint '{entry_point.name}': {e}")
165+
continue
166+
167+
except Exception as e:
168+
logger.error(f"Failed to discover entrypoints: {e}")
169+
170+
if not discovered_tools:
171+
logger.info("No tools discovered from entrypoints")
172+
173+
return discovered_tools
174+
94175
def initialize(self):
95176
"""Initialize the extension."""
96177
super().initialize()
@@ -115,8 +196,10 @@ async def start_extension(self):
115196
parent=self, name=self.mcp_name, port=self.mcp_port
116197
)
117198

118-
# Register configured tools
119-
self._register_configured_tools()
199+
# Register tools from entrypoints, then from configuration
200+
entrypoint_tools = self._discover_entrypoint_tools()
201+
self._register_tools(entrypoint_tools, source="entrypoints")
202+
self._register_tools(self.mcp_tools, source="configuration")
120203

121204
# Start the MCP server in a background task
122205
self.mcp_server_task = asyncio.create_task(
@@ -126,12 +209,9 @@ async def start_extension(self):
126209
# Give the server a moment to start
127210
await asyncio.sleep(0.5)
128211

212+
registered_count = len(self.mcp_server_instance._registered_tools)
129213
self.log.info(f"✅ MCP server started on port {self.mcp_port}")
130-
if self.mcp_tools:
131-
registered_count = len(self.mcp_server_instance._registered_tools)
132-
self.log.info(f"Registered {registered_count} tools from configuration")
133-
else:
134-
self.log.info("Use mcp_server_instance.register_tool() to add tools")
214+
self.log.info(f"Total registered tools: {registered_count}")
135215

136216
except Exception as e:
137217
self.log.error(f"Failed to start MCP server: {e}")

tests/test_extension.py

Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -340,3 +340,94 @@ async def test_start_extension_no_tools(self):
340340

341341
# Should not register any tools
342342
mock_server.register_tool.assert_not_called()
343+
344+
345+
class TestEntrypointDiscovery:
346+
"""Test entrypoint discovery functionality."""
347+
348+
def test_discover_entrypoint_tools_multiple_types(self):
349+
"""Test discovering tools from both list and function entrypoints."""
350+
extension = MCPExtensionApp()
351+
352+
# Create mock entrypoints - one list, one function
353+
mock_ep1 = Mock()
354+
mock_ep1.name = "package1_tools"
355+
mock_ep1.value = "package1.tools:TOOLS"
356+
mock_ep1.load.return_value = ["os:getcwd", "math:sqrt"]
357+
358+
mock_ep2 = Mock()
359+
mock_ep2.name = "package2_tools"
360+
mock_ep2.value = "package2.tools:get_tools"
361+
mock_function = Mock(return_value=["json:dumps", "time:time"])
362+
mock_ep2.load.return_value = mock_function
363+
364+
with patch("importlib.metadata.entry_points") as mock_ep_func:
365+
mock_ep_func.return_value.select = Mock(return_value=[mock_ep1, mock_ep2])
366+
367+
tools = extension._discover_entrypoint_tools()
368+
assert len(tools) == 4
369+
assert set(tools) == {"os:getcwd", "math:sqrt", "json:dumps", "time:time"}
370+
mock_function.assert_called_once() # Function was called
371+
372+
def test_discover_entrypoint_tools_error_handling(self):
373+
"""Test that discovery handles invalid entrypoints gracefully."""
374+
extension = MCPExtensionApp()
375+
376+
# Mix of valid and invalid entrypoints
377+
valid_ep = Mock()
378+
valid_ep.name = "valid"
379+
valid_ep.load.return_value = ["os:getcwd"]
380+
381+
invalid_type_ep = Mock()
382+
invalid_type_ep.name = "invalid_type"
383+
invalid_type_ep.load.return_value = "not_a_list"
384+
385+
function_bad_return_ep = Mock()
386+
function_bad_return_ep.name = "bad_function"
387+
function_bad_return_ep.load.return_value = Mock(return_value={"not": "list"})
388+
389+
load_error_ep = Mock()
390+
load_error_ep.name = "load_error"
391+
load_error_ep.load.side_effect = ImportError("Module not found")
392+
393+
with patch("importlib.metadata.entry_points") as mock_ep_func:
394+
mock_ep_func.return_value.select = Mock(
395+
return_value=[valid_ep, invalid_type_ep, function_bad_return_ep, load_error_ep]
396+
)
397+
398+
with patch("jupyter_server_mcp.extension.logger"):
399+
tools = extension._discover_entrypoint_tools()
400+
# Should only get the valid one
401+
assert tools == ["os:getcwd"]
402+
403+
def test_discover_entrypoint_tools_disabled(self):
404+
"""Test that discovery returns empty list when disabled."""
405+
extension = MCPExtensionApp()
406+
extension.use_tool_discovery = False
407+
408+
# Should return empty without trying to discover
409+
tools = extension._discover_entrypoint_tools()
410+
assert tools == []
411+
412+
@pytest.mark.asyncio
413+
async def test_start_extension_with_entrypoints_and_config(self):
414+
"""Test extension startup with both entrypoint and configured tools."""
415+
extension = MCPExtensionApp()
416+
extension.mcp_port = 3086
417+
extension.use_tool_discovery = True
418+
extension.mcp_tools = ["json:dumps"]
419+
420+
discovered_tools = ["os:getcwd"]
421+
422+
with patch("jupyter_server_mcp.extension.MCPServer") as mock_mcp_class:
423+
mock_server = Mock()
424+
mock_server.start_server = AsyncMock()
425+
mock_server._registered_tools = {"getcwd": {}, "dumps": {}}
426+
mock_mcp_class.return_value = mock_server
427+
428+
with patch.object(extension, "_discover_entrypoint_tools", return_value=discovered_tools):
429+
await extension.start_extension()
430+
431+
# Should register both entrypoint (1) and configured (1) tools = 2 total
432+
assert mock_server.register_tool.call_count == 2
433+

0 commit comments

Comments
 (0)