diff --git a/mcp-servers/viz-platform/mcp_server/page_tools.py b/mcp-servers/viz-platform/mcp_server/page_tools.py new file mode 100644 index 0000000..dc5c7da --- /dev/null +++ b/mcp-servers/viz-platform/mcp_server/page_tools.py @@ -0,0 +1,366 @@ +""" +Multi-page app tools for viz-platform. + +Provides tools for building complete Dash applications with routing and navigation. +""" +import logging +from typing import Dict, List, Optional, Any +from uuid import uuid4 + +logger = logging.getLogger(__name__) + + +# Navigation position options +NAV_POSITIONS = ["top", "left", "right"] + +# Auth types supported +AUTH_TYPES = ["none", "basic", "oauth", "custom"] + + +class PageTools: + """ + Multi-page Dash application tools. + + Creates page definitions, navigation, and auth configuration. + """ + + def __init__(self): + """Initialize page tools.""" + self._pages: Dict[str, Dict[str, Any]] = {} + self._navbars: Dict[str, Dict[str, Any]] = {} + self._app_config: Dict[str, Any] = { + "title": "Dash App", + "suppress_callback_exceptions": True + } + + async def page_create( + self, + name: str, + path: str, + layout_ref: Optional[str] = None, + title: Optional[str] = None + ) -> Dict[str, Any]: + """ + Create a new page definition. + + Args: + name: Unique page name (used as identifier) + path: URL path for the page (e.g., "/", "/settings") + layout_ref: Optional layout reference to use for the page + title: Optional page title (defaults to name) + + Returns: + Dict with: + - page_ref: Reference to use in other tools + - path: URL path + - registered: Whether page was registered + """ + # Validate path format + if not path.startswith('/'): + return { + "error": f"Path must start with '/'. Got: {path}", + "page_ref": None + } + + # Check for name collision + if name in self._pages: + return { + "error": f"Page '{name}' already exists. Use a different name.", + "page_ref": name + } + + # Check for path collision + for page_name, page_data in self._pages.items(): + if page_data['path'] == path: + return { + "error": f"Path '{path}' already used by page '{page_name}'.", + "page_ref": None + } + + # Create page definition + page = { + "id": str(uuid4()), + "name": name, + "path": path, + "title": title or name, + "layout_ref": layout_ref, + "auth": None, + "metadata": {} + } + + self._pages[name] = page + + return { + "page_ref": name, + "path": path, + "title": page["title"], + "layout_ref": layout_ref, + "registered": True + } + + async def page_add_navbar( + self, + pages: List[str], + options: Optional[Dict[str, Any]] = None + ) -> Dict[str, Any]: + """ + Generate a navigation component linking pages. + + Args: + pages: List of page names to include in navigation + options: Navigation options: + - position: "top", "left", or "right" + - style: Style variant + - brand: Brand/logo text or config + - collapsible: Whether to collapse on mobile + + Returns: + Dict with: + - navbar_id: Navigation ID + - pages: List of page links generated + - component: DMC component structure + """ + options = options or {} + + # Validate pages exist + missing_pages = [p for p in pages if p not in self._pages] + if missing_pages: + return { + "error": f"Pages not found: {missing_pages}. Create them first.", + "navbar_id": None + } + + # Validate position + position = options.get("position", "top") + if position not in NAV_POSITIONS: + return { + "error": f"Invalid position '{position}'. Must be one of: {NAV_POSITIONS}", + "navbar_id": None + } + + # Generate navbar ID + navbar_id = f"navbar_{len(self._navbars)}" + + # Build page links + page_links = [] + for page_name in pages: + page = self._pages[page_name] + page_links.append({ + "label": page["title"], + "href": page["path"], + "page_ref": page_name + }) + + # Build DMC component structure + if position == "top": + component = self._build_top_navbar(page_links, options) + else: + component = self._build_side_navbar(page_links, options, position) + + # Store navbar config + self._navbars[navbar_id] = { + "id": navbar_id, + "position": position, + "pages": pages, + "options": options, + "component": component + } + + return { + "navbar_id": navbar_id, + "position": position, + "pages": page_links, + "component": component + } + + async def page_set_auth( + self, + page_ref: str, + auth_config: Dict[str, Any] + ) -> Dict[str, Any]: + """ + Configure authentication for a page. + + Args: + page_ref: Page name to configure + auth_config: Authentication configuration: + - type: "none", "basic", "oauth", "custom" + - required: Whether auth is required (default True) + - roles: List of required roles (optional) + - redirect: Redirect path for unauthenticated users + + Returns: + Dict with: + - page_ref: Page reference + - auth_type: Type of auth configured + - protected: Whether page is protected + """ + # Validate page exists + if page_ref not in self._pages: + available = list(self._pages.keys()) + return { + "error": f"Page '{page_ref}' not found. Available: {available}", + "page_ref": page_ref + } + + # Validate auth type + auth_type = auth_config.get("type", "basic") + if auth_type not in AUTH_TYPES: + return { + "error": f"Invalid auth type '{auth_type}'. Must be one of: {AUTH_TYPES}", + "page_ref": page_ref + } + + # Build auth config + auth = { + "type": auth_type, + "required": auth_config.get("required", True), + "roles": auth_config.get("roles", []), + "redirect": auth_config.get("redirect", "/login") + } + + # Handle OAuth-specific config + if auth_type == "oauth": + auth["provider"] = auth_config.get("provider", "generic") + auth["scopes"] = auth_config.get("scopes", []) + + # Update page + self._pages[page_ref]["auth"] = auth + + return { + "page_ref": page_ref, + "auth_type": auth_type, + "protected": auth["required"], + "roles": auth["roles"], + "redirect": auth["redirect"] + } + + async def page_list(self) -> Dict[str, Any]: + """ + List all registered pages. + + Returns: + Dict with pages and their configurations + """ + pages_info = {} + for name, page in self._pages.items(): + pages_info[name] = { + "path": page["path"], + "title": page["title"], + "layout_ref": page["layout_ref"], + "protected": page["auth"] is not None and page["auth"].get("required", False) + } + + return { + "pages": pages_info, + "count": len(pages_info), + "navbars": list(self._navbars.keys()) + } + + async def page_get_app_config(self) -> Dict[str, Any]: + """ + Get the complete app configuration for Dash. + + Returns: + Dict with app config including pages, navbars, and settings + """ + # Build pages config + pages_config = [] + for name, page in self._pages.items(): + pages_config.append({ + "name": name, + "path": page["path"], + "title": page["title"], + "layout_ref": page["layout_ref"] + }) + + # Build routing config + routes = {page["path"]: name for name, page in self._pages.items()} + + return { + "app": self._app_config, + "pages": pages_config, + "routes": routes, + "navbars": list(self._navbars.values()), + "page_count": len(self._pages) + } + + def _build_top_navbar( + self, + page_links: List[Dict[str, str]], + options: Dict[str, Any] + ) -> Dict[str, Any]: + """Build a top navigation bar component.""" + brand = options.get("brand", "App") + + # Build nav links + nav_items = [] + for link in page_links: + nav_items.append({ + "component": "NavLink", + "props": { + "label": link["label"], + "href": link["href"] + } + }) + + return { + "component": "AppShell.Header", + "children": [ + { + "component": "Group", + "props": {"justify": "space-between", "h": "100%", "px": "md"}, + "children": [ + { + "component": "Text", + "props": {"size": "lg", "fw": 700}, + "children": brand + }, + { + "component": "Group", + "props": {"gap": "sm"}, + "children": nav_items + } + ] + } + ] + } + + def _build_side_navbar( + self, + page_links: List[Dict[str, str]], + options: Dict[str, Any], + position: str + ) -> Dict[str, Any]: + """Build a side navigation bar component.""" + brand = options.get("brand", "App") + + # Build nav links + nav_items = [] + for link in page_links: + nav_items.append({ + "component": "NavLink", + "props": { + "label": link["label"], + "href": link["href"] + } + }) + + navbar_component = "AppShell.Navbar" if position == "left" else "AppShell.Aside" + + return { + "component": navbar_component, + "props": {"p": "md"}, + "children": [ + { + "component": "Text", + "props": {"size": "lg", "fw": 700, "mb": "md"}, + "children": brand + }, + { + "component": "Stack", + "props": {"gap": "xs"}, + "children": nav_items + } + ] + } diff --git a/mcp-servers/viz-platform/mcp_server/server.py b/mcp-servers/viz-platform/mcp_server/server.py index c746f1d..a48d8e3 100644 --- a/mcp-servers/viz-platform/mcp_server/server.py +++ b/mcp-servers/viz-platform/mcp_server/server.py @@ -16,6 +16,7 @@ from .dmc_tools import DMCTools from .chart_tools import ChartTools from .layout_tools import LayoutTools from .theme_tools import ThemeTools +from .page_tools import PageTools # Suppress noisy MCP validation warnings on stderr logging.basicConfig(level=logging.INFO) @@ -34,8 +35,7 @@ class VizPlatformMCPServer: self.chart_tools = ChartTools() self.layout_tools = LayoutTools() self.theme_tools = ThemeTools() - # Tool handlers will be added in subsequent issues - # self.page_tools = None + self.page_tools = PageTools() async def initialize(self): """Initialize server and load configuration.""" @@ -370,9 +370,86 @@ class VizPlatformMCPServer: )) # Page tools (Issue #176) - # - page_create - # - page_add_navbar - # - page_set_auth + tools.append(Tool( + name="page_create", + description=( + "Create a new page for a multi-page Dash application. " + "Defines page routing and can link to a layout." + ), + inputSchema={ + "type": "object", + "properties": { + "name": { + "type": "string", + "description": "Unique page name (identifier)" + }, + "path": { + "type": "string", + "description": "URL path (e.g., '/', '/settings')" + }, + "layout_ref": { + "type": "string", + "description": "Optional layout reference to use" + }, + "title": { + "type": "string", + "description": "Page title (defaults to name)" + } + }, + "required": ["name", "path"] + } + )) + + tools.append(Tool( + name="page_add_navbar", + description=( + "Generate navigation component linking pages. " + "Creates top or side navigation with DMC components." + ), + inputSchema={ + "type": "object", + "properties": { + "pages": { + "type": "array", + "items": {"type": "string"}, + "description": "List of page names to include" + }, + "options": { + "type": "object", + "description": ( + "Navigation options: position (top|left|right), " + "brand (app name), collapsible (bool)" + ) + } + }, + "required": ["pages"] + } + )) + + tools.append(Tool( + name="page_set_auth", + description=( + "Configure authentication for a page. " + "Sets auth requirements, roles, and redirect behavior." + ), + inputSchema={ + "type": "object", + "properties": { + "page_ref": { + "type": "string", + "description": "Page name to configure" + }, + "auth_config": { + "type": "object", + "description": ( + "Auth config: type (none|basic|oauth|custom), " + "required (bool), roles (array), redirect (path)" + ) + } + }, + "required": ["page_ref", "auth_config"] + } + )) return tools @@ -547,7 +624,50 @@ class VizPlatformMCPServer: text=json.dumps(result, indent=2) )] - # Page tools (Issue #176) + # Page tools + elif name == "page_create": + page_name = arguments.get('name') + path = arguments.get('path') + layout_ref = arguments.get('layout_ref') + title = arguments.get('title') + if not page_name or not path: + return [TextContent( + type="text", + text=json.dumps({"error": "name and path are required"}, indent=2) + )] + result = await self.page_tools.page_create(page_name, path, layout_ref, title) + return [TextContent( + type="text", + text=json.dumps(result, indent=2) + )] + + elif name == "page_add_navbar": + pages = arguments.get('pages', []) + options = arguments.get('options', {}) + if not pages: + return [TextContent( + type="text", + text=json.dumps({"error": "pages list is required"}, indent=2) + )] + result = await self.page_tools.page_add_navbar(pages, options) + return [TextContent( + type="text", + text=json.dumps(result, indent=2) + )] + + elif name == "page_set_auth": + page_ref = arguments.get('page_ref') + auth_config = arguments.get('auth_config', {}) + if not page_ref: + return [TextContent( + type="text", + text=json.dumps({"error": "page_ref is required"}, indent=2) + )] + result = await self.page_tools.page_set_auth(page_ref, auth_config) + return [TextContent( + type="text", + text=json.dumps(result, indent=2) + )] raise ValueError(f"Unknown tool: {name}")