From 67fae6a93d66b1b7f45438e999feca9f2574ec0a Mon Sep 17 00:00:00 2001 From: lmiranda Date: Mon, 26 Jan 2026 11:45:40 -0500 Subject: [PATCH] feat(viz-platform): implement DMC validation tools (#172) - Add list_components tool: list DMC components by category - Add get_component_props tool: get props schema with types/defaults/enums - Add validate_component tool: validate props with error/warning messages - Includes typo detection and common mistake warnings - All tools registered in MCP server with proper JSON schemas Co-Authored-By: Claude Opus 4.5 --- .../viz-platform/mcp_server/dmc_tools.py | 262 +++++++++++++++++- mcp-servers/viz-platform/mcp_server/server.py | 124 ++++++++- 2 files changed, 362 insertions(+), 24 deletions(-) diff --git a/mcp-servers/viz-platform/mcp_server/dmc_tools.py b/mcp-servers/viz-platform/mcp_server/dmc_tools.py index ca8f589..0f62043 100644 --- a/mcp-servers/viz-platform/mcp_server/dmc_tools.py +++ b/mcp-servers/viz-platform/mcp_server/dmc_tools.py @@ -2,11 +2,12 @@ DMC (Dash Mantine Components) validation tools. Provides component constraint layer to prevent Claude from hallucinating invalid props. -Tools implemented in Issue #172. """ import logging from typing import Dict, List, Optional, Any +from .component_registry import ComponentRegistry + logger = logging.getLogger(__name__) @@ -18,16 +19,39 @@ class DMCTools: against a version-locked registry of DMC components. """ - def __init__(self, registry=None): + def __init__(self, registry: Optional[ComponentRegistry] = None): """ Initialize DMC tools with component registry. Args: - registry: ComponentRegistry instance (from Issue #171) + registry: ComponentRegistry instance. If None, creates one. """ self.registry = registry + self._initialized = False - async def list_components(self, category: Optional[str] = None) -> Dict[str, Any]: + def initialize(self, dmc_version: Optional[str] = None) -> bool: + """ + Initialize the registry if not already provided. + + Args: + dmc_version: DMC version to load registry for + + Returns: + True if initialized successfully + """ + if self.registry is None: + self.registry = ComponentRegistry(dmc_version) + + if not self.registry.is_loaded(): + self.registry.load() + + self._initialized = self.registry.is_loaded() + return self._initialized + + async def list_components( + self, + category: Optional[str] = None + ) -> Dict[str, Any]: """ List available DMC components, optionally filtered by category. @@ -35,10 +59,33 @@ class DMCTools: category: Optional category filter (e.g., "inputs", "buttons", "navigation") Returns: - Dict with components grouped by category + Dict with: + - components: Dict[category -> [component names]] + - categories: List of available categories + - version: Loaded DMC registry version + - total_count: Total number of components """ - # Implementation in Issue #172 - raise NotImplementedError("Implemented in Issue #172") + if not self._initialized: + return { + "error": "Registry not initialized", + "components": {}, + "categories": [], + "version": None, + "total_count": 0 + } + + components = self.registry.list_components(category) + all_categories = self.registry.get_categories() + + # Count total components + total = sum(len(comps) for comps in components.values()) + + return { + "components": components, + "categories": all_categories if not category else [category], + "version": self.registry.loaded_version, + "total_count": total + } async def get_component_props(self, component: str) -> Dict[str, Any]: """ @@ -48,10 +95,53 @@ class DMCTools: component: Component name (e.g., "Button", "TextInput") Returns: - Dict with props, types, defaults, and enum values + Dict with: + - component: Component name + - description: Component description + - props: Dict of prop name -> {type, default, enum, description} + - prop_count: Number of props + - required: List of required prop names + Or error dict if component not found """ - # Implementation in Issue #172 - raise NotImplementedError("Implemented in Issue #172") + if not self._initialized: + return { + "error": "Registry not initialized", + "component": component, + "props": {}, + "prop_count": 0 + } + + comp_def = self.registry.get_component(component) + if not comp_def: + # Try to suggest similar component name + similar = self._find_similar_component(component) + error_msg = f"Component '{component}' not found in registry" + if similar: + error_msg += f". Did you mean '{similar}'?" + + return { + "error": error_msg, + "component": component, + "props": {}, + "prop_count": 0 + } + + props = comp_def.get('props', {}) + + # Extract required props + required = [ + name for name, schema in props.items() + if schema.get('required', False) + ] + + return { + "component": component, + "description": comp_def.get('description', ''), + "props": props, + "prop_count": len(props), + "required": required, + "version": self.registry.loaded_version + } async def validate_component( self, @@ -63,10 +153,154 @@ class DMCTools: Args: component: Component name - props: Props to validate + props: Props dict to validate Returns: - Dict with valid: bool, errors: [], warnings: [] + Dict with: + - valid: bool - True if all props are valid + - errors: List of error messages + - warnings: List of warning messages + - validated_props: Number of props validated + - component: Component name for reference """ - # Implementation in Issue #172 - raise NotImplementedError("Implemented in Issue #172") + if not self._initialized: + return { + "valid": False, + "errors": ["Registry not initialized"], + "warnings": [], + "validated_props": 0, + "component": component + } + + errors: List[str] = [] + warnings: List[str] = [] + + # Check if component exists + comp_def = self.registry.get_component(component) + if not comp_def: + similar = self._find_similar_component(component) + error_msg = f"Unknown component: {component}" + if similar: + error_msg += f". Did you mean '{similar}'?" + errors.append(error_msg) + + return { + "valid": False, + "errors": errors, + "warnings": warnings, + "validated_props": 0, + "component": component + } + + comp_props = comp_def.get('props', {}) + + # Check for required props + for prop_name, prop_schema in comp_props.items(): + if prop_schema.get('required', False) and prop_name not in props: + errors.append(f"Missing required prop: '{prop_name}'") + + # Validate each provided prop + for prop_name, prop_value in props.items(): + # Skip special props that are always allowed + if prop_name in ('id', 'children', 'className', 'style', 'key'): + continue + + result = self.registry.validate_prop(component, prop_name, prop_value) + + if not result.get('valid', True): + error = result.get('error', f"Invalid prop: {prop_name}") + # Distinguish between typos/unknown props and type errors + if "Unknown prop" in error: + errors.append(f"❌ {error}") + elif "expects one of" in error: + errors.append(f"❌ {error}") + elif "expects type" in error: + warnings.append(f"⚠️ {error}") + else: + errors.append(f"❌ {error}") + + # Check for props that exist but might have common mistakes + self._check_common_mistakes(component, props, warnings) + + return { + "valid": len(errors) == 0, + "errors": errors, + "warnings": warnings, + "validated_props": len(props), + "component": component, + "version": self.registry.loaded_version + } + + def _find_similar_component(self, component: str) -> Optional[str]: + """ + Find a similar component name for suggestions. + + Args: + component: The (possibly misspelled) component name + + Returns: + Similar component name, or None if no close match + """ + if not self.registry: + return None + + comp_lower = component.lower() + all_components = [] + for comps in self.registry.categories.values(): + all_components.extend(comps) + + for comp in all_components: + # Exact match after lowercase + if comp.lower() == comp_lower: + return comp + # Check if it's a prefix match + if comp.lower().startswith(comp_lower) or comp_lower.startswith(comp.lower()): + return comp + # Check for common typos + if abs(len(comp) - len(component)) <= 2: + if comp_lower[:4] == comp.lower()[:4]: + return comp + + return None + + def _check_common_mistakes( + self, + component: str, + props: Dict[str, Any], + warnings: List[str] + ) -> None: + """ + Check for common prop usage mistakes and add warnings. + + Args: + component: Component name + props: Props being used + warnings: List to append warnings to + """ + # Common mistake: using 'onclick' instead of callback pattern + if 'onclick' in [p.lower() for p in props.keys()]: + warnings.append( + "⚠️ Dash uses callback patterns, not inline event handlers. " + "Use 'n_clicks' prop with a callback instead." + ) + + # Common mistake: using 'class' instead of 'className' + if 'class' in props: + warnings.append( + "⚠️ Use 'className' instead of 'class' for CSS classes." + ) + + # Button-specific checks + if component == 'Button': + if 'href' in props and 'component' not in props: + warnings.append( + "⚠️ Button with 'href' should also set 'component=\"a\"' for proper anchor behavior." + ) + + # Input-specific checks + if 'Input' in component: + if 'value' in props and 'onChange' in [p for p in props.keys()]: + warnings.append( + "⚠️ Dash uses 'value' prop with callbacks, not 'onChange'. " + "The value updates automatically through Dash callbacks." + ) diff --git a/mcp-servers/viz-platform/mcp_server/server.py b/mcp-servers/viz-platform/mcp_server/server.py index 778440a..59f6a92 100644 --- a/mcp-servers/viz-platform/mcp_server/server.py +++ b/mcp-servers/viz-platform/mcp_server/server.py @@ -12,6 +12,7 @@ from mcp.server.stdio import stdio_server from mcp.types import Tool, TextContent from .config import VizPlatformConfig +from .dmc_tools import DMCTools # Suppress noisy MCP validation warnings on stderr logging.basicConfig(level=logging.INFO) @@ -26,8 +27,8 @@ class VizPlatformMCPServer: def __init__(self): self.server = Server("viz-platform-mcp") self.config = None + self.dmc_tools = DMCTools() # Tool handlers will be added in subsequent issues - # self.dmc_tools = None # self.chart_tools = None # self.layout_tools = None # self.theme_tools = None @@ -39,10 +40,16 @@ class VizPlatformMCPServer: config_loader = VizPlatformConfig() self.config = config_loader.load() + # Initialize DMC tools with detected version + dmc_version = self.config.get('dmc_version') + self.dmc_tools.initialize(dmc_version) + # Log available capabilities caps = [] if self.config.get('dmc_available'): - caps.append(f"DMC {self.config.get('dmc_version')}") + caps.append(f"DMC {dmc_version}") + if self.dmc_tools._initialized: + caps.append(f"Registry loaded ({self.dmc_tools.registry.loaded_version})") else: caps.append("DMC (not installed)") @@ -61,9 +68,70 @@ class VizPlatformMCPServer: tools = [] # DMC validation tools (Issue #172) - # - list_components - # - get_component_props - # - validate_component + tools.append(Tool( + name="list_components", + description=( + "List available Dash Mantine Components. " + "Returns components grouped by category with version info. " + "Use this to discover what components are available before building UI." + ), + inputSchema={ + "type": "object", + "properties": { + "category": { + "type": "string", + "description": ( + "Optional category filter. Available categories: " + "buttons, inputs, navigation, feedback, overlays, " + "typography, layout, data_display, charts, dates" + ) + } + }, + "required": [] + } + )) + + tools.append(Tool( + name="get_component_props", + description=( + "Get the props schema for a specific DMC component. " + "Returns all available props with types, defaults, and allowed values. " + "ALWAYS use this before creating a component to ensure valid props." + ), + inputSchema={ + "type": "object", + "properties": { + "component": { + "type": "string", + "description": "Component name (e.g., 'Button', 'TextInput', 'Select')" + } + }, + "required": ["component"] + } + )) + + tools.append(Tool( + name="validate_component", + description=( + "Validate component props before use. " + "Checks for invalid props, type mismatches, and common mistakes. " + "Returns errors and warnings with suggestions for fixes." + ), + inputSchema={ + "type": "object", + "properties": { + "component": { + "type": "string", + "description": "Component name to validate" + }, + "props": { + "type": "object", + "description": "Props object to validate" + } + }, + "required": ["component", "props"] + } + )) # Chart tools (Issue #173) # - chart_create @@ -91,11 +159,47 @@ class VizPlatformMCPServer: async def call_tool(name: str, arguments: dict) -> list[TextContent]: """Handle tool invocation.""" try: - # Tool routing will be added as tools are implemented - # DMC tools - # if name == "list_components": - # result = await self.dmc_tools.list_components(**arguments) - # ... + # DMC validation tools + if name == "list_components": + result = await self.dmc_tools.list_components( + category=arguments.get('category') + ) + return [TextContent( + type="text", + text=json.dumps(result, indent=2) + )] + + elif name == "get_component_props": + component = arguments.get('component') + if not component: + return [TextContent( + type="text", + text=json.dumps({"error": "component is required"}, indent=2) + )] + result = await self.dmc_tools.get_component_props(component) + return [TextContent( + type="text", + text=json.dumps(result, indent=2) + )] + + elif name == "validate_component": + component = arguments.get('component') + props = arguments.get('props', {}) + if not component: + return [TextContent( + type="text", + text=json.dumps({"error": "component is required"}, indent=2) + )] + result = await self.dmc_tools.validate_component(component, props) + return [TextContent( + type="text", + text=json.dumps(result, indent=2) + )] + + # Chart tools (Issue #173) + # Layout tools (Issue #174) + # Theme tools (Issue #175) + # Page tools (Issue #176) raise ValueError(f"Unknown tool: {name}")