feat(viz-platform): implement layout tools (#174)
- Add layout_create tool: create layouts with templates (dashboard, report, form, blank) - Add layout_add_filter tool: add filter controls (dropdown, date_range, search, etc.) - Add layout_set_grid tool: configure responsive grid system (1-24 cols, breakpoints) - 4 templates and 9 filter types supported - Maps to DMC Grid and AppShell component patterns Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
367
mcp-servers/viz-platform/mcp_server/layout_tools.py
Normal file
367
mcp-servers/viz-platform/mcp_server/layout_tools.py
Normal file
@@ -0,0 +1,367 @@
|
|||||||
|
"""
|
||||||
|
Layout composition tools for dashboard building.
|
||||||
|
|
||||||
|
Provides tools for creating structured layouts with grids, filters, and sections.
|
||||||
|
"""
|
||||||
|
import logging
|
||||||
|
from typing import Dict, List, Optional, Any
|
||||||
|
from uuid import uuid4
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
# Layout templates
|
||||||
|
TEMPLATES = {
|
||||||
|
"dashboard": {
|
||||||
|
"sections": ["header", "filters", "main", "footer"],
|
||||||
|
"default_grid": {"cols": 12, "spacing": "md"},
|
||||||
|
"description": "Standard dashboard with header, filters, main content, and footer"
|
||||||
|
},
|
||||||
|
"report": {
|
||||||
|
"sections": ["title", "summary", "content", "appendix"],
|
||||||
|
"default_grid": {"cols": 1, "spacing": "lg"},
|
||||||
|
"description": "Report layout with title, summary, and content sections"
|
||||||
|
},
|
||||||
|
"form": {
|
||||||
|
"sections": ["header", "fields", "actions"],
|
||||||
|
"default_grid": {"cols": 2, "spacing": "md"},
|
||||||
|
"description": "Form layout with header, fields, and action buttons"
|
||||||
|
},
|
||||||
|
"blank": {
|
||||||
|
"sections": ["main"],
|
||||||
|
"default_grid": {"cols": 12, "spacing": "md"},
|
||||||
|
"description": "Blank canvas for custom layouts"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# Filter type definitions
|
||||||
|
FILTER_TYPES = {
|
||||||
|
"dropdown": {
|
||||||
|
"component": "Select",
|
||||||
|
"props": ["label", "data", "placeholder", "clearable", "searchable", "value"]
|
||||||
|
},
|
||||||
|
"multi_select": {
|
||||||
|
"component": "MultiSelect",
|
||||||
|
"props": ["label", "data", "placeholder", "clearable", "searchable", "value"]
|
||||||
|
},
|
||||||
|
"date_range": {
|
||||||
|
"component": "DateRangePicker",
|
||||||
|
"props": ["label", "placeholder", "value", "minDate", "maxDate"]
|
||||||
|
},
|
||||||
|
"date": {
|
||||||
|
"component": "DatePicker",
|
||||||
|
"props": ["label", "placeholder", "value", "minDate", "maxDate"]
|
||||||
|
},
|
||||||
|
"search": {
|
||||||
|
"component": "TextInput",
|
||||||
|
"props": ["label", "placeholder", "value", "icon"]
|
||||||
|
},
|
||||||
|
"checkbox_group": {
|
||||||
|
"component": "CheckboxGroup",
|
||||||
|
"props": ["label", "children", "value"]
|
||||||
|
},
|
||||||
|
"radio_group": {
|
||||||
|
"component": "RadioGroup",
|
||||||
|
"props": ["label", "children", "value"]
|
||||||
|
},
|
||||||
|
"slider": {
|
||||||
|
"component": "Slider",
|
||||||
|
"props": ["label", "min", "max", "step", "value", "marks"]
|
||||||
|
},
|
||||||
|
"range_slider": {
|
||||||
|
"component": "RangeSlider",
|
||||||
|
"props": ["label", "min", "max", "step", "value", "marks"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class LayoutTools:
|
||||||
|
"""
|
||||||
|
Dashboard layout composition tools.
|
||||||
|
|
||||||
|
Creates layouts that map to DMC Grid and AppShell components.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
"""Initialize layout tools."""
|
||||||
|
self._layouts: Dict[str, Dict[str, Any]] = {}
|
||||||
|
|
||||||
|
async def layout_create(
|
||||||
|
self,
|
||||||
|
name: str,
|
||||||
|
template: Optional[str] = None
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
"""
|
||||||
|
Create a new layout container.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
name: Unique name for the layout
|
||||||
|
template: Optional template (dashboard, report, form, blank)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dict with:
|
||||||
|
- layout_ref: Reference to use in other tools
|
||||||
|
- template: Template used
|
||||||
|
- sections: Available sections
|
||||||
|
- grid: Default grid configuration
|
||||||
|
"""
|
||||||
|
# Validate template
|
||||||
|
template = template or "blank"
|
||||||
|
if template not in TEMPLATES:
|
||||||
|
return {
|
||||||
|
"error": f"Invalid template '{template}'. Must be one of: {list(TEMPLATES.keys())}",
|
||||||
|
"layout_ref": None
|
||||||
|
}
|
||||||
|
|
||||||
|
# Check for name collision
|
||||||
|
if name in self._layouts:
|
||||||
|
return {
|
||||||
|
"error": f"Layout '{name}' already exists. Use a different name or modify existing.",
|
||||||
|
"layout_ref": name
|
||||||
|
}
|
||||||
|
|
||||||
|
template_config = TEMPLATES[template]
|
||||||
|
|
||||||
|
# Create layout structure
|
||||||
|
layout = {
|
||||||
|
"id": str(uuid4()),
|
||||||
|
"name": name,
|
||||||
|
"template": template,
|
||||||
|
"sections": {section: {"items": []} for section in template_config["sections"]},
|
||||||
|
"grid": template_config["default_grid"].copy(),
|
||||||
|
"filters": [],
|
||||||
|
"metadata": {
|
||||||
|
"description": template_config["description"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
self._layouts[name] = layout
|
||||||
|
|
||||||
|
return {
|
||||||
|
"layout_ref": name,
|
||||||
|
"template": template,
|
||||||
|
"sections": template_config["sections"],
|
||||||
|
"grid": layout["grid"],
|
||||||
|
"description": template_config["description"]
|
||||||
|
}
|
||||||
|
|
||||||
|
async def layout_add_filter(
|
||||||
|
self,
|
||||||
|
layout_ref: str,
|
||||||
|
filter_type: str,
|
||||||
|
options: Dict[str, Any]
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
"""
|
||||||
|
Add a filter control to a layout.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
layout_ref: Layout name to add filter to
|
||||||
|
filter_type: Type of filter (dropdown, date_range, search, checkbox_group, etc.)
|
||||||
|
options: Filter options (label, data for dropdown, placeholder, position)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dict with:
|
||||||
|
- filter_id: Unique ID for the filter
|
||||||
|
- component: DMC component that will be used
|
||||||
|
- props: Props that were set
|
||||||
|
- position: Where filter was placed
|
||||||
|
"""
|
||||||
|
# Validate layout exists
|
||||||
|
if layout_ref not in self._layouts:
|
||||||
|
return {
|
||||||
|
"error": f"Layout '{layout_ref}' not found. Create it first with layout_create.",
|
||||||
|
"filter_id": None
|
||||||
|
}
|
||||||
|
|
||||||
|
# Validate filter type
|
||||||
|
if filter_type not in FILTER_TYPES:
|
||||||
|
return {
|
||||||
|
"error": f"Invalid filter_type '{filter_type}'. Must be one of: {list(FILTER_TYPES.keys())}",
|
||||||
|
"filter_id": None
|
||||||
|
}
|
||||||
|
|
||||||
|
filter_config = FILTER_TYPES[filter_type]
|
||||||
|
layout = self._layouts[layout_ref]
|
||||||
|
|
||||||
|
# Generate filter ID
|
||||||
|
filter_id = f"filter_{filter_type}_{len(layout['filters'])}"
|
||||||
|
|
||||||
|
# Extract relevant props
|
||||||
|
props = {"id": filter_id}
|
||||||
|
for prop in filter_config["props"]:
|
||||||
|
if prop in options:
|
||||||
|
props[prop] = options[prop]
|
||||||
|
|
||||||
|
# Determine position
|
||||||
|
position = options.get("position", "filters")
|
||||||
|
if position not in layout["sections"]:
|
||||||
|
# Default to first available section
|
||||||
|
position = list(layout["sections"].keys())[0]
|
||||||
|
|
||||||
|
# Create filter definition
|
||||||
|
filter_def = {
|
||||||
|
"id": filter_id,
|
||||||
|
"type": filter_type,
|
||||||
|
"component": filter_config["component"],
|
||||||
|
"props": props,
|
||||||
|
"position": position
|
||||||
|
}
|
||||||
|
|
||||||
|
layout["filters"].append(filter_def)
|
||||||
|
layout["sections"][position]["items"].append({
|
||||||
|
"type": "filter",
|
||||||
|
"ref": filter_id
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
"filter_id": filter_id,
|
||||||
|
"component": filter_config["component"],
|
||||||
|
"props": props,
|
||||||
|
"position": position,
|
||||||
|
"layout_ref": layout_ref
|
||||||
|
}
|
||||||
|
|
||||||
|
async def layout_set_grid(
|
||||||
|
self,
|
||||||
|
layout_ref: str,
|
||||||
|
grid: Dict[str, Any]
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
"""
|
||||||
|
Configure the grid system for a layout.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
layout_ref: Layout name to configure
|
||||||
|
grid: Grid configuration:
|
||||||
|
- cols: Number of columns (default 12)
|
||||||
|
- spacing: Gap between items (xs, sm, md, lg, xl)
|
||||||
|
- breakpoints: Responsive breakpoints {xs: cols, sm: cols, ...}
|
||||||
|
- gutter: Gutter size
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dict with:
|
||||||
|
- grid: Updated grid configuration
|
||||||
|
- layout_ref: Layout reference
|
||||||
|
"""
|
||||||
|
# Validate layout exists
|
||||||
|
if layout_ref not in self._layouts:
|
||||||
|
return {
|
||||||
|
"error": f"Layout '{layout_ref}' not found. Create it first with layout_create.",
|
||||||
|
"grid": None
|
||||||
|
}
|
||||||
|
|
||||||
|
layout = self._layouts[layout_ref]
|
||||||
|
|
||||||
|
# Validate spacing if provided
|
||||||
|
valid_spacing = ["xs", "sm", "md", "lg", "xl"]
|
||||||
|
if "spacing" in grid and grid["spacing"] not in valid_spacing:
|
||||||
|
return {
|
||||||
|
"error": f"Invalid spacing '{grid['spacing']}'. Must be one of: {valid_spacing}",
|
||||||
|
"grid": layout["grid"]
|
||||||
|
}
|
||||||
|
|
||||||
|
# Validate cols
|
||||||
|
if "cols" in grid:
|
||||||
|
cols = grid["cols"]
|
||||||
|
if not isinstance(cols, int) or cols < 1 or cols > 24:
|
||||||
|
return {
|
||||||
|
"error": f"Invalid cols '{cols}'. Must be integer between 1 and 24.",
|
||||||
|
"grid": layout["grid"]
|
||||||
|
}
|
||||||
|
|
||||||
|
# Update grid configuration
|
||||||
|
layout["grid"].update(grid)
|
||||||
|
|
||||||
|
# Process breakpoints if provided
|
||||||
|
if "breakpoints" in grid:
|
||||||
|
bp = grid["breakpoints"]
|
||||||
|
layout["grid"]["breakpoints"] = bp
|
||||||
|
|
||||||
|
return {
|
||||||
|
"grid": layout["grid"],
|
||||||
|
"layout_ref": layout_ref
|
||||||
|
}
|
||||||
|
|
||||||
|
async def layout_get(self, layout_ref: str) -> Dict[str, Any]:
|
||||||
|
"""
|
||||||
|
Get a layout's full configuration.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
layout_ref: Layout name to retrieve
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Full layout configuration or error
|
||||||
|
"""
|
||||||
|
if layout_ref not in self._layouts:
|
||||||
|
return {
|
||||||
|
"error": f"Layout '{layout_ref}' not found.",
|
||||||
|
"layout": None
|
||||||
|
}
|
||||||
|
|
||||||
|
layout = self._layouts[layout_ref]
|
||||||
|
|
||||||
|
return {
|
||||||
|
"layout": layout,
|
||||||
|
"filter_count": len(layout["filters"]),
|
||||||
|
"sections": list(layout["sections"].keys())
|
||||||
|
}
|
||||||
|
|
||||||
|
async def layout_add_section(
|
||||||
|
self,
|
||||||
|
layout_ref: str,
|
||||||
|
section_name: str,
|
||||||
|
position: Optional[int] = None
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
"""
|
||||||
|
Add a custom section to a layout.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
layout_ref: Layout name
|
||||||
|
section_name: Name for the new section
|
||||||
|
position: Optional position index (appends if not specified)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dict with sections list and the new section name
|
||||||
|
"""
|
||||||
|
if layout_ref not in self._layouts:
|
||||||
|
return {
|
||||||
|
"error": f"Layout '{layout_ref}' not found.",
|
||||||
|
"sections": []
|
||||||
|
}
|
||||||
|
|
||||||
|
layout = self._layouts[layout_ref]
|
||||||
|
|
||||||
|
if section_name in layout["sections"]:
|
||||||
|
return {
|
||||||
|
"error": f"Section '{section_name}' already exists.",
|
||||||
|
"sections": list(layout["sections"].keys())
|
||||||
|
}
|
||||||
|
|
||||||
|
# Add new section
|
||||||
|
layout["sections"][section_name] = {"items": []}
|
||||||
|
|
||||||
|
return {
|
||||||
|
"section_name": section_name,
|
||||||
|
"sections": list(layout["sections"].keys()),
|
||||||
|
"layout_ref": layout_ref
|
||||||
|
}
|
||||||
|
|
||||||
|
def get_available_templates(self) -> Dict[str, Any]:
|
||||||
|
"""Get list of available layout templates."""
|
||||||
|
return {
|
||||||
|
name: {
|
||||||
|
"sections": config["sections"],
|
||||||
|
"description": config["description"]
|
||||||
|
}
|
||||||
|
for name, config in TEMPLATES.items()
|
||||||
|
}
|
||||||
|
|
||||||
|
def get_available_filter_types(self) -> Dict[str, Any]:
|
||||||
|
"""Get list of available filter types."""
|
||||||
|
return {
|
||||||
|
name: {
|
||||||
|
"component": config["component"],
|
||||||
|
"props": config["props"]
|
||||||
|
}
|
||||||
|
for name, config in FILTER_TYPES.items()
|
||||||
|
}
|
||||||
@@ -14,6 +14,7 @@ from mcp.types import Tool, TextContent
|
|||||||
from .config import VizPlatformConfig
|
from .config import VizPlatformConfig
|
||||||
from .dmc_tools import DMCTools
|
from .dmc_tools import DMCTools
|
||||||
from .chart_tools import ChartTools
|
from .chart_tools import ChartTools
|
||||||
|
from .layout_tools import LayoutTools
|
||||||
|
|
||||||
# Suppress noisy MCP validation warnings on stderr
|
# Suppress noisy MCP validation warnings on stderr
|
||||||
logging.basicConfig(level=logging.INFO)
|
logging.basicConfig(level=logging.INFO)
|
||||||
@@ -30,8 +31,8 @@ class VizPlatformMCPServer:
|
|||||||
self.config = None
|
self.config = None
|
||||||
self.dmc_tools = DMCTools()
|
self.dmc_tools = DMCTools()
|
||||||
self.chart_tools = ChartTools()
|
self.chart_tools = ChartTools()
|
||||||
|
self.layout_tools = LayoutTools()
|
||||||
# Tool handlers will be added in subsequent issues
|
# Tool handlers will be added in subsequent issues
|
||||||
# self.layout_tools = None
|
|
||||||
# self.theme_tools = None
|
# self.theme_tools = None
|
||||||
# self.page_tools = None
|
# self.page_tools = None
|
||||||
|
|
||||||
@@ -197,9 +198,86 @@ class VizPlatformMCPServer:
|
|||||||
))
|
))
|
||||||
|
|
||||||
# Layout tools (Issue #174)
|
# Layout tools (Issue #174)
|
||||||
# - layout_create
|
tools.append(Tool(
|
||||||
# - layout_add_filter
|
name="layout_create",
|
||||||
# - layout_set_grid
|
description=(
|
||||||
|
"Create a new dashboard layout container. "
|
||||||
|
"Templates available: dashboard, report, form, blank. "
|
||||||
|
"Returns layout reference for use with other layout tools."
|
||||||
|
),
|
||||||
|
inputSchema={
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"name": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Unique name for the layout"
|
||||||
|
},
|
||||||
|
"template": {
|
||||||
|
"type": "string",
|
||||||
|
"enum": ["dashboard", "report", "form", "blank"],
|
||||||
|
"description": "Layout template to use (default: blank)"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": ["name"]
|
||||||
|
}
|
||||||
|
))
|
||||||
|
|
||||||
|
tools.append(Tool(
|
||||||
|
name="layout_add_filter",
|
||||||
|
description=(
|
||||||
|
"Add a filter control to a layout. "
|
||||||
|
"Filter types: dropdown, multi_select, date_range, date, search, "
|
||||||
|
"checkbox_group, radio_group, slider, range_slider."
|
||||||
|
),
|
||||||
|
inputSchema={
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"layout_ref": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Layout name to add filter to"
|
||||||
|
},
|
||||||
|
"filter_type": {
|
||||||
|
"type": "string",
|
||||||
|
"enum": ["dropdown", "multi_select", "date_range", "date",
|
||||||
|
"search", "checkbox_group", "radio_group", "slider", "range_slider"],
|
||||||
|
"description": "Type of filter control"
|
||||||
|
},
|
||||||
|
"options": {
|
||||||
|
"type": "object",
|
||||||
|
"description": (
|
||||||
|
"Filter options: label, data (for dropdown), placeholder, "
|
||||||
|
"position (section name), value, etc."
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": ["layout_ref", "filter_type", "options"]
|
||||||
|
}
|
||||||
|
))
|
||||||
|
|
||||||
|
tools.append(Tool(
|
||||||
|
name="layout_set_grid",
|
||||||
|
description=(
|
||||||
|
"Configure the grid system for a layout. "
|
||||||
|
"Uses DMC Grid component patterns with 12 or 24 column system."
|
||||||
|
),
|
||||||
|
inputSchema={
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"layout_ref": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Layout name to configure"
|
||||||
|
},
|
||||||
|
"grid": {
|
||||||
|
"type": "object",
|
||||||
|
"description": (
|
||||||
|
"Grid config: cols (1-24), spacing (xs|sm|md|lg|xl), "
|
||||||
|
"breakpoints ({xs: cols, sm: cols, ...}), gutter"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": ["layout_ref", "grid"]
|
||||||
|
}
|
||||||
|
))
|
||||||
|
|
||||||
# Theme tools (Issue #175)
|
# Theme tools (Issue #175)
|
||||||
# - theme_create
|
# - theme_create
|
||||||
@@ -285,7 +363,50 @@ class VizPlatformMCPServer:
|
|||||||
text=json.dumps(result, indent=2)
|
text=json.dumps(result, indent=2)
|
||||||
)]
|
)]
|
||||||
|
|
||||||
# Layout tools (Issue #174)
|
# Layout tools
|
||||||
|
elif name == "layout_create":
|
||||||
|
layout_name = arguments.get('name')
|
||||||
|
template = arguments.get('template')
|
||||||
|
if not layout_name:
|
||||||
|
return [TextContent(
|
||||||
|
type="text",
|
||||||
|
text=json.dumps({"error": "name is required"}, indent=2)
|
||||||
|
)]
|
||||||
|
result = await self.layout_tools.layout_create(layout_name, template)
|
||||||
|
return [TextContent(
|
||||||
|
type="text",
|
||||||
|
text=json.dumps(result, indent=2)
|
||||||
|
)]
|
||||||
|
|
||||||
|
elif name == "layout_add_filter":
|
||||||
|
layout_ref = arguments.get('layout_ref')
|
||||||
|
filter_type = arguments.get('filter_type')
|
||||||
|
options = arguments.get('options', {})
|
||||||
|
if not layout_ref or not filter_type:
|
||||||
|
return [TextContent(
|
||||||
|
type="text",
|
||||||
|
text=json.dumps({"error": "layout_ref and filter_type are required"}, indent=2)
|
||||||
|
)]
|
||||||
|
result = await self.layout_tools.layout_add_filter(layout_ref, filter_type, options)
|
||||||
|
return [TextContent(
|
||||||
|
type="text",
|
||||||
|
text=json.dumps(result, indent=2)
|
||||||
|
)]
|
||||||
|
|
||||||
|
elif name == "layout_set_grid":
|
||||||
|
layout_ref = arguments.get('layout_ref')
|
||||||
|
grid = arguments.get('grid', {})
|
||||||
|
if not layout_ref:
|
||||||
|
return [TextContent(
|
||||||
|
type="text",
|
||||||
|
text=json.dumps({"error": "layout_ref is required"}, indent=2)
|
||||||
|
)]
|
||||||
|
result = await self.layout_tools.layout_set_grid(layout_ref, grid)
|
||||||
|
return [TextContent(
|
||||||
|
type="text",
|
||||||
|
text=json.dumps(result, indent=2)
|
||||||
|
)]
|
||||||
|
|
||||||
# Theme tools (Issue #175)
|
# Theme tools (Issue #175)
|
||||||
# Page tools (Issue #176)
|
# Page tools (Issue #176)
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user