#!/usr/bin/env python3 """ DexOrder User Container Main Entry Point Brings together: - Config and secrets loading from k8s mounted YAML files - ZeroMQ event publisher for user events - MCP server with minimal "hello world" resource - Lifecycle management integration """ import asyncio import logging import os import signal import sys from pathlib import Path from typing import Optional import yaml from mcp.server import Server from mcp.server.stdio import stdio_server from dexorder import EventPublisher, start_lifecycle_manager, get_lifecycle_manager from dexorder.events import EventType, UserEvent, DeliverySpec from dexorder.api.workspace_tools import get_workspace_store # ============================================================================= # Global Data Directory # ============================================================================= # Default data directory (relative to working directory for local dev) DEFAULT_DATA_DIR = Path("data") # Global data directory - set after config is loaded DATA_DIR: Path = DEFAULT_DATA_DIR def get_data_dir() -> Path: """Get the global data directory.""" return DATA_DIR # ============================================================================= # Configuration # ============================================================================= class Config: """Application configuration loaded from config.yaml and secrets.yaml""" def __init__(self): # User ID (required) self.user_id: str = os.getenv("USER_ID", "") if not self.user_id: raise ValueError("USER_ID environment variable required") # Config and secrets paths (k8s mounted) self.config_path = Path(os.getenv("CONFIG_PATH", "/app/config/config.yaml")) self.secrets_path = Path(os.getenv("SECRETS_PATH", "/app/config/secrets.yaml")) # ZMQ ports for event system self.zmq_xpub_port: int = int(os.getenv("ZMQ_XPUB_PORT", "5570")) self.zmq_gateway_endpoint: str = os.getenv( "ZMQ_GATEWAY_ENDPOINT", "tcp://gateway:5571" ) # MCP server settings self.mcp_server_name: str = os.getenv("MCP_SERVER_NAME", "dexorder-user") # Lifecycle settings self.idle_timeout_minutes: int = int(os.getenv("IDLE_TIMEOUT_MINUTES", "15")) self.enable_idle_shutdown: bool = os.getenv("ENABLE_IDLE_SHUTDOWN", "true").lower() == "true" # Loaded from files self.config_data: dict = {} self.secrets_data: dict = {} # Data directory (set after config load) self.data_dir: Path = DEFAULT_DATA_DIR def load(self) -> None: """Load configuration and secrets from YAML files""" global DATA_DIR # Load config.yaml if exists if self.config_path.exists(): with open(self.config_path) as f: self.config_data = yaml.safe_load(f) or {} logging.info(f"Loaded config from {self.config_path}") else: logging.warning(f"Config file not found: {self.config_path}") # Load secrets.yaml if exists if self.secrets_path.exists(): with open(self.secrets_path) as f: self.secrets_data = yaml.safe_load(f) or {} logging.info(f"Loaded secrets from {self.secrets_path}") else: logging.warning(f"Secrets file not found: {self.secrets_path}") # Set data directory from config or environment # Priority: env var > config file > default data_dir_str = os.getenv("DATA_DIR") or self.config_data.get("data_dir") if data_dir_str: self.data_dir = Path(data_dir_str) else: self.data_dir = DEFAULT_DATA_DIR # Update global DATA_DIR DATA_DIR = self.data_dir # Ensure data directory exists self.data_dir.mkdir(parents=True, exist_ok=True) logging.info(f"Data directory: {self.data_dir}") @property def workspace_dir(self) -> Path: """Workspace directory under DATA_DIR.""" return self.data_dir / "workspace" # ============================================================================= # MCP Server Setup # ============================================================================= def create_mcp_server(config: Config, event_publisher: EventPublisher) -> Server: """Create MCP server with resources and workspace tools""" server = Server(config.mcp_server_name) # Initialize workspace store workspace_store = get_workspace_store(config.workspace_dir) logging.info(f"Workspace store initialized at {config.workspace_dir}") @server.list_resources() async def list_resources(): """List available resources""" return [ { "uri": f"dexorder://user/{config.user_id}/hello", "name": "Hello World", "description": "A simple hello world resource", "mimeType": "text/plain", } ] @server.read_resource() async def read_resource(uri: str): """Read a resource by URI""" if uri == f"dexorder://user/{config.user_id}/hello": # Publish an event when resource is accessed await event_publisher.publish(UserEvent( event_type=EventType.STRATEGY_LOG, payload={ "message": "Hello world resource accessed", "uri": uri, }, delivery=DeliverySpec.informational(), )) return { "uri": uri, "mimeType": "text/plain", "text": f"Hello from DexOrder user container!\nUser ID: {config.user_id}\n", } else: raise ValueError(f"Unknown resource: {uri}") @server.list_tools() async def list_tools(): """List available tools including workspace tools""" return [ { "name": "workspace_read", "description": "Read a workspace store from persistent storage", "inputSchema": { "type": "object", "properties": { "store_name": { "type": "string", "description": "Name of the store (e.g., 'chartStore', 'userPreferences')" } }, "required": ["store_name"] } }, { "name": "workspace_write", "description": "Write a workspace store to persistent storage", "inputSchema": { "type": "object", "properties": { "store_name": { "type": "string", "description": "Name of the store" }, "data": { "description": "Data to write" } }, "required": ["store_name", "data"] } }, { "name": "workspace_patch", "description": "Apply JSON patch operations to a workspace store", "inputSchema": { "type": "object", "properties": { "store_name": { "type": "string", "description": "Name of the store" }, "patch": { "type": "array", "description": "JSON Patch operations (RFC 6902)", "items": { "type": "object", "properties": { "op": {"type": "string", "enum": ["add", "remove", "replace", "move", "copy", "test"]}, "path": {"type": "string"}, "value": {} }, "required": ["op", "path"] } } }, "required": ["store_name", "patch"] } } ] @server.call_tool() async def handle_tool_call(name: str, arguments: dict): """Handle tool calls including workspace tools""" if name == "workspace_read": return workspace_store.read(arguments.get("store_name", "")) elif name == "workspace_write": return workspace_store.write( arguments.get("store_name", ""), arguments.get("data") ) elif name == "workspace_patch": return workspace_store.patch( arguments.get("store_name", ""), arguments.get("patch", []) ) else: raise ValueError(f"Unknown tool: {name}") logging.info(f"MCP server '{config.mcp_server_name}' created with workspace tools") return server # ============================================================================= # Main Application # ============================================================================= class UserContainer: """Main user container application""" def __init__(self): self.config = Config() self.event_publisher: Optional[EventPublisher] = None self.mcp_server: Optional[Server] = None self.running = False async def start(self) -> None: """Start all subsystems""" logging.info("Starting user container") # Load configuration self.config.load() # Start lifecycle manager await start_lifecycle_manager( user_id=self.config.user_id, idle_timeout_minutes=self.config.idle_timeout_minutes, enable_idle_shutdown=self.config.enable_idle_shutdown, ) logging.info("Lifecycle manager started") # Start event publisher self.event_publisher = EventPublisher( user_id=self.config.user_id, xpub_port=self.config.zmq_xpub_port, gateway_router_endpoint=self.config.zmq_gateway_endpoint, ) await self.event_publisher.start() logging.info("Event publisher started") # Publish CONTAINER_STARTING event await self.event_publisher.publish(UserEvent( event_type=EventType.CONTAINER_STARTING, payload={ "user_id": self.config.user_id, "timestamp": None, # Will be auto-filled }, delivery=DeliverySpec.active_or_telegram(), )) # Create MCP server self.mcp_server = create_mcp_server(self.config, self.event_publisher) # Publish CONTAINER_READY event await self.event_publisher.publish(UserEvent( event_type=EventType.CONTAINER_READY, payload={ "user_id": self.config.user_id, }, delivery=DeliverySpec.active_or_telegram(), )) self.running = True logging.info("User container ready") async def stop(self) -> None: """Stop all subsystems""" if not self.running: return logging.info("Stopping user container") self.running = False # Publish CONTAINER_SHUTTING_DOWN event if self.event_publisher: await self.event_publisher.publish(UserEvent( event_type=EventType.CONTAINER_SHUTTING_DOWN, payload={ "user_id": self.config.user_id, }, delivery=DeliverySpec.active_or_telegram(), )) # Stop subsystems if self.event_publisher: await self.event_publisher.stop() logging.info("Event publisher stopped") lifecycle = get_lifecycle_manager() if lifecycle: await lifecycle.stop() logging.info("Lifecycle manager stopped") logging.info("User container stopped") async def run(self) -> None: """Run the MCP server via stdio""" await self.start() try: # Run MCP server on stdio async with stdio_server() as (read_stream, write_stream): await self.mcp_server.run( read_stream, write_stream, self.mcp_server.create_initialization_options() ) finally: await self.stop() # ============================================================================= # Entry Point # ============================================================================= async def main(): """Main entry point""" # Setup logging log_level = os.getenv("LOG_LEVEL", "INFO").upper() logging.basicConfig( level=getattr(logging, log_level), format="%(asctime)s [%(levelname)s] %(name)s: %(message)s", stream=sys.stderr, # MCP uses stdout for protocol ) # Create and run container container = UserContainer() # Handle shutdown signals loop = asyncio.get_event_loop() def handle_signal(sig): logging.info(f"Received signal {sig}, shutting down...") asyncio.create_task(container.stop()) loop.stop() for sig in (signal.SIGTERM, signal.SIGINT): loop.add_signal_handler(sig, lambda s=sig: handle_signal(s)) try: await container.run() except KeyboardInterrupt: logging.info("Keyboard interrupt received") except Exception as e: logging.error(f"Fatal error: {e}", exc_info=True) sys.exit(1) if __name__ == "__main__": asyncio.run(main())