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
« 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."""
3import importlib
4import inspect
5from contextlib import suppress
6from pathlib import Path
7from typing import Any
8from uuid import UUID
10from acb.config import AdapterBase
11from acb.depends import depends
14class AdapterInfo:
15 """Information about a discovered adapter."""
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
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 }
54class AdapterDiscoveryServer:
55 """MCP server for discovering and introspecting FastBlocks adapters."""
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]] = {}
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
68 self._discovered_adapters = {}
69 self._category_map = {}
71 # Discover adapters from filesystem
72 await self._discover_from_filesystem()
74 # Discover adapters from ACB registry
75 await self._discover_from_acb_registry()
77 return self._discovered_adapters
79 async def _discover_from_filesystem(self) -> None:
80 """Discover adapters by scanning the filesystem."""
81 if not self.adapters_root.exists():
82 return
84 for category_dir in self.adapters_root.iterdir():
85 if not category_dir.is_dir() or category_dir.name.startswith("_"):
86 continue
88 category = category_dir.name
89 self._category_map[category] = []
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)
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
106 # Get all registered instances that might be adapters
107 registry = getattr(depends, "_registry", {})
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 )
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)
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 )
134 self._discovered_adapters[adapter_name] = info
136 if category not in self._category_map:
137 self._category_map[category] = []
138 self._category_map[category].append(adapter_name)
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}"
145 with suppress(Exception):
146 module = importlib.import_module(module_name)
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", "")
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 )
172 self._discovered_adapters[adapter_name] = info
173 self._category_map[category].append(adapter_name)
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 )
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"
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 ""
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 ]
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__
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
221 return ""
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)
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, [])]
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())
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
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
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