""" Dynamic Plugin Registry for DSS MCP Server Automatically discovers and registers MCP tools from the plugins/ directory. Plugins follow a simple contract: export TOOLS list and a handler class with execute_tool() method. """ import pkgutil import importlib import inspect import logging import types as python_types from typing import List, Dict, Any, Optional from mcp import types logger = logging.getLogger("dss.mcp.plugins") class PluginRegistry: """ Discovers and manages dynamically loaded plugins. Plugin Contract: - Must export TOOLS: List[types.Tool] - MCP tool definitions - Must have a class with execute_tool(name: str, arguments: dict) method - Optional: PLUGIN_METADATA dict with name, version, author Example Plugin Structure: ```python from mcp import types PLUGIN_METADATA = { "name": "Example Plugin", "version": "1.0.0", "author": "DSS Team" } TOOLS = [ types.Tool( name="example_tool", description="Example tool", inputSchema={...} ) ] class PluginTools: async def execute_tool(self, name: str, arguments: dict): if name == "example_tool": return {"result": "success"} raise ValueError(f"Unknown tool: {name}") ``` """ def __init__(self): self.tools: List[types.Tool] = [] self.handlers: Dict[str, Any] = {} # tool_name -> handler_instance self.plugins: List[Dict[str, Any]] = [] # plugin metadata self._loaded_modules: set = set() def load_plugins(self, plugins_package_name: str = "dss_mcp.plugins"): """ Scans the plugins directory and registers valid tool modules. Args: plugins_package_name: Fully qualified name of plugins package Default: "dss_mcp.plugins" (works when called from tools/ dir) """ try: # Dynamically import the plugins package plugins_pkg = importlib.import_module(plugins_package_name) path = plugins_pkg.__path__ prefix = plugins_pkg.__name__ + "." logger.info(f"Scanning for plugins in: {path}") # Iterate through all modules in the plugins directory for _, name, is_pkg in pkgutil.iter_modules(path, prefix): # Skip packages (only load .py files) if is_pkg: continue # Skip template and private modules module_basename = name.split('.')[-1] if module_basename.startswith('_'): logger.debug(f"Skipping private module: {module_basename}") continue try: module = importlib.import_module(name) self._register_module(module) except Exception as e: logger.error(f"Failed to load plugin module {name}: {e}", exc_info=True) except ImportError as e: logger.warning(f"Plugins package not found: {plugins_package_name} ({e})") logger.info("Server will run without plugins") def _register_module(self, module: python_types.ModuleType): """ Validates and registers a single plugin module. Args: module: The imported plugin module """ module_name = module.__name__ # Check if already loaded if module_name in self._loaded_modules: logger.debug(f"Module already loaded: {module_name}") return # Contract Check 1: Must export TOOLS list if not hasattr(module, 'TOOLS'): logger.debug(f"Module {module_name} has no TOOLS export, skipping") return if not isinstance(module.TOOLS, list): logger.error(f"Module {module_name} TOOLS must be a list, got {type(module.TOOLS)}") return if len(module.TOOLS) == 0: logger.warning(f"Module {module_name} has empty TOOLS list") return # Contract Check 2: Must have a class with execute_tool method handler_instance = self._find_and_instantiate_handler(module) if not handler_instance: logger.warning(f"Plugin {module_name} has TOOLS but no valid handler class") return # Contract Check 3: execute_tool must be async (coroutine) execute_tool_method = getattr(handler_instance, 'execute_tool', None) if execute_tool_method and not inspect.iscoroutinefunction(execute_tool_method): logger.error( f"Plugin '{module_name}' is invalid: 'PluginTools.execute_tool' must be " f"an async function ('async def'). Skipping plugin." ) return # Extract metadata metadata = getattr(module, 'PLUGIN_METADATA', {}) plugin_name = metadata.get('name', module_name.split('.')[-1]) plugin_version = metadata.get('version', 'unknown') # Validate tools and check for name collisions registered_count = 0 for tool in module.TOOLS: if not hasattr(tool, 'name'): logger.error(f"Tool in {module_name} missing 'name' attribute") continue # Check for name collision if tool.name in self.handlers: logger.error( f"Tool name collision: '{tool.name}' already registered. " f"Skipping duplicate from {module_name}" ) continue # Register tool self.tools.append(tool) self.handlers[tool.name] = handler_instance registered_count += 1 logger.debug(f"Registered tool: {tool.name}") # Track plugin metadata self.plugins.append({ "name": plugin_name, "version": plugin_version, "module": module_name, "tools_count": registered_count, "author": metadata.get('author', 'unknown') }) self._loaded_modules.add(module_name) logger.info( f"Loaded plugin: {plugin_name} v{plugin_version} " f"({registered_count} tools from {module_name})" ) def _find_and_instantiate_handler(self, module: python_types.ModuleType) -> Optional[Any]: """ Finds a class implementing execute_tool and instantiates it. Args: module: The plugin module to search Returns: Instantiated handler class or None if not found """ for name, obj in inspect.getmembers(module, inspect.isclass): # Only consider classes defined in this module (not imports) if obj.__module__ != module.__name__: continue # Look for execute_tool method if hasattr(obj, 'execute_tool'): try: # Try to instantiate with no args instance = obj() logger.debug(f"Instantiated handler class: {name}") return instance except TypeError: # Try with **kwargs for flexible initialization try: instance = obj(**{}) logger.debug(f"Instantiated handler class with kwargs: {name}") return instance except Exception as e: logger.error( f"Failed to instantiate handler {name} in {module.__name__}: {e}" ) return None except Exception as e: logger.error( f"Failed to instantiate handler {name} in {module.__name__}: {e}" ) return None return None async def execute_tool(self, name: str, arguments: dict) -> Any: """ Routes tool execution to the correct plugin handler. Args: name: Tool name arguments: Tool arguments Returns: Tool execution result Raises: ValueError: If tool not found in registry """ if name not in self.handlers: raise ValueError(f"Tool '{name}' not found in plugin registry") handler = self.handlers[name] # Support both async and sync implementations if inspect.iscoroutinefunction(handler.execute_tool): return await handler.execute_tool(name, arguments) else: return handler.execute_tool(name, arguments) def get_all_tools(self) -> List[types.Tool]: """Get merged list of all plugin tools""" return self.tools.copy() def get_plugin_info(self) -> List[Dict[str, Any]]: """Get metadata for all loaded plugins""" return self.plugins.copy() def reload_plugins(self, plugins_package_name: str = "dss_mcp.plugins"): """ Reload all plugins (useful for development). WARNING: This clears all registered plugins and reloads from scratch. Args: plugins_package_name: Fully qualified name of plugins package """ logger.info("Reloading all plugins...") # Clear existing registrations self.tools.clear() self.handlers.clear() self.plugins.clear() self._loaded_modules.clear() # Reload self.load_plugins(plugins_package_name) logger.info(f"Plugin reload complete. Loaded {len(self.plugins)} plugins, {len(self.tools)} tools")