99from traitlets import Any , Bool , Dict , HasTraits , Instance , List , Unicode , default , observe
1010from traitlets import validate as validate_trait
1111from traitlets .config import LoggingConfigurable
12+ import jsonschema
13+
1214
1315from .config import ExtensionConfigManager
1416from .utils import ExtensionMetadataError , ExtensionModuleNotFound , get_loader , get_metadata
1517
1618
19+ MCP_TOOL_SCHEMA = {
20+ "type" : "object" ,
21+ "properties" : {
22+ "name" : {"type" : "string" },
23+ "description" : {"type" : "string" },
24+ "inputSchema" : {
25+ "type" : "object" ,
26+ "properties" : {
27+ "type" : {"type" : "string" , "enum" : ["object" ]},
28+ "properties" : {"type" : "object" },
29+ "required" : {"type" : "array" , "items" : {"type" : "string" }}
30+ },
31+ "required" : ["type" , "properties" ]
32+ },
33+ "annotations" : {
34+ "type" : "object" ,
35+ "properties" : {
36+ "title" : {"type" : "string" },
37+ "readOnlyHint" : {"type" : "boolean" },
38+ "destructiveHint" : {"type" : "boolean" },
39+ "idempotentHint" : {"type" : "boolean" },
40+ "openWorldHint" : {"type" : "boolean" }
41+ },
42+ "additionalProperties" : True
43+ }
44+ },
45+ "required" : ["name" , "inputSchema" ],
46+ "additionalProperties" : False
47+ }
48+
1749class ExtensionPoint (HasTraits ):
1850 """A simple API for connecting to a Jupyter Server extension
1951 point defined by metadata and importable from a Python package.
@@ -96,6 +128,28 @@ def name(self):
96128 def module (self ):
97129 """The imported module (using importlib.import_module)"""
98130 return self ._module
131+
132+ @property
133+ def tools (self ):
134+ """Structured tools exposed by this extension point, if any."""
135+ loc = self .app or self .module
136+ if not loc :
137+ return {}
138+
139+ tools_func = getattr (loc , "jupyter_server_extension_tools" , None )
140+ if not callable (tools_func ):
141+ return {}
142+
143+ tools = {}
144+ try :
145+ definitions = tools_func ()
146+ for name , tool in definitions .items ():
147+ jsonschema .validate (instance = tool ["metadata" ], schema = MCP_TOOL_SCHEMA )
148+ tools [name ] = tool
149+ except Exception as e :
150+ # You could also `self.log.warning(...)` if you pass the log around
151+ print (f"[tool-discovery] Failed to load tools from { self .module_name } : { e } " )
152+ return tools
99153
100154 def _get_linker (self ):
101155 """Get a linker."""
@@ -111,6 +165,7 @@ def _get_linker(self):
111165 )
112166 return linker
113167
168+
114169 def _get_loader (self ):
115170 """Get a loader."""
116171 loc = self .app
@@ -443,6 +498,23 @@ def load_all_extensions(self):
443498 for name in self .sorted_extensions :
444499 self .load_extension (name )
445500
501+
502+ def get_tools (self ) -> Dict [str , Any ]:
503+ """Aggregate tools from all extensions that expose them."""
504+ all_tools = {}
505+
506+ for ext_name , ext_pkg in self .extensions .items ():
507+ if not ext_pkg .enabled :
508+ continue
509+
510+ for point in ext_pkg .extension_points .values ():
511+ for name , tool in point .tools .items (): # 🔥 <— new property!
512+ if name in all_tools :
513+ raise ValueError (f"Duplicate tool name detected: '{ name } '" )
514+ all_tools [name ] = tool
515+
516+ return all_tools
517+
446518 async def start_all_extensions (self ):
447519 """Start all enabled extensions."""
448520 # Sort the extension names to enforce deterministic loading
0 commit comments