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 <noreply@anthropic.com>
This commit is contained in:
2026-01-26 11:45:40 -05:00
parent 9e4b3d5a91
commit 67fae6a93d
2 changed files with 362 additions and 24 deletions

View File

@@ -2,11 +2,12 @@
DMC (Dash Mantine Components) validation tools. DMC (Dash Mantine Components) validation tools.
Provides component constraint layer to prevent Claude from hallucinating invalid props. Provides component constraint layer to prevent Claude from hallucinating invalid props.
Tools implemented in Issue #172.
""" """
import logging import logging
from typing import Dict, List, Optional, Any from typing import Dict, List, Optional, Any
from .component_registry import ComponentRegistry
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -18,16 +19,39 @@ class DMCTools:
against a version-locked registry of DMC components. 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. Initialize DMC tools with component registry.
Args: Args:
registry: ComponentRegistry instance (from Issue #171) registry: ComponentRegistry instance. If None, creates one.
""" """
self.registry = registry 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. List available DMC components, optionally filtered by category.
@@ -35,10 +59,33 @@ class DMCTools:
category: Optional category filter (e.g., "inputs", "buttons", "navigation") category: Optional category filter (e.g., "inputs", "buttons", "navigation")
Returns: 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 if not self._initialized:
raise NotImplementedError("Implemented in Issue #172") 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]: async def get_component_props(self, component: str) -> Dict[str, Any]:
""" """
@@ -48,10 +95,53 @@ class DMCTools:
component: Component name (e.g., "Button", "TextInput") component: Component name (e.g., "Button", "TextInput")
Returns: 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 if not self._initialized:
raise NotImplementedError("Implemented in Issue #172") 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( async def validate_component(
self, self,
@@ -63,10 +153,154 @@ class DMCTools:
Args: Args:
component: Component name component: Component name
props: Props to validate props: Props dict to validate
Returns: 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 if not self._initialized:
raise NotImplementedError("Implemented in Issue #172") 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."
)

View File

@@ -12,6 +12,7 @@ from mcp.server.stdio import stdio_server
from mcp.types import Tool, TextContent from mcp.types import Tool, TextContent
from .config import VizPlatformConfig from .config import VizPlatformConfig
from .dmc_tools import DMCTools
# Suppress noisy MCP validation warnings on stderr # Suppress noisy MCP validation warnings on stderr
logging.basicConfig(level=logging.INFO) logging.basicConfig(level=logging.INFO)
@@ -26,8 +27,8 @@ class VizPlatformMCPServer:
def __init__(self): def __init__(self):
self.server = Server("viz-platform-mcp") self.server = Server("viz-platform-mcp")
self.config = None self.config = None
self.dmc_tools = DMCTools()
# Tool handlers will be added in subsequent issues # Tool handlers will be added in subsequent issues
# self.dmc_tools = None
# self.chart_tools = None # self.chart_tools = None
# self.layout_tools = None # self.layout_tools = None
# self.theme_tools = None # self.theme_tools = None
@@ -39,10 +40,16 @@ class VizPlatformMCPServer:
config_loader = VizPlatformConfig() config_loader = VizPlatformConfig()
self.config = config_loader.load() 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 # Log available capabilities
caps = [] caps = []
if self.config.get('dmc_available'): 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: else:
caps.append("DMC (not installed)") caps.append("DMC (not installed)")
@@ -61,9 +68,70 @@ class VizPlatformMCPServer:
tools = [] tools = []
# DMC validation tools (Issue #172) # DMC validation tools (Issue #172)
# - list_components tools.append(Tool(
# - get_component_props name="list_components",
# - validate_component 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 tools (Issue #173)
# - chart_create # - chart_create
@@ -91,11 +159,47 @@ class VizPlatformMCPServer:
async def call_tool(name: str, arguments: dict) -> list[TextContent]: async def call_tool(name: str, arguments: dict) -> list[TextContent]:
"""Handle tool invocation.""" """Handle tool invocation."""
try: try:
# Tool routing will be added as tools are implemented # DMC validation tools
# DMC tools if name == "list_components":
# if name == "list_components": result = await self.dmc_tools.list_components(
# result = await self.dmc_tools.list_components(**arguments) 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}") raise ValueError(f"Unknown tool: {name}")