Coverage for fastblocks/mcp/discovery.py: 57%

121 statements  

« prev     ^ index     » next       coverage.py v7.10.7, created at 2025-10-09 00:47 -0700

1"""MCP server for FastBlocks adapter discovery and introspection.""" 

2 

3import importlib 

4import inspect 

5from contextlib import suppress 

6from pathlib import Path 

7from typing import Any 

8from uuid import UUID 

9 

10from acb.config import AdapterBase 

11from acb.depends import depends 

12 

13 

14class AdapterInfo: 

15 """Information about a discovered adapter.""" 

16 

17 def __init__( 

18 self, 

19 name: str, 

20 module_path: str, 

21 class_name: str, 

22 module_id: UUID, 

23 module_status: str, 

24 category: str, 

25 description: str = "", 

26 protocols: list[str] | None = None, 

27 settings_class: str = "", 

28 ): 

29 self.name = name 

30 self.module_path = module_path 

31 self.class_name = class_name 

32 self.module_id = module_id 

33 self.module_status = module_status 

34 self.category = category 

35 self.description = description 

36 self.protocols = protocols or [] 

37 self.settings_class = settings_class 

38 

39 def to_dict(self) -> dict[str, Any]: 

40 """Convert adapter info to dictionary.""" 

41 return { 

42 "name": self.name, 

43 "module_path": self.module_path, 

44 "class_name": self.class_name, 

45 "module_id": str(self.module_id), 

46 "module_status": self.module_status, 

47 "category": self.category, 

48 "description": self.description, 

49 "protocols": self.protocols, 

50 "settings_class": self.settings_class, 

51 } 

52 

53 

54class AdapterDiscoveryServer: 

55 """MCP server for discovering and introspecting FastBlocks adapters.""" 

56 

57 def __init__(self, adapters_root: Path | None = None): 

58 """Initialize discovery server.""" 

59 self.adapters_root = adapters_root or Path(__file__).parent.parent / "adapters" 

60 self._discovered_adapters: dict[str, AdapterInfo] = {} 

61 self._category_map: dict[str, list[str]] = {} 

62 

63 async def discover_adapters(self) -> dict[str, AdapterInfo]: 

64 """Discover all available adapters in the FastBlocks system.""" 

65 if self._discovered_adapters: 

66 return self._discovered_adapters 

67 

68 self._discovered_adapters = {} 

69 self._category_map = {} 

70 

71 # Discover adapters from filesystem 

72 await self._discover_from_filesystem() 

73 

74 # Discover adapters from ACB registry 

75 await self._discover_from_acb_registry() 

76 

77 return self._discovered_adapters 

78 

79 async def _discover_from_filesystem(self) -> None: 

80 """Discover adapters by scanning the filesystem.""" 

81 if not self.adapters_root.exists(): 

82 return 

83 

84 for category_dir in self.adapters_root.iterdir(): 

85 if not category_dir.is_dir() or category_dir.name.startswith("_"): 

86 continue 

87 

88 category = category_dir.name 

89 self._category_map[category] = [] 

90 

91 for adapter_file in category_dir.iterdir(): 

92 if ( 

93 adapter_file.is_file() 

94 and adapter_file.suffix == ".py" 

95 and not adapter_file.name.startswith("_") 

96 ): 

97 await self._inspect_adapter_file(adapter_file, category) 

98 

99 async def _discover_from_acb_registry(self) -> None: 

100 """Discover adapters from ACB dependency registry.""" 

101 with suppress(Exception): 

102 # Try to get adapters from ACB registry 

103 # This may not be available in all ACB versions 

104 from acb.depends import depends 

105 

106 # Get all registered instances that might be adapters 

107 registry = getattr(depends, "_registry", {}) 

108 

109 for key, adapter in registry.items(): 

110 if hasattr(adapter, "MODULE_ID") and hasattr(adapter, "MODULE_STATUS"): 

111 adapter_name = adapter.__class__.__name__.lower().replace( 

112 "adapter", "" 

113 ) 

114 

115 if adapter_name not in self._discovered_adapters: 

116 # Try to determine category from module path 

117 module_path = adapter.__class__.__module__ 

118 category = self._extract_category_from_module(module_path) 

119 

120 info = AdapterInfo( 

121 name=adapter_name, 

122 module_path=module_path, 

123 class_name=adapter.__class__.__name__, 

124 module_id=adapter.MODULE_ID, 

125 module_status=adapter.MODULE_STATUS, 

126 category=category, 

127 description=self._extract_description(adapter.__class__), 

128 protocols=self._extract_protocols(adapter.__class__), 

129 settings_class=self._extract_settings_class( 

130 adapter.__class__ 

131 ), 

132 ) 

133 

134 self._discovered_adapters[adapter_name] = info 

135 

136 if category not in self._category_map: 

137 self._category_map[category] = [] 

138 self._category_map[category].append(adapter_name) 

139 

140 async def _inspect_adapter_file(self, adapter_file: Path, category: str) -> None: 

141 """Inspect a single adapter file for adapter classes.""" 

142 with suppress(Exception): 

143 module_name = f"fastblocks.adapters.{category}.{adapter_file.stem}" 

144 

145 with suppress(Exception): 

146 module = importlib.import_module(module_name) 

147 

148 for name, obj in inspect.getmembers(module, inspect.isclass): 

149 if ( 

150 obj.__module__ == module_name 

151 and self._is_adapter_class(obj) 

152 and not name.endswith(("Base", "Protocol")) 

153 ): 

154 adapter_name = name.lower().replace("adapter", "") 

155 

156 info = AdapterInfo( 

157 name=adapter_name, 

158 module_path=module_name, 

159 class_name=name, 

160 module_id=getattr( 

161 obj, 

162 "MODULE_ID", 

163 UUID("00000000-0000-0000-0000-000000000000"), 

164 ), 

165 module_status=getattr(obj, "MODULE_STATUS", "unknown"), 

166 category=category, 

167 description=self._extract_description(obj), 

168 protocols=self._extract_protocols(obj), 

169 settings_class=self._extract_settings_class(obj), 

170 ) 

171 

172 self._discovered_adapters[adapter_name] = info 

173 self._category_map[category].append(adapter_name) 

174 

175 def _is_adapter_class(self, cls: type) -> bool: 

176 """Check if a class is an adapter class.""" 

177 return ( 

178 issubclass(cls, AdapterBase) 

179 or hasattr(cls, "MODULE_ID") 

180 or any("adapter" in base.__name__.lower() for base in cls.__bases__) 

181 ) 

182 

183 def _extract_category_from_module(self, module_path: str) -> str: 

184 """Extract category from module path.""" 

185 parts = module_path.split(".") 

186 for i, part in enumerate(parts): 

187 if part == "adapters" and i + 1 < len(parts): 

188 return parts[i + 1] 

189 return "unknown" 

190 

191 def _extract_description(self, cls: type) -> str: 

192 """Extract description from class docstring.""" 

193 doc = cls.__doc__ 

194 if doc: 

195 return doc.split("\n")[0].strip('."""') 

196 return "" 

197 

198 def _extract_protocols(self, cls: type) -> list[str]: 

199 """Extract implemented protocols from class.""" 

200 return [ 

201 base.__name__ 

202 for base in cls.__bases__ 

203 if hasattr(base, "__name__") and "protocol" in base.__name__.lower() 

204 ] 

205 

206 def _extract_settings_class(self, cls: type) -> str: 

207 """Extract settings class name from adapter.""" 

208 # Look for settings attribute or Settings class in module 

209 if hasattr(cls, "settings"): 

210 settings_obj = getattr(cls, "settings", None) 

211 if hasattr(settings_obj, "__class__"): 

212 return settings_obj.__class__.__name__ 

213 

214 # Look for Settings class in same module 

215 with suppress(Exception): 

216 module = importlib.import_module(cls.__module__) 

217 for name, obj in inspect.getmembers(module, inspect.isclass): 

218 if name.endswith("Settings") and obj.__module__ == cls.__module__: 

219 return name 

220 

221 return "" 

222 

223 async def get_adapter_by_name(self, name: str) -> AdapterInfo | None: 

224 """Get adapter information by name.""" 

225 adapters = await self.discover_adapters() 

226 return adapters.get(name) 

227 

228 async def get_adapters_by_category(self, category: str) -> list[AdapterInfo]: 

229 """Get all adapters in a specific category.""" 

230 adapters = await self.discover_adapters() 

231 return [adapters[name] for name in self._category_map.get(category, [])] 

232 

233 async def get_all_categories(self) -> list[str]: 

234 """Get all available adapter categories.""" 

235 await self.discover_adapters() 

236 return list(self._category_map.keys()) 

237 

238 async def get_adapter_instance(self, name: str) -> Any | None: 

239 """Get an actual adapter instance from ACB registry.""" 

240 try: 

241 return depends.get(name) 

242 except Exception: 

243 return None 

244 

245 async def instantiate_adapter(self, name: str) -> Any | None: 

246 """Instantiate an adapter by name.""" 

247 adapter_info = await self.get_adapter_by_name(name) 

248 if not adapter_info: 

249 return None 

250 

251 try: 

252 module = importlib.import_module(adapter_info.module_path) 

253 adapter_class = getattr(module, adapter_info.class_name) 

254 return adapter_class() 

255 except Exception: 

256 return None