Skip to content

Commit 754ead2

Browse files
authored
Add automatic tool discovery via Python entrypoints (#10)
* 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. * Update entrypoint name * fix unit test * lintin
1 parent b6f8df6 commit 754ead2

File tree

4 files changed

+259
-31
lines changed

4 files changed

+259
-31
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_server_mcp.tools` entrypoint group.
157+
158+
**In your package's `pyproject.toml`:**
159+
160+
```toml
161+
[project.entry-points."jupyter_server_mcp.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: 99 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_server_mcp.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,98 @@ 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(
106+
f"❌ Failed to register tool '{tool_spec}' from {source}: {e}"
107+
)
92108
continue
93109

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

118-
# Register configured tools
119-
self._register_configured_tools()
203+
# Register tools from entrypoints, then from configuration
204+
entrypoint_tools = self._discover_entrypoint_tools()
205+
self._register_tools(entrypoint_tools, source="entrypoints")
206+
self._register_tools(self.mcp_tools, source="configuration")
120207

121208
# Start the MCP server in a background task
122209
self.mcp_server_task = asyncio.create_task(
@@ -126,12 +213,9 @@ async def start_extension(self):
126213
# Give the server a moment to start
127214
await asyncio.sleep(0.5)
128215

216+
registered_count = len(self.mcp_server_instance._registered_tools)
129217
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")
218+
self.log.info(f"Total registered tools: {registered_count}")
135219

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

0 commit comments

Comments
 (0)