"""外部 MCP stack 部署前檢查 preview。 本模組只讀本機 compose 設計與環境變數狀態;不執行 docker、SSH、DB migration、 RBAC 建立或任何 MCP server health call。 """ import os from pathlib import Path MCP_COMPOSE_PATH = "docker-compose.mcp.yml" EXPECTED_MCP_SERVICES = ( "postgres-mcp", "mcp-omnisearch", "firecrawl-self", "firecrawl-redis", "firecrawl-playwright", "chrome-reaper", "filesystem-mcp", ) EXPECTED_MCP_CONTAINERS = ( "momo-mcp-postgres", "momo-mcp-omnisearch", "momo-mcp-firecrawl", "momo-mcp-firecrawl-redis", "momo-mcp-firecrawl-playwright", "momo-mcp-chrome-reaper", "momo-mcp-filesystem", ) EXPECTED_LOCALHOST_PORTS = ("3001", "3002", "3003", "3004") REQUIRED_ENV_VARS = ("MCP_POSTGRES_PASSWORD", "TAVILY_API_KEY", "EXA_API_KEY") OPTIONAL_ENV_VARS = ("FIRECRAWL_AUTH_KEY",) def _read_compose_file(path): compose_path = Path(path) if not compose_path.exists(): return compose_path, "" return compose_path, compose_path.read_text(encoding="utf-8") def _build_env_status(env): statuses = [] for name in REQUIRED_ENV_VARS: statuses.append( { "name": name, "required": True, "present": bool(env.get(name)), "value_redacted": bool(env.get(name)), } ) for name in OPTIONAL_ENV_VARS: statuses.append( { "name": name, "required": False, "present": bool(env.get(name)), "value_redacted": bool(env.get(name)), } ) return statuses def build_mcp_deploy_preflight_plan(*, compose_path=MCP_COMPOSE_PATH, env=None): """建立外部 MCP 部署 preflight;不執行任何部署動作。""" env = env or os.environ compose_file, compose_text = _read_compose_file(compose_path) service_statuses = [ { "service": service, "declared": f" {service}:" in compose_text, } for service in EXPECTED_MCP_SERVICES ] container_statuses = [ { "container": container, "declared": f"container_name: {container}" in compose_text, } for container in EXPECTED_MCP_CONTAINERS ] port_statuses = [ { "port": port, "localhost_only": f"127.0.0.1:{port}:" in compose_text, "declared": f":{port}" in compose_text, } for port in EXPECTED_LOCALHOST_PORTS ] env_statuses = _build_env_status(env) env_required_present = all( item["present"] for item in env_statuses if item["required"] ) router_enabled = env.get("MCP_ROUTER_ENABLED", "false").strip().lower() in ( "true", "1", "yes", "on", ) checks = { "compose_file_present": compose_file.exists(), "all_expected_services_declared": all(item["declared"] for item in service_statuses), "all_expected_containers_declared": all(item["declared"] for item in container_statuses), "all_public_mcp_ports_localhost_only": all( item["localhost_only"] for item in port_statuses ), "required_env_vars_present": env_required_present, "postgres_readonly_role_required": "POSTGRES_USER=mcp_readonly" in compose_text, "postgres_allowed_tables_limited": "ALLOWED_TABLES=" in compose_text, "filesystem_mounts_read_only": "./data:/data:ro" in compose_text and "./logs:/logs:ro" in compose_text, "firecrawl_resource_guard_present": "memory: 2g" in compose_text and "PLAYWRIGHT_BROWSER_POOL_MAX=3" in compose_text, "chrome_reaper_declared": "chrome-reaper:" in compose_text, "router_flag_still_off_before_health": not router_enabled, "no_deployment_action_executed": True, "no_database_lifecycle_action_executed": True, } blocked_reasons = [ key for key, passed in checks.items() if not passed ] return { "mode": "mcp_external_deploy_preflight_preview", "compose_path": str(compose_file), "compose_file_present": compose_file.exists(), "expected_services": list(EXPECTED_MCP_SERVICES), "service_statuses": service_statuses, "container_statuses": container_statuses, "port_statuses": port_statuses, "env_statuses": env_statuses, "checks": checks, "ready_to_start_stack": not blocked_reasons, "blocked_reasons": blocked_reasons, "operator_command_preview": ( "docker compose -f docker-compose.mcp.yml up -d" if compose_file.exists() else None ), "post_start_health_targets": [ "http://localhost:3001/health", "http://localhost:3002/health", "http://localhost:3003/health", "http://localhost:3004/health", ], "fallback_plan": [ { "key": "keep_router_disabled", "label": "MCP_ROUTER_ENABLED 維持 false,直到四個 health endpoint 全部 200", }, { "key": "stop_mcp_stack_only", "label": "若 MCP stack 異常,只停止 docker-compose.mcp.yml 管理的 momo-mcp-* 容器", }, { "key": "no_momo_db_lifecycle_change", "label": "不得 stop/rm/recreate momo-db;postgres-mcp 只能使用 mcp_readonly", }, ], "safe_deploy_boundaries": [ "do_not_use_remove_orphans", "do_not_touch_momo_db_container", "do_not_enable_mcp_router_before_health_passes", "do_not_run_external_crawlers_without_operator_approval", ], "deployment_actions_executed": False, "docker_command_executed": False, "ssh_command_executed": False, "database_session_created": False, "database_write_executed": False, "database_commit_executed": False, "external_network_executed": False, "scheduler_attached": False, "writes_executed": False, "would_write_database": False, }