diff --git a/docs/adr/ADR-035-cross-platform-market-campaign-intelligence.md b/docs/adr/ADR-035-cross-platform-market-campaign-intelligence.md index 40fd183..ab8fd9b 100644 --- a/docs/adr/ADR-035-cross-platform-market-campaign-intelligence.md +++ b/docs/adr/ADR-035-cross-platform-market-campaign-intelligence.md @@ -154,6 +154,7 @@ EwoooC 目前已有 MOMO EDM / 節慶活動資料、`promo_products`、PChome - 2026-05-18 追加 legacy source bridge preview:`/api/market_intel/legacy_source_bridge` 預設 `execute=false` 只回 planned,不連 DB;人工 smoke 才能以 `execute=true` 只讀盤點 `promo_products`、`competitor_prices`、`competitor_price_history`,產生舊資料導入 `market_*` 的 mapping、dedupe 與 blocked operation preview。此橋接不得寫入 DB、不得建立 ORM session、不得把 PChome 比價快取冒充為活動頁商品、不得掛 scheduler。 - 2026-05-18 追加 MCP readiness preview:`/api/market_intel/mcp_readiness` 預設 `execute=false` 只回 planned,盤點 ADR-031 外部 MCP server、`services.mcp_router` feature flag、tool registry、`mcp_calls` telemetry 與 market_intel tool contract 缺口。人工 smoke 才能以 `execute=true` 做只讀 health / telemetry probe;此探針不得寫 DB、不得建立 ORM session、不得替市場情報自動啟用 MCP 或外部爬取。 - 2026-05-18 追加 internal MCP tool contract preview:`services.market_intel.mcp_contract` 與 `/api/market_intel/mcp_tool_contract` 定義 `market_campaign_search`、`market_campaign_scrape`、`market_product_match_lookup` 三個 read-only contract,並在 `services.mcp_router.TOOL_REGISTRY` 註冊 `market_intel` caller 白名單。此階段只建立可審核合約與 readiness 檢查,不啟用 `MCP_ROUTER_ENABLED`、不呼叫 MCP server、不寫 DB、不掛 scheduler。 +- 2026-05-18 追加 external MCP deploy preflight preview:`services.market_intel.mcp_deploy_preflight` 與 `/api/market_intel/mcp_deploy_preflight` 只讀檢查 `docker-compose.mcp.yml`、必要 env、localhost-only ports、read-only volume、Firecrawl resource guard 與 fallback plan。此 preflight 不執行 docker/SSH、不建立 `mcp_readonly` role、不啟用 `MCP_ROUTER_ENABLED`、不寫 DB、不掛 scheduler;外部 MCP stack 須等 env 與 operator smoke 全過後另行批准。 ### Phase 4:Coupang / Shopee Adapter diff --git a/routes/README.md b/routes/README.md index 52933f7..c3265be 100644 --- a/routes/README.md +++ b/routes/README.md @@ -19,7 +19,7 @@ | `edm_routes.py` | EDM 與節慶儀表板 | `/edm`, `/festival` | | `monthly_routes.py` | 月結分析 | `/monthly_summary_analysis`, `/api/monthly_summary_data` | | `daily_sales_routes.py` | 當日業績 | `/daily_sales`, `/daily_sales/export*` | -| `market_intel_routes.py` | 市場情報 Phase 29 internal MCP contract preview | `/market_intel`, `/market_intel/*`, `/api/market_intel/status`, `/api/market_intel/schema`, `/api/market_intel/schema_smoke`, `/api/market_intel/schema_db_probe`, `/api/market_intel/platform_seed_db_diff`, `/api/market_intel/legacy_source_bridge`, `/api/market_intel/mcp_readiness`, `/api/market_intel/mcp_tool_contract`, `/api/market_intel/adapters`, `/api/market_intel/dry_run_plan`, `/api/market_intel/discovery_plan`, `/api/market_intel/manual_discovery`, `/api/market_intel/candidate_preview`, `/api/market_intel/platform_seed_plan`, `/api/market_intel/platform_seed_write_guard`, `/api/market_intel/platform_seed_writer_plan`, `/api/market_intel/migration_blueprint`, `/api/market_intel/seed_writer_cli_status`, `/api/market_intel/write_approval_runbook`, `/api/market_intel/deployment_readiness` | +| `market_intel_routes.py` | 市場情報 Phase 30 external MCP preflight preview | `/market_intel`, `/market_intel/*`, `/api/market_intel/status`, `/api/market_intel/schema`, `/api/market_intel/schema_smoke`, `/api/market_intel/schema_db_probe`, `/api/market_intel/platform_seed_db_diff`, `/api/market_intel/legacy_source_bridge`, `/api/market_intel/mcp_readiness`, `/api/market_intel/mcp_tool_contract`, `/api/market_intel/mcp_deploy_preflight`, `/api/market_intel/adapters`, `/api/market_intel/dry_run_plan`, `/api/market_intel/discovery_plan`, `/api/market_intel/manual_discovery`, `/api/market_intel/candidate_preview`, `/api/market_intel/platform_seed_plan`, `/api/market_intel/platform_seed_write_guard`, `/api/market_intel/platform_seed_writer_plan`, `/api/market_intel/migration_blueprint`, `/api/market_intel/seed_writer_cli_status`, `/api/market_intel/write_approval_runbook`, `/api/market_intel/deployment_readiness` | | `api_routes.py` | 通用任務與查詢 API | `/api/run_task`, `/api/history/*` | | `export_routes.py` | 匯出功能 | `/api/export/*` | | `import_routes.py` | 匯入功能 | `/api/import_excel`, `/api/import/monthly_summary` | diff --git a/routes/market_intel_routes.py b/routes/market_intel_routes.py index 73e0ad7..bdec99e 100644 --- a/routes/market_intel_routes.py +++ b/routes/market_intel_routes.py @@ -126,6 +126,12 @@ def market_intel_mcp_tool_contract(): return jsonify(_service().build_mcp_tool_contract()) +@market_intel_bp.route("/api/market_intel/mcp_deploy_preflight") +@login_required +def market_intel_mcp_deploy_preflight(): + return jsonify(_service().build_mcp_deploy_preflight()) + + @market_intel_bp.route("/api/market_intel/adapters") @login_required def market_intel_adapters(): diff --git a/services/market_intel/mcp_deploy_preflight.py b/services/market_intel/mcp_deploy_preflight.py new file mode 100644 index 0000000..6aabbb1 --- /dev/null +++ b/services/market_intel/mcp_deploy_preflight.py @@ -0,0 +1,178 @@ +"""外部 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, + } diff --git a/services/market_intel/mcp_readiness.py b/services/market_intel/mcp_readiness.py index 52c29b3..1cb4ec6 100644 --- a/services/market_intel/mcp_readiness.py +++ b/services/market_intel/mcp_readiness.py @@ -252,9 +252,9 @@ def build_mcp_readiness_plan( "would_write_database": False, "blocked_reasons": blocked_reasons, "next_required_steps": [ + "先通過 /api/market_intel/mcp_deploy_preflight 的 env、compose、port 與 fallback 檢查", "部署並健康檢查 docker-compose.mcp.yml 的 postgres / omnisearch / firecrawl / filesystem", - "在正式環境設定 MCP_ROUTER_ENABLED=true 與 MCP_* URL / API keys", - "建立 market_intel caller 的 MCP tool 白名單與 read-only tool contract", + "四個 MCP health endpoint 全部 200 後,才在正式環境設定 MCP_ROUTER_ENABLED=true", "把 market_intel discovery / bridge preview 改成先走 MCP readiness,再允許人工 fetch", ], } diff --git a/services/market_intel/service.py b/services/market_intel/service.py index eeb6dad..d034899 100644 --- a/services/market_intel/service.py +++ b/services/market_intel/service.py @@ -21,6 +21,7 @@ from services.market_intel.candidate_preview import build_candidate_preview_from from services.market_intel.discovery_runner import ManualDiscoveryRunner from services.market_intel.legacy_source_bridge import build_legacy_source_bridge_plan from services.market_intel.mcp_contract import build_mcp_tool_contract_preview +from services.market_intel.mcp_deploy_preflight import build_mcp_deploy_preflight_plan from services.market_intel.mcp_readiness import build_mcp_readiness_plan from services.market_intel.migration_blueprint import build_migration_blueprint from services.market_intel.platform_seed import build_platform_seed_rows @@ -65,7 +66,7 @@ class MarketIntelRuntimeStatus: class MarketIntelService: """市場情報入口服務,先集中 feature gate 與安全狀態。""" - phase = "phase_29_internal_mcp_contract_preview" + phase = "phase_30_external_mcp_preflight_preview" def get_runtime_status(self) -> MarketIntelRuntimeStatus: return MarketIntelRuntimeStatus( @@ -328,6 +329,12 @@ class MarketIntelService: contract["phase"] = self.phase return contract + def build_mcp_deploy_preflight(self): + """回報外部 MCP stack 部署前檢查;不執行 docker/SSH/DB。""" + preflight = build_mcp_deploy_preflight_plan() + preflight["phase"] = self.phase + return preflight + def build_platform_seed_writer_plan(self, platform_code="all"): """建立 platform seed writer dry-run plan,不建立 DB session。""" seed_plan = self.build_platform_seed_plan(platform_code=platform_code) @@ -404,6 +411,7 @@ class MarketIntelService: status = self.get_runtime_status() schema_smoke = build_schema_smoke(MARKET_INTEL_TABLES) writer_plan = self.build_platform_seed_writer_plan() + mcp_deploy_preflight = self.build_mcp_deploy_preflight() checks = { "schema_smoke_passed": bool(schema_smoke["passed"]), "feature_flags_default_safe": bool( @@ -435,6 +443,10 @@ class MarketIntelService: "mcp_tool_contract_ready": bool( self.build_mcp_tool_contract()["contract_ready"] ), + "mcp_deploy_preflight_preview_safe": bool( + mcp_deploy_preflight["mode"] == "mcp_external_deploy_preflight_preview" + and not mcp_deploy_preflight["deployment_actions_executed"] + ), } ready_for_production_deploy = all(checks.values()) blocked_reasons = [ @@ -560,6 +572,7 @@ class MarketIntelService: "/api/market_intel/legacy_source_bridge", "/api/market_intel/mcp_readiness", "/api/market_intel/mcp_tool_contract", + "/api/market_intel/mcp_deploy_preflight", ], "status": status.to_dict(), "schema_smoke": schema_smoke, @@ -576,4 +589,5 @@ class MarketIntelService: "legacy_source_bridge": self.build_legacy_source_bridge(), "mcp_readiness": self.build_mcp_readiness(), "mcp_tool_contract": self.build_mcp_tool_contract(), + "mcp_deploy_preflight": mcp_deploy_preflight, } diff --git a/templates/market_intel/disabled.html b/templates/market_intel/disabled.html index 5dc4a4c..bf87aac 100644 --- a/templates/market_intel/disabled.html +++ b/templates/market_intel/disabled.html @@ -432,6 +432,24 @@ +
MCP / EXTERNAL DEPLOY PREFLIGHT
+ENV GATES
+COMPOSE SERVICES
+LOCALHOST PORTS
+FALLBACK
+