From d990316d7406081e3eb2ea1175980384386ef6d9 Mon Sep 17 00:00:00 2001 From: OoO Date: Mon, 18 May 2026 15:25:44 +0800 Subject: [PATCH] feat(market-intel): add mcp activation runbook --- ...s-platform-market-campaign-intelligence.md | 1 + routes/README.md | 2 +- routes/market_intel_routes.py | 6 + .../market_intel/mcp_activation_runbook.py | 170 ++++++++++++++++++ services/market_intel/service.py | 21 ++- templates/market_intel/disabled.html | 109 ++++++++++- tests/test_market_intel_skeleton.py | 103 ++++++++++- 7 files changed, 405 insertions(+), 7 deletions(-) create mode 100644 services/market_intel/mcp_activation_runbook.py 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 c4610e2..4b60ba6 100644 --- a/docs/adr/ADR-035-cross-platform-market-campaign-intelligence.md +++ b/docs/adr/ADR-035-cross-platform-market-campaign-intelligence.md @@ -155,6 +155,7 @@ EwoooC 目前已有 MOMO EDM / 節慶活動資料、`promo_products`、PChome - 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。`docker-compose.mcp.yml` 需以 read-only bind mount 進 app 容器供 preflight 審核。此 preflight 不執行 docker/SSH、不建立 `mcp_readonly` role、不啟用 `MCP_ROUTER_ENABLED`、不寫 DB、不掛 scheduler;外部 MCP stack 須等 env 與 operator smoke 全過後另行批准。 +- 2026-05-18 追加 MCP activation runbook preview:`services.market_intel.mcp_activation_runbook` 與 `/api/market_intel/mcp_activation_runbook` 只輸出人工啟用順序與 gate:補必要 env、人工建立/驗證 `mcp_readonly`、啟動外部 MCP stack、四個 health 全過、最後才允許 `MCP_ROUTER_ENABLED=true`。此 runbook 不執行 docker/SSH、不寫 env、不建立 DB role、不跑 health、不啟用 router、不寫 DB、不掛 scheduler。 ### Phase 4:Coupang / Shopee Adapter diff --git a/routes/README.md b/routes/README.md index c3265be..3b938c5 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 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` | +| `market_intel_routes.py` | 市場情報 Phase 31 MCP activation runbook 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/mcp_activation_runbook`, `/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 bdec99e..9d6bb0b 100644 --- a/routes/market_intel_routes.py +++ b/routes/market_intel_routes.py @@ -132,6 +132,12 @@ def market_intel_mcp_deploy_preflight(): return jsonify(_service().build_mcp_deploy_preflight()) +@market_intel_bp.route("/api/market_intel/mcp_activation_runbook") +@login_required +def market_intel_mcp_activation_runbook(): + return jsonify(_service().build_mcp_activation_runbook()) + + @market_intel_bp.route("/api/market_intel/adapters") @login_required def market_intel_adapters(): diff --git a/services/market_intel/mcp_activation_runbook.py b/services/market_intel/mcp_activation_runbook.py new file mode 100644 index 0000000..22700b1 --- /dev/null +++ b/services/market_intel/mcp_activation_runbook.py @@ -0,0 +1,170 @@ +"""外部 MCP 啟用 runbook preview。 + +只組裝人工操作順序、gate、command shape 與 fallback;不執行 docker、SSH、 +SQL、health check、env 寫入或 router 啟用。 +""" + +from services.market_intel.mcp_deploy_preflight import build_mcp_deploy_preflight_plan +from services.market_intel.mcp_readiness import build_mcp_readiness_plan + + +def _step(key, label, gate, status, command_preview=None, notes=None): + item = { + "key": key, + "label": label, + "gate": gate, + "status": status, + } + if command_preview: + item["command_preview"] = command_preview + if notes: + item["notes"] = notes + return item + + +def build_mcp_activation_runbook_preview( + *, + preflight=None, + readiness=None, +): + """建立 MCP 啟用流程;預設只讀 planned,不做 health / DB / docker。""" + preflight = preflight or build_mcp_deploy_preflight_plan() + readiness = readiness or build_mcp_readiness_plan(execute_requested=False) + required_env_ready = bool(preflight["checks"].get("required_env_vars_present")) + compose_ready = bool( + preflight["checks"].get("compose_file_present") + and preflight["checks"].get("all_expected_services_declared") + and preflight["checks"].get("all_expected_containers_declared") + and preflight["checks"].get("all_public_mcp_ports_localhost_only") + ) + readonly_design_ready = bool( + preflight["checks"].get("postgres_readonly_role_required") + and preflight["checks"].get("postgres_allowed_tables_limited") + and preflight["checks"].get("filesystem_mounts_read_only") + ) + resource_guard_ready = bool( + preflight["checks"].get("firecrawl_resource_guard_present") + and preflight["checks"].get("chrome_reaper_declared") + ) + router_still_off = bool(preflight["checks"].get("router_flag_still_off_before_health")) + health_checked = bool(readiness["readiness_checks"].get("external_server_health_checked")) + all_health_passed = bool(readiness["readiness_checks"].get("external_servers_all_healthy")) + contract_ready = bool(readiness["readiness_checks"].get("market_intel_tool_contract_ready")) + + stages = [ + _step( + "configure_required_env", + "設定 MCP_POSTGRES_PASSWORD、TAVILY_API_KEY、EXA_API_KEY,值不得寫入 repo", + "required_env_vars_present", + "ready" if required_env_ready else "blocked", + ), + _step( + "create_mcp_readonly_role", + "人工確認 momo-db 內存在 mcp_readonly,且只 GRANT 允許表 SELECT", + "postgres_readonly_role_required", + "ready" if readonly_design_ready else "blocked", + command_preview="psql \"$DATABASE_URL\" -v ON_ERROR_STOP=1 -f ", + notes="不可 stop/rm/recreate momo-db;只允許 operator 在 DB 內建立或修正 readonly role。", + ), + _step( + "start_external_mcp_stack", + "在 188 主機啟動 docker-compose.mcp.yml 管理的 momo-mcp-* stack", + "preflight_ready_to_start_stack", + "ready" if preflight["ready_to_start_stack"] else "blocked", + command_preview=preflight.get("operator_command_preview"), + ), + _step( + "run_mcp_health_smoke", + "確認 3001/3002/3003/3004 四個 localhost health endpoint 全部 200", + "external_servers_all_healthy", + "pass" if all_health_passed else "pending" if health_checked else "blocked", + command_preview="curl http://localhost:3001/health && curl http://localhost:3002/health && curl http://localhost:3003/health && curl http://localhost:3004/health", + ), + _step( + "enable_mcp_router_flag", + "只有 health 全過且 operator 批准後,才可設定 MCP_ROUTER_ENABLED=true", + "router_enable_after_health", + "ready" if all_health_passed and router_still_off and contract_ready else "blocked", + command_preview="MCP_ROUTER_ENABLED=true docker compose up -d --no-deps --force-recreate momo-app", + ), + _step( + "run_market_intel_mcp_smoke", + "啟用 router 後只跑 market_intel MCP read-only smoke,不寫 market_*", + "market_intel_contract_ready", + "ready" if contract_ready and all_health_passed else "blocked", + command_preview="/api/market_intel/mcp_readiness?execute=true&timeout=3", + ), + ] + + checks = { + "compose_ready": compose_ready, + "required_env_ready": required_env_ready, + "readonly_design_ready": readonly_design_ready, + "resource_guard_ready": resource_guard_ready, + "router_still_off_before_health": router_still_off, + "market_intel_contract_ready": contract_ready, + "health_checked": health_checked, + "all_health_passed": all_health_passed, + "no_activation_actions_executed": True, + "no_database_lifecycle_action_executed": True, + "no_database_write_executed": True, + } + blocked_reasons = [ + key for key, passed in checks.items() + if not passed and key not in { + "health_checked", + "all_health_passed", + } + ] + if not health_checked: + blocked_reasons.append("external_health_smoke_not_executed") + elif not all_health_passed: + blocked_reasons.append("external_health_smoke_not_passed") + + return { + "mode": "mcp_activation_runbook_preview", + "ready_for_operator_activation": not blocked_reasons, + "checks": checks, + "blocked_reasons": blocked_reasons, + "stages": stages, + "required_env_vars": [ + item["name"] + for item in preflight.get("env_statuses", []) + if item.get("required") + ], + "post_start_health_targets": preflight.get("post_start_health_targets", []), + "fallback_plan": [ + { + "key": "router_flag_kill_switch", + "label": "若 smoke 失敗,先把 MCP_ROUTER_ENABLED 關回 false,只 recreate momo-app", + }, + { + "key": "stop_mcp_stack_only", + "label": "外部 MCP stack 異常時,只停 docker-compose.mcp.yml 管理的 momo-mcp-* 容器", + }, + { + "key": "preserve_momo_db", + "label": "不得 stop/rm/recreate momo-db;必要時只撤回 mcp_readonly grants", + }, + ], + "safety_contract": { + "does_not_execute_docker": True, + "does_not_execute_ssh": True, + "does_not_write_env": True, + "does_not_create_db_role": True, + "does_not_run_health_check": True, + "does_not_enable_router": True, + "does_not_write_database": True, + "does_not_attach_scheduler": True, + }, + "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/service.py b/services/market_intel/service.py index d034899..bdff6fb 100644 --- a/services/market_intel/service.py +++ b/services/market_intel/service.py @@ -20,6 +20,7 @@ from services.market_intel.adapters import ( from services.market_intel.candidate_preview import build_candidate_preview_from_discovery 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_activation_runbook import build_mcp_activation_runbook_preview 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 @@ -66,7 +67,7 @@ class MarketIntelRuntimeStatus: class MarketIntelService: """市場情報入口服務,先集中 feature gate 與安全狀態。""" - phase = "phase_30_external_mcp_preflight_preview" + phase = "phase_31_mcp_activation_runbook_preview" def get_runtime_status(self) -> MarketIntelRuntimeStatus: return MarketIntelRuntimeStatus( @@ -335,6 +336,17 @@ class MarketIntelService: preflight["phase"] = self.phase return preflight + def build_mcp_activation_runbook(self): + """回報 MCP 啟用順序與 gate;不執行任何啟用動作。""" + preflight = self.build_mcp_deploy_preflight() + readiness = self.build_mcp_readiness() + runbook = build_mcp_activation_runbook_preview( + preflight=preflight, + readiness=readiness, + ) + runbook["phase"] = self.phase + return runbook + 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) @@ -412,6 +424,7 @@ class MarketIntelService: schema_smoke = build_schema_smoke(MARKET_INTEL_TABLES) writer_plan = self.build_platform_seed_writer_plan() mcp_deploy_preflight = self.build_mcp_deploy_preflight() + mcp_activation_runbook = self.build_mcp_activation_runbook() checks = { "schema_smoke_passed": bool(schema_smoke["passed"]), "feature_flags_default_safe": bool( @@ -447,6 +460,10 @@ class MarketIntelService: mcp_deploy_preflight["mode"] == "mcp_external_deploy_preflight_preview" and not mcp_deploy_preflight["deployment_actions_executed"] ), + "mcp_activation_runbook_preview_safe": bool( + mcp_activation_runbook["mode"] == "mcp_activation_runbook_preview" + and not mcp_activation_runbook["deployment_actions_executed"] + ), } ready_for_production_deploy = all(checks.values()) blocked_reasons = [ @@ -573,6 +590,7 @@ class MarketIntelService: "/api/market_intel/mcp_readiness", "/api/market_intel/mcp_tool_contract", "/api/market_intel/mcp_deploy_preflight", + "/api/market_intel/mcp_activation_runbook", ], "status": status.to_dict(), "schema_smoke": schema_smoke, @@ -590,4 +608,5 @@ class MarketIntelService: "mcp_readiness": self.build_mcp_readiness(), "mcp_tool_contract": self.build_mcp_tool_contract(), "mcp_deploy_preflight": mcp_deploy_preflight, + "mcp_activation_runbook": mcp_activation_runbook, } diff --git a/templates/market_intel/disabled.html b/templates/market_intel/disabled.html index bf87aac..3e43171 100644 --- a/templates/market_intel/disabled.html +++ b/templates/market_intel/disabled.html @@ -450,6 +450,24 @@ +
+
+
+

MCP / ACTIVATION RUNBOOK

+

MCP 啟用 Runbook

+
+ +
+
+ loading +
+
+
讀取 MCP 啟用 Runbook 中...
+
+
+
@@ -517,10 +535,11 @@ const legacyBridgeRoot = document.querySelector('[data-market-intel-legacy-bridge]'); const mcpReadinessRoot = document.querySelector('[data-market-intel-mcp-readiness]'); const mcpPreflightRoot = document.querySelector('[data-market-intel-mcp-preflight]'); + const mcpActivationRoot = document.querySelector('[data-market-intel-mcp-activation]'); const migrationRoot = document.querySelector('[data-market-intel-migration]'); const approvalRoot = document.querySelector('[data-market-intel-approval]'); const deployRoot = document.querySelector('[data-market-intel-deploy]'); - if (!root && !writerRoot && !cliRoot && !dbProbeRoot && !seedDiffRoot && !legacyBridgeRoot && !mcpReadinessRoot && !mcpPreflightRoot && !migrationRoot && !approvalRoot && !deployRoot) return; + if (!root && !writerRoot && !cliRoot && !dbProbeRoot && !seedDiffRoot && !legacyBridgeRoot && !mcpReadinessRoot && !mcpPreflightRoot && !mcpActivationRoot && !migrationRoot && !approvalRoot && !deployRoot) return; const meta = root ? root.querySelector('[data-market-intel-preview-meta]') : null; const body = root ? root.querySelector('[data-market-intel-preview-body]') : null; @@ -554,6 +573,10 @@ const mcpPreflightBody = mcpPreflightRoot ? mcpPreflightRoot.querySelector('[data-market-intel-mcp-preflight-body]') : null; const mcpPreflightRefresh = mcpPreflightRoot ? mcpPreflightRoot.querySelector('[data-market-intel-mcp-preflight-refresh]') : null; const mcpPreflightEndpoint = "{{ url_for('market_intel.market_intel_mcp_deploy_preflight') }}"; + const mcpActivationMeta = mcpActivationRoot ? mcpActivationRoot.querySelector('[data-market-intel-mcp-activation-meta]') : null; + const mcpActivationBody = mcpActivationRoot ? mcpActivationRoot.querySelector('[data-market-intel-mcp-activation-body]') : null; + const mcpActivationRefresh = mcpActivationRoot ? mcpActivationRoot.querySelector('[data-market-intel-mcp-activation-refresh]') : null; + const mcpActivationEndpoint = "{{ url_for('market_intel.market_intel_mcp_activation_runbook') }}"; const migrationMeta = migrationRoot ? migrationRoot.querySelector('[data-market-intel-migration-meta]') : null; const migrationBody = migrationRoot ? migrationRoot.querySelector('[data-market-intel-migration-body]') : null; const migrationRefresh = migrationRoot ? migrationRoot.querySelector('[data-market-intel-migration-refresh]') : null; @@ -1092,6 +1115,86 @@ } }; + const renderMcpActivationMeta = data => { + mcpActivationMeta.innerHTML = [ + `mode=${data.mode || 'unknown'}`, + `ready=${data.ready_for_operator_activation ? 'yes' : 'no'}`, + `stages=${(data.stages || []).length}`, + `blocked=${(data.blocked_reasons || []).length}`, + `docker=${data.docker_command_executed ? 'executed' : 'blocked'}` + ].map(item => `${escapeHtml(item)}`).join(''); + }; + + const renderMcpActivationBody = data => { + const blockers = (data.blocked_reasons || []).join(' / '); + const stages = data.stages || []; + const fallback = data.fallback_plan || []; + const safety = Object.entries(data.safety_contract || {}); + const renderStage = item => ` +
+ ${escapeHtml(item.key)} + ${escapeHtml(item.label)} + gate=${escapeHtml(item.gate)} / status=${escapeHtml(item.status)} + ${item.command_preview ? `${escapeHtml(item.command_preview)}` : ''} + ${item.notes ? `${escapeHtml(item.notes)}` : ''} +
+ `; + const renderCheck = (key, label, status) => ` +
+
+ ${escapeHtml(key)} + ${escapeHtml(label || '')} +
+ ${escapeHtml(status)} +
+ `; + + mcpActivationBody.innerHTML = ` +
這是 MCP 啟用順序預覽;不寫 env、不執行 docker/SSH、不建立 DB role、不開 router。${blockers ? `阻擋:${escapeHtml(blockers)}` : ''}
+
+
+

ACTIVATION STAGES

+
${ + stages.length + ? stages.map(renderStage).join('') + : '
尚未提供啟用步驟。
' + }
+
+
+

SAFETY CONTRACT

+
${ + safety.length + ? safety.map(([key, value]) => renderCheck(key, '', value ? 'YES' : 'NO')).join('') + : '
尚未提供安全合約。
' + }
+
+
+

FALLBACK

+
${ + fallback.length + ? fallback.map(item => renderCheck(item.key, item.label, 'READY')).join('') + : '
尚未提供備援方案。
' + }
+
+
+ `; + }; + + const loadMcpActivation = async () => { + if (!mcpActivationMeta || !mcpActivationBody) return; + mcpActivationBody.innerHTML = '
讀取 MCP 啟用 Runbook 中...
'; + try { + const response = await fetch(mcpActivationEndpoint, { credentials: 'same-origin' }); + if (!response.ok) throw new Error(`HTTP ${response.status}`); + const data = await response.json(); + renderMcpActivationMeta(data); + renderMcpActivationBody(data); + } catch (error) { + mcpActivationMeta.innerHTML = 'error'; + mcpActivationBody.innerHTML = `
MCP 啟用 Runbook 讀取失敗:${escapeHtml(error.message)}
`; + } + }; + const renderMigrationMeta = data => { const seedWriter = data.command_plan && data.command_plan.seed_writer_command ? data.command_plan.seed_writer_command @@ -1350,6 +1453,9 @@ if (mcpPreflightRefresh) { mcpPreflightRefresh.addEventListener('click', loadMcpPreflight); } + if (mcpActivationRefresh) { + mcpActivationRefresh.addEventListener('click', loadMcpActivation); + } if (migrationRefresh) { migrationRefresh.addEventListener('click', loadMigration); } @@ -1367,6 +1473,7 @@ loadLegacyBridge(); loadMcpReadiness(); loadMcpPreflight(); + loadMcpActivation(); loadMigration(); loadApproval(); loadDeploy(); diff --git a/tests/test_market_intel_skeleton.py b/tests/test_market_intel_skeleton.py index 598a9a5..31a77ea 100644 --- a/tests/test_market_intel_skeleton.py +++ b/tests/test_market_intel_skeleton.py @@ -14,6 +14,7 @@ from services.market_intel.adapters import get_adapter, get_adapter_summaries from services.market_intel.candidate_preview import build_candidate_preview_from_discovery from services.market_intel.discovery_runner import ManualDiscoveryRunner from services.market_intel.html_diagnostics import parse_html_diagnostics +from services.market_intel.mcp_activation_runbook import build_mcp_activation_runbook_preview 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 @@ -394,6 +395,10 @@ def test_market_intel_preview_template_uses_safe_fetch_false_endpoint(): assert "data-market-intel-mcp-preflight-services" in template assert "data-market-intel-mcp-preflight-ports" in template assert "data-market-intel-mcp-preflight-fallback" in template + assert "data-market-intel-mcp-activation" in template + assert "data-market-intel-mcp-activation-stages" in template + assert "data-market-intel-mcp-activation-safety" in template + assert "data-market-intel-mcp-activation-fallback" in template assert "data-market-intel-migration" in template assert "data-market-intel-migration-tables" in template assert "data-market-intel-approval" in template @@ -409,6 +414,7 @@ def test_market_intel_preview_template_uses_safe_fetch_false_endpoint(): assert "market_intel.market_intel_legacy_source_bridge" in template assert "market_intel.market_intel_mcp_readiness" in template assert "market_intel.market_intel_mcp_deploy_preflight" in template + assert "market_intel.market_intel_mcp_activation_runbook" in template assert "market_intel.market_intel_migration_blueprint" in template assert "market_intel.market_intel_write_approval_runbook" in template assert "market_intel.market_intel_deployment_readiness" in template @@ -434,7 +440,7 @@ def test_legacy_source_bridge_default_is_planned_only(): bridge = MarketIntelService().build_legacy_source_bridge() assert bridge["mode"] == "legacy_source_bridge_planned" - assert bridge["phase"] == "phase_30_external_mcp_preflight_preview" + assert bridge["phase"] == "phase_31_mcp_activation_runbook_preview" assert bridge["execute_requested"] is False assert bridge["read_only_query_executed"] is False assert bridge["database_connection_opened"] is False @@ -592,7 +598,7 @@ def test_mcp_tool_contract_preview_is_read_only_and_whitelisted(): contract = MarketIntelService().build_mcp_tool_contract() assert contract["mode"] == "mcp_tool_contract_preview" - assert contract["phase"] == "phase_30_external_mcp_preflight_preview" + assert contract["phase"] == "phase_31_mcp_activation_runbook_preview" assert contract["caller"] == "market_intel" assert contract["contract_ready"] is True assert contract["blocked_reasons"] == [] @@ -649,6 +655,91 @@ def test_mcp_tool_contract_route_is_preview_only(): assert data["scheduler_attached"] is False +def test_mcp_activation_runbook_blocks_until_env_and_health_pass(): + preflight = build_mcp_deploy_preflight_plan(env={}) + runbook = build_mcp_activation_runbook_preview(preflight=preflight) + + assert runbook["mode"] == "mcp_activation_runbook_preview" + assert runbook["ready_for_operator_activation"] is False + assert "required_env_ready" in runbook["blocked_reasons"] + assert "external_health_smoke_not_executed" in runbook["blocked_reasons"] + assert runbook["deployment_actions_executed"] is False + assert runbook["docker_command_executed"] is False + assert runbook["ssh_command_executed"] is False + assert runbook["database_session_created"] is False + assert runbook["database_write_executed"] is False + assert runbook["database_commit_executed"] is False + assert runbook["external_network_executed"] is False + assert runbook["scheduler_attached"] is False + assert runbook["would_write_database"] is False + assert runbook["safety_contract"]["does_not_execute_docker"] is True + assert runbook["safety_contract"]["does_not_enable_router"] is True + assert {item["key"] for item in runbook["stages"]} == { + "configure_required_env", + "create_mcp_readonly_role", + "start_external_mcp_stack", + "run_mcp_health_smoke", + "enable_mcp_router_flag", + "run_market_intel_mcp_smoke", + } + + +def test_mcp_activation_runbook_can_be_ready_with_mocked_gates(): + preflight = build_mcp_deploy_preflight_plan( + env={ + "MCP_POSTGRES_PASSWORD": "secret", + "TAVILY_API_KEY": "tavily", + "EXA_API_KEY": "exa", + "MCP_ROUTER_ENABLED": "false", + } + ) + readiness = { + "readiness_checks": { + "external_server_health_checked": True, + "external_servers_all_healthy": True, + "market_intel_tool_contract_ready": True, + } + } + + runbook = build_mcp_activation_runbook_preview( + preflight=preflight, + readiness=readiness, + ) + + assert runbook["ready_for_operator_activation"] is True + assert runbook["blocked_reasons"] == [] + assert runbook["checks"]["required_env_ready"] is True + assert runbook["checks"]["all_health_passed"] is True + assert { + item["key"]: item["status"] + for item in runbook["stages"] + }["enable_mcp_router_flag"] == "ready" + + +def test_mcp_activation_runbook_route_is_preview_only(): + from routes.market_intel_routes import market_intel_bp + + app = Flask(__name__) + app.secret_key = "test-secret" + app.register_blueprint(market_intel_bp) + client = app.test_client() + with client.session_transaction() as session: + session["logged_in"] = True + + response = client.get("/api/market_intel/mcp_activation_runbook") + data = response.get_json() + + assert response.status_code == 200 + assert data["mode"] == "mcp_activation_runbook_preview" + assert data["phase"] == "phase_31_mcp_activation_runbook_preview" + assert data["deployment_actions_executed"] is False + assert data["docker_command_executed"] is False + assert data["ssh_command_executed"] is False + assert data["database_write_executed"] is False + assert data["external_network_executed"] is False + assert data["scheduler_attached"] is False + + def test_mcp_deploy_preflight_blocks_without_required_env(): preflight = build_mcp_deploy_preflight_plan(env={}) @@ -712,7 +803,7 @@ def test_mcp_deploy_preflight_route_is_preview_only(): assert response.status_code == 200 assert data["mode"] == "mcp_external_deploy_preflight_preview" - assert data["phase"] == "phase_30_external_mcp_preflight_preview" + assert data["phase"] == "phase_31_mcp_activation_runbook_preview" assert data["deployment_actions_executed"] is False assert data["docker_command_executed"] is False assert data["ssh_command_executed"] is False @@ -727,7 +818,7 @@ def test_mcp_readiness_default_is_planned_only(monkeypatch): readiness = MarketIntelService().build_mcp_readiness() assert readiness["mode"] == "mcp_readiness_planned" - assert readiness["phase"] == "phase_30_external_mcp_preflight_preview" + assert readiness["phase"] == "phase_31_mcp_activation_runbook_preview" assert readiness["execute_requested"] is False assert readiness["router_enabled"] is False assert readiness["external_mcp_complete"] is False @@ -1112,6 +1203,7 @@ def test_deployment_readiness_reports_app_only_release_gate(): assert readiness["checks"]["mcp_readiness_planned_safe"] is True assert readiness["checks"]["mcp_tool_contract_ready"] is True assert readiness["checks"]["mcp_deploy_preflight_preview_safe"] is True + assert readiness["checks"]["mcp_activation_runbook_preview_safe"] is True assert readiness["checks"]["writer_plan_dry_run_only"] is True assert readiness["writer_plan_summary"]["writes_executed"] is False assert "readiness_checks_not_all_passed" not in readiness["blocked_reasons"] @@ -1130,6 +1222,7 @@ def test_deployment_readiness_reports_app_only_release_gate(): assert "/api/market_intel/mcp_readiness" in readiness["production_smoke_targets"] assert "/api/market_intel/mcp_tool_contract" in readiness["production_smoke_targets"] assert "/api/market_intel/mcp_deploy_preflight" in readiness["production_smoke_targets"] + assert "/api/market_intel/mcp_activation_runbook" in readiness["production_smoke_targets"] assert readiness["write_approval_runbook"]["ready_for_real_write"] is False assert readiness["write_approval_runbook"]["writes_executed"] is False assert readiness["migration_blueprint"]["migration_executed"] is False @@ -1144,6 +1237,8 @@ def test_deployment_readiness_reports_app_only_release_gate(): assert readiness["mcp_tool_contract"]["database_write_executed"] is False assert readiness["mcp_deploy_preflight"]["deployment_actions_executed"] is False assert readiness["mcp_deploy_preflight"]["docker_command_executed"] is False + assert readiness["mcp_activation_runbook"]["deployment_actions_executed"] is False + assert readiness["mcp_activation_runbook"]["docker_command_executed"] is False def test_write_approval_runbook_is_read_only_and_blocks_real_write():