Coverage for src/quickmcp/factory/core.py: 91%

204 statements  

« prev     ^ index     » next       coverage.py v7.10.4, created at 2025-08-20 20:56 +0200

1""" 

2Core MCP Factory classes for generating MCP servers from Python code. 

3""" 

4 

5import ast 

6import inspect 

7import importlib.util 

8import sys 

9from pathlib import Path 

10from typing import Any, Dict, List, Optional, Callable 

11import logging 

12 

13from ..server import QuickMCPServer 

14from .config import FactoryConfig, DEFAULT_CONFIG 

15from .import_analyzer import ImportAnalyzer, MissingDependency 

16from .wrappers import ToolWrapperFactory, MethodToolWrapper 

17from .errors import ( 

18 MissingDependencyError, ModuleLoadError, FunctionExtractionError, 

19 CodeExecutionError, ToolRegistrationError, log_factory_error 

20) 

21 

22logger = logging.getLogger(__name__) 

23 

24 

25class ModuleLoader: 

26 """Handles safe loading of Python modules.""" 

27 

28 def __init__(self, config: Optional[FactoryConfig] = None): 

29 self.config = config or DEFAULT_CONFIG 

30 

31 def load_module(self, module_path: str): 

32 """Load a Python module from a file path or module name.""" 

33 if not self.config.allow_code_execution: 

34 raise CodeExecutionError( 

35 "Code execution is disabled. Enable 'allow_code_execution' in config to load modules.", 

36 module_path 

37 ) 

38 

39 if self.config.warn_on_code_execution: 

40 logger.warning(f"Loading module '{module_path}' will execute its code") 

41 

42 path = Path(module_path) 

43 

44 try: 

45 if path.exists() and path.suffix == '.py': 

46 # Load from file 

47 spec = importlib.util.spec_from_file_location(path.stem, path) 

48 if spec and spec.loader: 

49 module = importlib.util.module_from_spec(spec) 

50 sys.modules[path.stem] = module 

51 spec.loader.exec_module(module) 

52 return module 

53 else: 

54 raise ModuleLoadError(f"Could not create module spec for {path}", str(path)) 

55 else: 

56 # Try to import as module name 

57 return importlib.import_module(module_path) 

58 

59 except ImportError as e: 

60 raise ModuleLoadError(f"Failed to import module: {e}", module_path, e) 

61 except Exception as e: 

62 raise ModuleLoadError(f"Error loading module: {e}", module_path, e) 

63 

64 

65class FunctionExtractor: 

66 """Extracts functions from modules and classes.""" 

67 

68 def __init__(self, config: Optional[FactoryConfig] = None): 

69 self.config = config or DEFAULT_CONFIG 

70 

71 def extract_from_module(self, module, include_private: bool = False) -> Dict[str, Callable]: 

72 """Extract functions from a module.""" 

73 functions = {} 

74 

75 try: 

76 for name in dir(module): 

77 # Skip special attributes 

78 if name.startswith('__'): 

79 continue 

80 

81 # Skip private functions if requested 

82 if not include_private and name.startswith('_'): 

83 continue 

84 

85 attr = getattr(module, name) 

86 

87 # Only include functions (not classes, imports, etc.) 

88 if callable(attr) and not inspect.isclass(attr): 

89 # Check if it's defined in this module (not imported) 

90 if hasattr(attr, '__module__'): 

91 if attr.__module__ == module.__name__: 

92 functions[name] = attr 

93 else: 

94 # Include if no module info (likely defined in this file) 

95 functions[name] = attr 

96 

97 logger.info(f"Extracted {len(functions)} functions from module") 

98 return functions 

99 

100 except Exception as e: 

101 raise FunctionExtractionError(f"Failed to extract functions from module: {e}") from e 

102 

103 def extract_from_class(self, cls: type, include_private: bool = False) -> Dict[str, Callable]: 

104 """Extract methods from a class.""" 

105 methods = {} 

106 

107 try: 

108 # Create an instance if needed 

109 instance = cls() 

110 

111 for name in dir(instance): 

112 if name.startswith('__'): 

113 continue 

114 if not include_private and name.startswith('_'): 

115 continue 

116 

117 attr = getattr(instance, name) 

118 if callable(attr) and not inspect.isclass(attr): 

119 methods[name] = attr 

120 

121 logger.info(f"Extracted {len(methods)} methods from class {cls.__name__}") 

122 return methods, instance 

123 

124 except Exception as e: 

125 raise FunctionExtractionError(f"Failed to extract methods from class {cls.__name__}: {e}") from e 

126 

127 def extract_decorated_functions(self, file_path: str, decorator_name: str = "mcp_tool") -> Dict[str, Callable]: 

128 """Extract functions decorated with a specific decorator.""" 

129 try: 

130 # Parse the file to find decorated functions 

131 with open(file_path, 'r') as f: 

132 tree = ast.parse(f.read()) 

133 

134 decorated_functions = [] 

135 for node in ast.walk(tree): 

136 if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)): 

137 for decorator in node.decorator_list: 

138 if isinstance(decorator, ast.Name) and decorator.id == decorator_name: 

139 decorated_functions.append(node.name) 

140 elif isinstance(decorator, ast.Call): 

141 if isinstance(decorator.func, ast.Name) and decorator.func.id == decorator_name: 

142 decorated_functions.append(node.name) 

143 

144 # Load the module and extract decorated functions 

145 loader = ModuleLoader(self.config) 

146 module = loader.load_module(file_path) 

147 

148 functions = {} 

149 for func_name in decorated_functions: 

150 if hasattr(module, func_name): 

151 functions[func_name] = getattr(module, func_name) 

152 

153 logger.info(f"Extracted {len(functions)} decorated functions") 

154 return functions 

155 

156 except Exception as e: 

157 raise FunctionExtractionError(f"Failed to extract decorated functions: {e}") from e 

158 

159 

160class MCPFactory: 

161 """Factory for creating MCP servers from Python code.""" 

162 

163 def __init__(self, name: Optional[str] = None, version: str = "1.0.0", config: Optional[FactoryConfig] = None): 

164 """ 

165 Initialize the MCP factory. 

166  

167 Args: 

168 name: Server name (defaults to module name) 

169 version: Server version 

170 config: Factory configuration 

171 """ 

172 self.name = name 

173 self.version = version 

174 self.config = config or DEFAULT_CONFIG 

175 

176 # Initialize components 

177 self.import_analyzer = ImportAnalyzer(self.config) 

178 self.module_loader = ModuleLoader(self.config) 

179 self.function_extractor = FunctionExtractor(self.config) 

180 self.wrapper_factory = ToolWrapperFactory(self.config) 

181 

182 self.server: Optional[QuickMCPServer] = None 

183 

184 def from_module(self, module_path: str, include_private: bool = False) -> QuickMCPServer: 

185 """ 

186 Create an MCP server from a Python module. 

187  

188 Args: 

189 module_path: Path to Python file or module name 

190 include_private: Include private functions (starting with _) 

191  

192 Returns: 

193 QuickMCPServer with tools generated from module functions 

194  

195 Raises: 

196 MissingDependencyError: If required dependencies are missing 

197 """ 

198 # Analyze dependencies before attempting to load 

199 missing_deps = [] 

200 if self.config.check_dependencies and Path(module_path).exists(): 

201 try: 

202 missing_deps = self.import_analyzer.analyze_file(module_path) 

203 except Exception as e: 

204 logger.warning(f"Dependency analysis failed: {e}") 

205 

206 # Check for required missing dependencies 

207 if missing_deps: 

208 required_deps = [dep for dep in missing_deps if dep.import_type != "optional"] 

209 if required_deps: 

210 raise MissingDependencyError( 

211 f"Cannot load module '{module_path}' due to missing dependencies", 

212 missing_deps, module_path 

213 ) 

214 

215 # Load the module 

216 try: 

217 module = self.module_loader.load_module(module_path) 

218 except ModuleLoadError as e: 

219 # If we have dependency analysis, enhance the error 

220 if missing_deps: 

221 raise MissingDependencyError( 

222 f"Failed to load module '{module_path}' due to missing dependencies", 

223 missing_deps, module_path 

224 ) from e 

225 raise 

226 

227 # Extract functions 

228 functions = self.function_extractor.extract_from_module(module, include_private) 

229 

230 # Create server 

231 module_name = module.__name__ if hasattr(module, '__name__') else Path(module_path).stem 

232 server_name = self.name or f"{module_name}-mcp" 

233 server_description = module.__doc__ or f"MCP server for {module_name}" 

234 

235 self.server = QuickMCPServer( 

236 name=server_name, 

237 version=self.version, 

238 description=server_description.strip() 

239 ) 

240 

241 # Register functions as tools 

242 for func_name, func in functions.items(): 

243 self._register_tool(func_name, func) 

244 

245 # Report optional dependencies 

246 if missing_deps and self.config.warn_on_optional_missing: 

247 optional_deps = [dep for dep in missing_deps if dep.import_type == "optional"] 

248 if optional_deps: 

249 logger.info(f"Note: {len(optional_deps)} optional dependencies not installed: " + 

250 ", ".join([dep.module for dep in optional_deps])) 

251 

252 logger.info(f"Created MCP server '{server_name}' with {len(functions)} tools") 

253 return self.server 

254 

255 def from_module_object(self, module, include_private: bool = False) -> QuickMCPServer: 

256 """ 

257 Create an MCP server from an already-loaded module object. 

258  

259 Args: 

260 module: Already-loaded module object 

261 include_private: Include private functions 

262  

263 Returns: 

264 QuickMCPServer with module functions as tools 

265 """ 

266 # Extract functions from the module 

267 functions = self.function_extractor.extract_from_module(module, include_private) 

268 

269 # Create server 

270 module_name = module.__name__ if hasattr(module, '__name__') else "module" 

271 server_name = self.name or f"{module_name.replace('.', '-')}-mcp" 

272 server_description = module.__doc__ or f"MCP server for {module_name}" 

273 

274 self.server = QuickMCPServer( 

275 name=server_name, 

276 version=self.version, 

277 description=server_description.strip() if server_description else "" 

278 ) 

279 

280 # Register functions as tools 

281 for func_name, func in functions.items(): 

282 self._register_tool(func_name, func) 

283 

284 logger.info(f"Created MCP server '{server_name}' with {len(functions)} tools") 

285 return self.server 

286 

287 def from_functions(self, functions: Dict[str, Callable], name: Optional[str] = None) -> QuickMCPServer: 

288 """ 

289 Create an MCP server from a dictionary of functions. 

290  

291 Args: 

292 functions: Dictionary mapping tool names to functions 

293 name: Server name 

294  

295 Returns: 

296 QuickMCPServer with specified functions as tools 

297 """ 

298 server_name = name or self.name or "custom-mcp" 

299 

300 self.server = QuickMCPServer( 

301 name=server_name, 

302 version=self.version, 

303 description=f"MCP server with {len(functions)} custom tools" 

304 ) 

305 

306 for func_name, func in functions.items(): 

307 self._register_tool(func_name, func) 

308 

309 logger.info(f"Created MCP server '{server_name}' with {len(functions)} tools") 

310 return self.server 

311 

312 def from_class(self, cls: type, include_private: bool = False) -> QuickMCPServer: 

313 """ 

314 Create an MCP server from a class. 

315  

316 Args: 

317 cls: Class to extract methods from 

318 include_private: Include private methods 

319  

320 Returns: 

321 QuickMCPServer with class methods as tools 

322 """ 

323 class_name = cls.__name__ 

324 server_name = self.name or f"{class_name.lower()}-mcp" 

325 

326 self.server = QuickMCPServer( 

327 name=server_name, 

328 version=self.version, 

329 description=cls.__doc__ or f"MCP server for {class_name}" 

330 ) 

331 

332 # Extract methods and instance 

333 methods, instance = self.function_extractor.extract_from_class(cls, include_private) 

334 

335 # Register methods as tools 

336 for method_name, method in methods.items(): 

337 try: 

338 wrapper = self.wrapper_factory.create_method_wrapper(method, instance, method_name) 

339 self.server.add_tool_from_function( 

340 wrapper.wrapper, 

341 name=method_name, 

342 description=wrapper.doc.strip() if wrapper.doc else f"Execute {method_name}" 

343 ) 

344 except Exception as e: 

345 error = ToolRegistrationError(f"Failed to register method '{method_name}': {e}", method_name, e) 

346 log_factory_error(error, f"Class {class_name}") 

347 if self.config.strict_type_conversion: 

348 raise error 

349 

350 logger.info(f"Created MCP server '{server_name}' with {len(methods)} method tools") 

351 return self.server 

352 

353 def from_file_with_decorators(self, file_path: str, decorator_name: str = "mcp_tool") -> QuickMCPServer: 

354 """ 

355 Create an MCP server from functions decorated with a specific decorator. 

356  

357 Args: 

358 file_path: Path to Python file 

359 decorator_name: Name of decorator to look for 

360  

361 Returns: 

362 QuickMCPServer with decorated functions as tools 

363 """ 

364 # Extract decorated functions 

365 functions = self.function_extractor.extract_decorated_functions(file_path, decorator_name) 

366 

367 # Create server 

368 module_name = Path(file_path).stem 

369 server_name = self.name or f"{module_name}-mcp" 

370 

371 self.server = QuickMCPServer( 

372 name=server_name, 

373 version=self.version, 

374 description=f"MCP server for {module_name}" 

375 ) 

376 

377 # Register functions as tools 

378 for func_name, func in functions.items(): 

379 self._register_tool(func_name, func) 

380 

381 logger.info(f"Created MCP server '{server_name}' with {len(functions)} decorated tools") 

382 return self.server 

383 

384 def _register_tool(self, name: str, func: Callable): 

385 """Register a function as an MCP tool.""" 

386 if not self.server: 

387 raise ToolRegistrationError("Server not initialized", name) 

388 

389 try: 

390 wrapper = self.wrapper_factory.create_wrapper(func, name) 

391 self.server.add_tool_from_function( 

392 wrapper.wrapper, 

393 name=name, 

394 description=wrapper.doc.strip() if wrapper.doc else f"Execute {name}" 

395 ) 

396 except Exception as e: 

397 error = ToolRegistrationError(f"Failed to register tool '{name}': {e}", name, e) 

398 log_factory_error(error, f"Tool registration") 

399 if self.config.strict_type_conversion: 

400 raise error 

401 

402 def analyze_dependencies(self, module_path: str) -> List[MissingDependency]: 

403 """ 

404 Analyze dependencies of a Python file without loading it. 

405  

406 Args: 

407 module_path: Path to Python file 

408  

409 Returns: 

410 List of missing dependencies 

411 """ 

412 if not Path(module_path).exists(): 

413 return [] 

414 return self.import_analyzer.analyze_file(module_path) 

415 

416 def check_dependencies(self, module_path: str) -> Dict[str, Any]: 

417 """ 

418 Check dependencies and return a summary report. 

419  

420 Args: 

421 module_path: Path to Python file 

422  

423 Returns: 

424 Dictionary with dependency analysis results 

425 """ 

426 missing_deps = self.analyze_dependencies(module_path) 

427 

428 required = [dep for dep in missing_deps if dep.import_type != "optional"] 

429 optional = [dep for dep in missing_deps if dep.import_type == "optional"] 

430 dev_deps = [dep for dep in missing_deps if dep.is_dev_dependency] 

431 

432 return { 

433 "file": module_path, 

434 "total_missing": len(missing_deps), 

435 "required_missing": len(required), 

436 "optional_missing": len(optional), 

437 "dev_missing": len(dev_deps), 

438 "required_dependencies": required, 

439 "optional_dependencies": optional, 

440 "dev_dependencies": dev_deps, 

441 "can_load": len(required) == 0, 

442 "install_command": self._generate_install_command(required) if required else None 

443 } 

444 

445 def _generate_install_command(self, dependencies: List[MissingDependency]) -> str: 

446 """Generate a pip install command for missing dependencies.""" 

447 packages = [] 

448 for dep in dependencies: 

449 packages.append(dep.suggested_install or dep.module) 

450 return f"pip install {' '.join(set(packages))}" # Remove duplicates