33import asyncio
44import contextlib
55import importlib
6+ import importlib .metadata
67import logging
78
89from jupyter_server .extension .application import ExtensionApp
9- from traitlets import Int , List , Unicode
10+ from traitlets import Bool , Int , List , Unicode
1011
1112from .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 } " )
0 commit comments