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
« 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."""
3import logging
4import operator
5from pathlib import Path
6from typing import Any
8from acb.depends import depends
10from .discovery import AdapterDiscoveryServer
11from .health import HealthCheckSystem
13logger = logging.getLogger(__name__)
16# Template Management Tools
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.
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)
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)
41 # Determine file extension
42 extension = ".html" if template_type == "jinja2" else ".py"
43 template_path = templates_root / f"{name}{extension}"
45 if template_path.exists():
46 return {
47 "success": False,
48 "error": f"Template already exists: {template_path}",
49 "path": str(template_path),
50 }
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
66@dataclass
67class {name.title().replace("_", "")}:
68 """HTMY component for {name}."""
70 content: str = ""
72 def htmy(self, context: dict[str, Any]) -> str:
73 return f"""
74 <div class="{name}">
75 {{self.content}}
76 </div>
77 """
78'''
80 # Write template file
81 template_path.write_text(content)
83 return {
84 "success": True,
85 "path": str(template_path),
86 "type": template_type,
87 "variant": variant,
88 }
90 except Exception as e:
91 logger.error(f"Error creating template: {e}")
92 return {"success": False, "error": str(e)}
95async def validate_template(template_path: str) -> dict[str, Any]:
96 """Validate a FastBlocks template for syntax errors.
98 Args:
99 template_path: Path to template file
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}"}
109 content = path.read_text()
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 }
119 # Check syntax
120 errors = syntax_support.check_syntax(content, path)
122 if not errors:
123 return {"success": True, "errors": [], "path": template_path}
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 }
141 except Exception as e:
142 logger.error(f"Error validating template: {e}")
143 return {"success": False, "error": str(e)}
146def _should_skip_variant_dir(variant_dir: Path, variant_filter: str | None) -> bool:
147 """Check if variant directory should be skipped.
149 Args:
150 variant_dir: Variant directory path
151 variant_filter: Optional variant filter
153 Returns:
154 True if should skip, False otherwise
155 """
156 if not variant_dir.is_dir():
157 return True
159 if variant_filter and variant_dir.name != variant_filter:
160 return True
162 return False
165def _determine_template_type(suffix: str) -> str:
166 """Determine template type from file suffix.
168 Args:
169 suffix: File suffix (e.g., '.html', '.py')
171 Returns:
172 Template type ('jinja2' or 'htmy')
173 """
174 return "jinja2" if suffix == ".html" else "htmy"
177def _create_template_info(template_file: Path, variant_name: str) -> dict[str, str]:
178 """Create template info dictionary.
180 Args:
181 template_file: Template file path
182 variant_name: Variant directory name
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 }
195def _collect_variant_templates(variant_dir: Path) -> list[dict[str, str]]:
196 """Collect all template files from a variant directory.
198 Args:
199 variant_dir: Variant directory path
201 Returns:
202 List of template info dicts
203 """
204 templates: list[dict[str, str]] = []
205 blocks_dir = variant_dir / "blocks"
207 if not blocks_dir.exists():
208 return templates
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))
214 return templates
217async def list_templates(variant: str | None = None) -> dict[str, Any]:
218 """List all available FastBlocks templates.
220 Args:
221 variant: Optional variant filter (base, bulma, etc.)
223 Returns:
224 Dict with list of discovered templates
225 """
226 try:
227 templates_root = Path.cwd() / "templates"
229 # Guard clause: no templates directory
230 if not templates_root.exists():
231 return {"success": True, "templates": [], "count": 0}
233 templates: list[dict[str, str]] = []
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
240 templates.extend(_collect_variant_templates(variant_dir))
242 return {
243 "success": True,
244 "templates": sorted(templates, key=operator.itemgetter("name")),
245 "count": len(templates),
246 }
248 except Exception as e:
249 logger.error(f"Error listing templates: {e}")
250 return {"success": False, "error": str(e)}
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.
258 Args:
259 template_name: Template name to render
260 context: Template context variables
261 variant: Template variant (base, bulma, etc.)
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 }
275 # Render template
276 context = context or {}
277 rendered = await templates.render(
278 f"{variant}/blocks/{template_name}.html", context
279 )
281 return {
282 "success": True,
283 "output": rendered,
284 "template": template_name,
285 "variant": variant,
286 }
288 except Exception as e:
289 logger.error(f"Error rendering template: {e}")
290 return {"success": False, "error": str(e)}
293# Component Management Tools
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.
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
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 }
321 from fastblocks.adapters.templates._htmy_components import ComponentType
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 }
331 comp_type = type_map.get(component_type.lower(), ComponentType.DATACLASS)
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
340 created_path = await htmy_adapter.scaffold_component(
341 name=name, component_type=comp_type, **kwargs
342 )
344 return {
345 "success": True,
346 "path": str(created_path),
347 "name": name,
348 "type": component_type,
349 }
351 except Exception as e:
352 logger.error(f"Error creating component: {e}")
353 return {"success": False, "error": str(e)}
356async def list_components() -> dict[str, Any]:
357 """List all discovered HTMY components.
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 }
370 components = await htmy_adapter.discover_components()
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 }
388 except Exception as e:
389 logger.error(f"Error listing components: {e}")
390 return {"success": False, "error": str(e)}
393async def validate_component(component_name: str) -> dict[str, Any]:
394 """Validate an HTMY component.
396 Args:
397 component_name: Component name to validate
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 }
410 metadata = await htmy_adapter.validate_component(component_name)
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 }
423 except Exception as e:
424 logger.error(f"Error validating component: {e}")
425 return {"success": False, "error": str(e)}
428# Adapter Configuration Tools
431async def configure_adapter(
432 adapter_name: str, settings: dict[str, Any]
433) -> dict[str, Any]:
434 """Configure a FastBlocks adapter.
436 Args:
437 adapter_name: Adapter name (e.g., "templates", "auth")
438 settings: Adapter configuration settings
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 }
452 # Update adapter settings
453 for key, value in settings.items():
454 if hasattr(adapter, key):
455 setattr(adapter, key, value)
457 return {
458 "success": True,
459 "adapter": adapter_name,
460 "settings": settings,
461 }
463 except Exception as e:
464 logger.error(f"Error configuring adapter: {e}")
465 return {"success": False, "error": str(e)}
468async def list_adapters(category: str | None = None) -> dict[str, Any]:
469 """List all available FastBlocks adapters.
471 Args:
472 category: Optional category filter
474 Returns:
475 Dict with list of adapters and metadata
476 """
477 try:
478 discovery = AdapterDiscoveryServer()
479 adapters = await discovery.discover_adapters()
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 }
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 }
496 except Exception as e:
497 logger.error(f"Error listing adapters: {e}")
498 return {"success": False, "error": str(e)}
501async def check_adapter_health(adapter_name: str | None = None) -> dict[str, Any]:
502 """Check health status of adapters.
504 Args:
505 adapter_name: Optional specific adapter to check
507 Returns:
508 Dict with health check results
509 """
510 try:
511 from .registry import AdapterRegistry
513 # Create registry and health system
514 registry = AdapterRegistry()
515 health_system = HealthCheckSystem(registry)
516 results_dict = await health_system.check_all_adapters()
518 # Convert to list of dicts
519 results = [
520 {"adapter": name} | result.to_dict()
521 for name, result in results_dict.items()
522 ]
524 # Filter by adapter if specified
525 if adapter_name:
526 results = [r for r in results if r["adapter"] == adapter_name]
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 }
536 except Exception as e:
537 logger.error(f"Error checking adapter health: {e}")
538 return {"success": False, "error": str(e)}
541# Tool registration function
544async def register_fastblocks_tools(server: Any) -> None:
545 """Register all FastBlocks MCP tools with the server.
547 Args:
548 server: MCP server instance from ACB
549 """
550 try:
551 from acb.mcp import register_tools # type: ignore[attr-defined]
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 }
570 # Register tools with MCP server
571 await register_tools(server, tools) # type: ignore[misc]
573 logger.info(f"Registered {len(tools)} FastBlocks MCP tools")
575 except Exception as e:
576 logger.error(f"Failed to register MCP tools: {e}")
577 raise