Coverage for fastblocks/mcp/tools.py: 0%

167 statements  

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

1"""MCP tools for FastBlocks template, component, and adapter management.""" 

2 

3import logging 

4import operator 

5from pathlib import Path 

6from typing import Any 

7 

8from acb.depends import depends 

9 

10from .discovery import AdapterDiscoveryServer 

11from .health import HealthCheckSystem 

12 

13logger = logging.getLogger(__name__) 

14 

15 

16# Template Management Tools 

17 

18 

19async def create_template( 

20 name: str, 

21 template_type: str = "jinja2", 

22 variant: str = "base", 

23 content: str = "", 

24) -> dict[str, Any]: 

25 """Create a new FastBlocks template. 

26 

27 Args: 

28 name: Template name (e.g., "user_card") 

29 template_type: Template engine (jinja2, htmy) 

30 variant: Template variant/theme (base, bulma, etc.) 

31 content: Template content (optional, uses default if empty) 

32 

33 Returns: 

34 Dict with template creation status and path 

35 """ 

36 try: 

37 # Determine template directory 

38 templates_root = Path.cwd() / "templates" / variant / "blocks" 

39 templates_root.mkdir(parents=True, exist_ok=True) 

40 

41 # Determine file extension 

42 extension = ".html" if template_type == "jinja2" else ".py" 

43 template_path = templates_root / f"{name}{extension}" 

44 

45 if template_path.exists(): 

46 return { 

47 "success": False, 

48 "error": f"Template already exists: {template_path}", 

49 "path": str(template_path), 

50 } 

51 

52 # Generate default content if not provided 

53 if not content: 

54 if template_type == "jinja2": 

55 content = f"""[# {name} template #] 

56<div class="{name}"> 

57 [[ content ]] 

58</div> 

59""" 

60 else: # htmy 

61 content = f'''"""HTMY component: {name}""" 

62from dataclasses import dataclass 

63from typing import Any 

64 

65 

66@dataclass 

67class {name.title().replace("_", "")}: 

68 """HTMY component for {name}.""" 

69 

70 content: str = "" 

71 

72 def htmy(self, context: dict[str, Any]) -> str: 

73 return f""" 

74 <div class="{name}"> 

75 {{self.content}} 

76 </div> 

77 """ 

78''' 

79 

80 # Write template file 

81 template_path.write_text(content) 

82 

83 return { 

84 "success": True, 

85 "path": str(template_path), 

86 "type": template_type, 

87 "variant": variant, 

88 } 

89 

90 except Exception as e: 

91 logger.error(f"Error creating template: {e}") 

92 return {"success": False, "error": str(e)} 

93 

94 

95async def validate_template(template_path: str) -> dict[str, Any]: 

96 """Validate a FastBlocks template for syntax errors. 

97 

98 Args: 

99 template_path: Path to template file 

100 

101 Returns: 

102 Dict with validation status and any errors found 

103 """ 

104 try: 

105 path = Path(template_path) 

106 if not path.exists(): 

107 return {"success": False, "error": f"Template not found: {template_path}"} 

108 

109 content = path.read_text() 

110 

111 # Get syntax support from ACB registry 

112 syntax_support = depends.get("syntax_support") 

113 if syntax_support is None: 

114 return { 

115 "success": False, 

116 "error": "Syntax support not available", 

117 } 

118 

119 # Check syntax 

120 errors = syntax_support.check_syntax(content, path) 

121 

122 if not errors: 

123 return {"success": True, "errors": [], "path": template_path} 

124 

125 return { 

126 "success": False, 

127 "errors": [ 

128 { 

129 "line": e.line, 

130 "column": e.column, 

131 "message": e.message, 

132 "severity": e.severity, 

133 "code": e.code, 

134 "fix_suggestion": e.fix_suggestion, 

135 } 

136 for e in errors 

137 ], 

138 "path": template_path, 

139 } 

140 

141 except Exception as e: 

142 logger.error(f"Error validating template: {e}") 

143 return {"success": False, "error": str(e)} 

144 

145 

146def _should_skip_variant_dir(variant_dir: Path, variant_filter: str | None) -> bool: 

147 """Check if variant directory should be skipped. 

148 

149 Args: 

150 variant_dir: Variant directory path 

151 variant_filter: Optional variant filter 

152 

153 Returns: 

154 True if should skip, False otherwise 

155 """ 

156 if not variant_dir.is_dir(): 

157 return True 

158 

159 if variant_filter and variant_dir.name != variant_filter: 

160 return True 

161 

162 return False 

163 

164 

165def _determine_template_type(suffix: str) -> str: 

166 """Determine template type from file suffix. 

167 

168 Args: 

169 suffix: File suffix (e.g., '.html', '.py') 

170 

171 Returns: 

172 Template type ('jinja2' or 'htmy') 

173 """ 

174 return "jinja2" if suffix == ".html" else "htmy" 

175 

176 

177def _create_template_info(template_file: Path, variant_name: str) -> dict[str, str]: 

178 """Create template info dictionary. 

179 

180 Args: 

181 template_file: Template file path 

182 variant_name: Variant directory name 

183 

184 Returns: 

185 Template info dict 

186 """ 

187 return { 

188 "name": template_file.stem, 

189 "path": str(template_file), 

190 "variant": variant_name, 

191 "type": _determine_template_type(template_file.suffix), 

192 } 

193 

194 

195def _collect_variant_templates(variant_dir: Path) -> list[dict[str, str]]: 

196 """Collect all template files from a variant directory. 

197 

198 Args: 

199 variant_dir: Variant directory path 

200 

201 Returns: 

202 List of template info dicts 

203 """ 

204 templates: list[dict[str, str]] = [] 

205 blocks_dir = variant_dir / "blocks" 

206 

207 if not blocks_dir.exists(): 

208 return templates 

209 

210 for template_file in blocks_dir.rglob("*"): 

211 if template_file.is_file() and template_file.suffix in (".html", ".py"): 

212 templates.append(_create_template_info(template_file, variant_dir.name)) 

213 

214 return templates 

215 

216 

217async def list_templates(variant: str | None = None) -> dict[str, Any]: 

218 """List all available FastBlocks templates. 

219 

220 Args: 

221 variant: Optional variant filter (base, bulma, etc.) 

222 

223 Returns: 

224 Dict with list of discovered templates 

225 """ 

226 try: 

227 templates_root = Path.cwd() / "templates" 

228 

229 # Guard clause: no templates directory 

230 if not templates_root.exists(): 

231 return {"success": True, "templates": [], "count": 0} 

232 

233 templates: list[dict[str, str]] = [] 

234 

235 # Search for template files in each variant directory 

236 for variant_dir in templates_root.iterdir(): 

237 if _should_skip_variant_dir(variant_dir, variant): 

238 continue 

239 

240 templates.extend(_collect_variant_templates(variant_dir)) 

241 

242 return { 

243 "success": True, 

244 "templates": sorted(templates, key=operator.itemgetter("name")), 

245 "count": len(templates), 

246 } 

247 

248 except Exception as e: 

249 logger.error(f"Error listing templates: {e}") 

250 return {"success": False, "error": str(e)} 

251 

252 

253async def render_template( 

254 template_name: str, context: dict[str, Any] | None = None, variant: str = "base" 

255) -> dict[str, Any]: 

256 """Render a template with given context for testing. 

257 

258 Args: 

259 template_name: Template name to render 

260 context: Template context variables 

261 variant: Template variant (base, bulma, etc.) 

262 

263 Returns: 

264 Dict with rendered output or error 

265 """ 

266 try: 

267 # Get template adapter from ACB 

268 templates = depends.get("templates") 

269 if templates is None: 

270 return { 

271 "success": False, 

272 "error": "Template adapter not available", 

273 } 

274 

275 # Render template 

276 context = context or {} 

277 rendered = await templates.render( 

278 f"{variant}/blocks/{template_name}.html", context 

279 ) 

280 

281 return { 

282 "success": True, 

283 "output": rendered, 

284 "template": template_name, 

285 "variant": variant, 

286 } 

287 

288 except Exception as e: 

289 logger.error(f"Error rendering template: {e}") 

290 return {"success": False, "error": str(e)} 

291 

292 

293# Component Management Tools 

294 

295 

296async def create_component( 

297 name: str, 

298 component_type: str = "dataclass", 

299 props: dict[str, str] | None = None, 

300 htmx_enabled: bool = False, 

301) -> dict[str, Any]: 

302 """Create a new HTMY component. 

303 

304 Args: 

305 name: Component name 

306 component_type: Component type (basic, dataclass, htmx, composite) 

307 props: Component properties as {name: type} 

308 htmx_enabled: Enable HTMX features 

309 

310 Returns: 

311 Dict with component creation status 

312 """ 

313 try: 

314 htmy_adapter = depends.get("htmy") 

315 if htmy_adapter is None: 

316 return { 

317 "success": False, 

318 "error": "HTMY adapter not available", 

319 } 

320 

321 from fastblocks.adapters.templates._htmy_components import ComponentType 

322 

323 # Map string type to ComponentType enum 

324 type_map = { 

325 "basic": ComponentType.BASIC, 

326 "dataclass": ComponentType.DATACLASS, 

327 "htmx": ComponentType.HTMX, 

328 "composite": ComponentType.COMPOSITE, 

329 } 

330 

331 comp_type = type_map.get(component_type.lower(), ComponentType.DATACLASS) 

332 

333 # Scaffold component 

334 kwargs: dict[str, Any] = {} 

335 if props: 

336 kwargs["props"] = props 

337 if htmx_enabled: 

338 kwargs["htmx_enabled"] = htmx_enabled 

339 

340 created_path = await htmy_adapter.scaffold_component( 

341 name=name, component_type=comp_type, **kwargs 

342 ) 

343 

344 return { 

345 "success": True, 

346 "path": str(created_path), 

347 "name": name, 

348 "type": component_type, 

349 } 

350 

351 except Exception as e: 

352 logger.error(f"Error creating component: {e}") 

353 return {"success": False, "error": str(e)} 

354 

355 

356async def list_components() -> dict[str, Any]: 

357 """List all discovered HTMY components. 

358 

359 Returns: 

360 Dict with list of components and metadata 

361 """ 

362 try: 

363 htmy_adapter = depends.get("htmy") 

364 if htmy_adapter is None: 

365 return { 

366 "success": False, 

367 "error": "HTMY adapter not available", 

368 } 

369 

370 components = await htmy_adapter.discover_components() 

371 

372 return { 

373 "success": True, 

374 "components": [ 

375 { 

376 "name": name, 

377 "path": str(metadata.path), 

378 "type": metadata.type.value, 

379 "status": metadata.status.value, 

380 "docstring": metadata.docstring, 

381 "error": metadata.error_message, 

382 } 

383 for name, metadata in components.items() 

384 ], 

385 "count": len(components), 

386 } 

387 

388 except Exception as e: 

389 logger.error(f"Error listing components: {e}") 

390 return {"success": False, "error": str(e)} 

391 

392 

393async def validate_component(component_name: str) -> dict[str, Any]: 

394 """Validate an HTMY component. 

395 

396 Args: 

397 component_name: Component name to validate 

398 

399 Returns: 

400 Dict with validation status and metadata 

401 """ 

402 try: 

403 htmy_adapter = depends.get("htmy") 

404 if htmy_adapter is None: 

405 return { 

406 "success": False, 

407 "error": "HTMY adapter not available", 

408 } 

409 

410 metadata = await htmy_adapter.validate_component(component_name) 

411 

412 return { 

413 "success": metadata.status.value != "error", 

414 "component": component_name, 

415 "type": metadata.type.value, 

416 "status": metadata.status.value, 

417 "path": str(metadata.path), 

418 "docstring": metadata.docstring, 

419 "error": metadata.error_message, 

420 "dependencies": metadata.dependencies, 

421 } 

422 

423 except Exception as e: 

424 logger.error(f"Error validating component: {e}") 

425 return {"success": False, "error": str(e)} 

426 

427 

428# Adapter Configuration Tools 

429 

430 

431async def configure_adapter( 

432 adapter_name: str, settings: dict[str, Any] 

433) -> dict[str, Any]: 

434 """Configure a FastBlocks adapter. 

435 

436 Args: 

437 adapter_name: Adapter name (e.g., "templates", "auth") 

438 settings: Adapter configuration settings 

439 

440 Returns: 

441 Dict with configuration status 

442 """ 

443 try: 

444 # Get adapter from ACB registry 

445 adapter = depends.get(adapter_name) 

446 if adapter is None: 

447 return { 

448 "success": False, 

449 "error": f"Adapter '{adapter_name}' not found", 

450 } 

451 

452 # Update adapter settings 

453 for key, value in settings.items(): 

454 if hasattr(adapter, key): 

455 setattr(adapter, key, value) 

456 

457 return { 

458 "success": True, 

459 "adapter": adapter_name, 

460 "settings": settings, 

461 } 

462 

463 except Exception as e: 

464 logger.error(f"Error configuring adapter: {e}") 

465 return {"success": False, "error": str(e)} 

466 

467 

468async def list_adapters(category: str | None = None) -> dict[str, Any]: 

469 """List all available FastBlocks adapters. 

470 

471 Args: 

472 category: Optional category filter 

473 

474 Returns: 

475 Dict with list of adapters and metadata 

476 """ 

477 try: 

478 discovery = AdapterDiscoveryServer() 

479 adapters = await discovery.discover_adapters() 

480 

481 # Filter by category if specified 

482 if category: 

483 adapters = { 

484 name: info 

485 for name, info in adapters.items() 

486 if info.category == category 

487 } 

488 

489 return { 

490 "success": True, 

491 "adapters": [info.to_dict() for info in adapters.values()], 

492 "count": len(adapters), 

493 "categories": list({info.category for info in adapters.values()}), 

494 } 

495 

496 except Exception as e: 

497 logger.error(f"Error listing adapters: {e}") 

498 return {"success": False, "error": str(e)} 

499 

500 

501async def check_adapter_health(adapter_name: str | None = None) -> dict[str, Any]: 

502 """Check health status of adapters. 

503 

504 Args: 

505 adapter_name: Optional specific adapter to check 

506 

507 Returns: 

508 Dict with health check results 

509 """ 

510 try: 

511 from .registry import AdapterRegistry 

512 

513 # Create registry and health system 

514 registry = AdapterRegistry() 

515 health_system = HealthCheckSystem(registry) 

516 results_dict = await health_system.check_all_adapters() 

517 

518 # Convert to list of dicts 

519 results = [ 

520 {"adapter": name} | result.to_dict() 

521 for name, result in results_dict.items() 

522 ] 

523 

524 # Filter by adapter if specified 

525 if adapter_name: 

526 results = [r for r in results if r["adapter"] == adapter_name] 

527 

528 return { 

529 "success": True, 

530 "checks": results, 

531 "count": len(results), 

532 "healthy": sum(1 for r in results if r["status"] == "healthy"), 

533 "unhealthy": sum(1 for r in results if r["status"] != "healthy"), 

534 } 

535 

536 except Exception as e: 

537 logger.error(f"Error checking adapter health: {e}") 

538 return {"success": False, "error": str(e)} 

539 

540 

541# Tool registration function 

542 

543 

544async def register_fastblocks_tools(server: Any) -> None: 

545 """Register all FastBlocks MCP tools with the server. 

546 

547 Args: 

548 server: MCP server instance from ACB 

549 """ 

550 try: 

551 from acb.mcp import register_tools # type: ignore[attr-defined] 

552 

553 # Define tool registry 

554 tools = { 

555 # Template tools 

556 "create_template": create_template, 

557 "validate_template": validate_template, 

558 "list_templates": list_templates, 

559 "render_template": render_template, 

560 # Component tools 

561 "create_component": create_component, 

562 "list_components": list_components, 

563 "validate_component": validate_component, 

564 # Adapter tools 

565 "configure_adapter": configure_adapter, 

566 "list_adapters": list_adapters, 

567 "check_adapter_health": check_adapter_health, 

568 } 

569 

570 # Register tools with MCP server 

571 await register_tools(server, tools) # type: ignore[misc] 

572 

573 logger.info(f"Registered {len(tools)} FastBlocks MCP tools") 

574 

575 except Exception as e: 

576 logger.error(f"Failed to register MCP tools: {e}") 

577 raise