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_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