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 4163e6a..40fd183 100644 --- a/docs/adr/ADR-035-cross-platform-market-campaign-intelligence.md +++ b/docs/adr/ADR-035-cross-platform-market-campaign-intelligence.md @@ -153,6 +153,7 @@ EwoooC 目前已有 MOMO EDM / 節慶活動資料、`promo_products`、PChome - 2026-05-13 追加 platform seed CLI writer:`scripts/market_intel_seed_writer.py` 可在 CLI 明確帶入 `--execute`、`--apply-real-write` 與確認 token 時,以 SQLAlchemy Core 短 transaction upsert `market_platforms`;API 仍不得替使用者執行 DB 寫入,不建立 ORM session、不連外、不掛 scheduler。 - 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。 ### Phase 4:Coupang / Shopee Adapter diff --git a/routes/README.md b/routes/README.md index 06bd6aa..52933f7 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 28 MCP readiness 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/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 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` | | `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 5669287..73e0ad7 100644 --- a/routes/market_intel_routes.py +++ b/routes/market_intel_routes.py @@ -120,6 +120,12 @@ def market_intel_mcp_readiness(): ) +@market_intel_bp.route("/api/market_intel/mcp_tool_contract") +@login_required +def market_intel_mcp_tool_contract(): + return jsonify(_service().build_mcp_tool_contract()) + + @market_intel_bp.route("/api/market_intel/adapters") @login_required def market_intel_adapters(): diff --git a/services/market_intel/mcp_contract.py b/services/market_intel/mcp_contract.py new file mode 100644 index 0000000..6dd74c1 --- /dev/null +++ b/services/market_intel/mcp_contract.py @@ -0,0 +1,128 @@ +"""市場情報內部 MCP tool contract preview。 + +這裡只定義 market_intel 可使用的 read-only MCP 能力與白名單檢查; +不呼叫 MCP server、不寫 DB、不掛排程。 +""" + +EXPECTED_MARKET_INTEL_TOOLS = ( + "market_campaign_search", + "market_campaign_scrape", + "market_product_match_lookup", +) + + +MARKET_INTEL_MCP_TOOL_CONTRACT = ( + { + "name": "market_campaign_search", + "server": "omnisearch", + "router_tools": ("tavily_search", "exa_search"), + "purpose": "搜尋公開活動檔期入口與平台促銷資訊,僅產生候選來源。", + "read_only": True, + "database_write_allowed": False, + "scheduler_attach_allowed": False, + "external_network_required": True, + }, + { + "name": "market_campaign_scrape", + "server": "firecrawl", + "router_tools": ("scrape_url",), + "purpose": "抓取人工批准的公開活動頁內容,供 parser diagnostics 使用。", + "read_only": True, + "database_write_allowed": False, + "scheduler_attach_allowed": False, + "external_network_required": True, + }, + { + "name": "market_product_match_lookup", + "server": "postgres", + "router_tools": ("query",), + "purpose": "只讀查詢既有商品、比價與 market_* 表,輔助商品比對審核。", + "read_only": True, + "database_write_allowed": False, + "scheduler_attach_allowed": False, + "external_network_required": False, + }, +) + + +def get_market_intel_mcp_tool_contract(): + """回傳可序列化的 market_intel MCP tool contract。""" + return [ + { + **item, + "router_tools": list(item["router_tools"]), + } + for item in MARKET_INTEL_MCP_TOOL_CONTRACT + ] + + +def build_mcp_tool_contract_preview(tool_registry=None): + """檢查 market_intel MCP contract 是否已被 router 白名單覆蓋。""" + if tool_registry is None: + from services.mcp_router import TOOL_REGISTRY + + tool_registry = TOOL_REGISTRY + + registered = tool_registry.get("market_intel", {}) + tool_statuses = [] + for item in get_market_intel_mcp_tool_contract(): + registered_tools = set(registered.get(item["server"], [])) + expected_tools = set(item["router_tools"]) + missing_tools = sorted(expected_tools - registered_tools) + tool_statuses.append( + { + **item, + "registered_router_tools": sorted(registered_tools), + "missing_router_tools": missing_tools, + "router_tools_whitelisted": not missing_tools, + "write_risk": bool( + not item["read_only"] + or item["database_write_allowed"] + or item["scheduler_attach_allowed"] + ), + } + ) + + checks = { + "market_intel_caller_registered": "market_intel" in tool_registry, + "expected_tool_count_matched": len(tool_statuses) == len(EXPECTED_MARKET_INTEL_TOOLS), + "all_expected_tool_names_present": { + item["name"] for item in tool_statuses + } + == set(EXPECTED_MARKET_INTEL_TOOLS), + "all_router_tools_whitelisted": all( + item["router_tools_whitelisted"] + for item in tool_statuses + ), + "all_tools_read_only": all(item["read_only"] for item in tool_statuses), + "no_database_write_allowed": all( + not item["database_write_allowed"] + for item in tool_statuses + ), + "no_scheduler_attach_allowed": all( + not item["scheduler_attach_allowed"] + for item in tool_statuses + ), + } + blocked_reasons = [ + key for key, passed in checks.items() + if not passed + ] + + return { + "mode": "mcp_tool_contract_preview", + "caller": "market_intel", + "expected_tool_names": list(EXPECTED_MARKET_INTEL_TOOLS), + "tool_count": len(tool_statuses), + "tools": tool_statuses, + "checks": checks, + "contract_ready": not blocked_reasons, + "blocked_reasons": blocked_reasons, + "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 5802158..52c29b3 100644 --- a/services/market_intel/mcp_readiness.py +++ b/services/market_intel/mcp_readiness.py @@ -6,13 +6,13 @@ from sqlalchemy import create_engine, inspect, text +from services.market_intel.mcp_contract import ( + EXPECTED_MARKET_INTEL_TOOLS, + build_mcp_tool_contract_preview, +) + EXPECTED_EXTERNAL_SERVERS = ("postgres", "omnisearch", "firecrawl", "filesystem") -EXPECTED_MARKET_INTEL_TOOLS = ( - "market_campaign_search", - "market_campaign_scrape", - "market_product_match_lookup", -) def _planned_server_statuses(base_hosts): @@ -192,7 +192,8 @@ def build_mcp_readiness_plan( registered_callers = sorted(TOOL_REGISTRY.keys()) market_intel_tools = TOOL_REGISTRY.get("market_intel", {}) - market_intel_tool_count = sum(len(tools) for tools in market_intel_tools.values()) + tool_contract = build_mcp_tool_contract_preview(TOOL_REGISTRY) + market_intel_tool_count = int(tool_contract["tool_count"]) external_mcp_complete = bool( router_enabled and execute_requested @@ -200,14 +201,10 @@ def build_mcp_readiness_plan( and all(item["healthy"] for item in server_statuses) ) internal_mcp_complete = bool( - "market_intel" in TOOL_REGISTRY - and market_intel_tool_count >= len(EXPECTED_MARKET_INTEL_TOOLS) + tool_contract["contract_ready"] and telemetry.get("table_exists") ) - market_intel_mcp_integrated = bool( - "market_intel" in TOOL_REGISTRY - and market_intel_tool_count > 0 - ) + market_intel_mcp_integrated = bool(tool_contract["contract_ready"]) readiness_checks = { "mcp_router_module_present": True, @@ -216,8 +213,13 @@ def build_mcp_readiness_plan( "external_servers_all_healthy": all(item["healthy"] for item in server_statuses), "mcp_calls_table_exists": bool(telemetry.get("table_exists")), "base_callers_registered": {"mcp_collector", "hermes_analyst", "openclaw_strategist"} <= set(registered_callers), - "market_intel_caller_registered": "market_intel" in TOOL_REGISTRY, - "market_intel_tools_registered": market_intel_tool_count >= len(EXPECTED_MARKET_INTEL_TOOLS), + "market_intel_caller_registered": bool( + tool_contract["checks"]["market_intel_caller_registered"] + ), + "market_intel_tools_registered": bool( + tool_contract["checks"]["all_router_tools_whitelisted"] + ), + "market_intel_tool_contract_ready": bool(tool_contract["contract_ready"]), } blocked_reasons = [ key for key, passed in readiness_checks.items() @@ -238,6 +240,7 @@ def build_mcp_readiness_plan( "market_intel_tools": market_intel_tools, "market_intel_tool_count": market_intel_tool_count, "expected_market_intel_tools": list(EXPECTED_MARKET_INTEL_TOOLS), + "mcp_tool_contract": tool_contract, "telemetry": telemetry, "readiness_checks": readiness_checks, "database_session_created": False, diff --git a/services/market_intel/service.py b/services/market_intel/service.py index 4500316..eeb6dad 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_contract import build_mcp_tool_contract_preview 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 @@ -64,7 +65,7 @@ class MarketIntelRuntimeStatus: class MarketIntelService: """市場情報入口服務,先集中 feature gate 與安全狀態。""" - phase = "phase_28_mcp_readiness_preview" + phase = "phase_29_internal_mcp_contract_preview" def get_runtime_status(self) -> MarketIntelRuntimeStatus: return MarketIntelRuntimeStatus( @@ -321,6 +322,12 @@ class MarketIntelService: readiness["phase"] = self.phase return readiness + def build_mcp_tool_contract(self): + """回報市場情報內部 MCP tool contract;不呼叫 MCP、不查 DB。""" + contract = build_mcp_tool_contract_preview() + contract["phase"] = self.phase + return contract + 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) @@ -425,6 +432,9 @@ class MarketIntelService: "mcp_readiness_planned_safe": bool( self.build_mcp_readiness()["mode"] == "mcp_readiness_planned" ), + "mcp_tool_contract_ready": bool( + self.build_mcp_tool_contract()["contract_ready"] + ), } ready_for_production_deploy = all(checks.values()) blocked_reasons = [ @@ -549,6 +559,7 @@ class MarketIntelService: "/api/market_intel/platform_seed_db_diff", "/api/market_intel/legacy_source_bridge", "/api/market_intel/mcp_readiness", + "/api/market_intel/mcp_tool_contract", ], "status": status.to_dict(), "schema_smoke": schema_smoke, @@ -564,4 +575,5 @@ class MarketIntelService: "platform_seed_db_diff": self.build_platform_seed_db_diff(), "legacy_source_bridge": self.build_legacy_source_bridge(), "mcp_readiness": self.build_mcp_readiness(), + "mcp_tool_contract": self.build_mcp_tool_contract(), } diff --git a/services/mcp_router.py b/services/mcp_router.py index 91aac57..9675b4b 100644 --- a/services/mcp_router.py +++ b/services/mcp_router.py @@ -93,6 +93,12 @@ TOOL_REGISTRY: Dict[str, Dict[str, List[str]]] = { 'postgres': ['query'], 'omnisearch': ['tavily_search', 'exa_search'], }, + # 市場情報內部 MCP 合約:只允許公開搜尋、人工批准頁面 scrape、只讀查詢。 + 'market_intel': { + 'postgres': ['query'], + 'omnisearch': ['tavily_search', 'exa_search'], + 'firecrawl': ['scrape_url'], + }, # filesystem-mcp 僅掛載 /data、/logs read-only;保留給診斷工具讀檔,不開寫入類工具。 'ops_diagnostics': { 'filesystem': READONLY_FILESYSTEM_TOOLS, diff --git a/templates/market_intel/disabled.html b/templates/market_intel/disabled.html index bd33213..5dc4a4c 100644 --- a/templates/market_intel/disabled.html +++ b/templates/market_intel/disabled.html @@ -881,6 +881,8 @@ const checks = Object.entries(data.readiness_checks || {}); const expectedTools = data.expected_market_intel_tools || []; const registeredCallers = data.registered_callers || []; + const toolContract = data.mcp_tool_contract || {}; + const contractTools = toolContract.tools || []; const telemetry = data.telemetry || {}; mcpReadinessBody.innerHTML = `
目前只做 MCP readiness planned preview;不自動呼叫外部平台、不建立 DB session、不寫入 telemetry。${blockers ? `阻擋:${escapeHtml(blockers)}` : ''}
@@ -929,7 +931,14 @@
- market_intel_expected_tools + market_intel_contract + ${escapeHtml((toolContract.blocked_reasons || []).join(' / ') || 'ready')} +
+ ${toolContract.contract_ready ? 'READY' : 'PENDING'} +
+
+
+ expected_tool_names ${escapeHtml(expectedTools.join(' / ') || 'none')}
${escapeHtml(data.market_intel_tool_count || 0)} @@ -941,6 +950,15 @@
${telemetry.read_only_query_executed ? 'QUERY' : 'PLANNED'} + ${contractTools.map(item => ` +
+
+ ${escapeHtml(item.name)} + ${escapeHtml(item.server)} / ${escapeHtml((item.router_tools || []).join(' + '))} +
+ ${item.router_tools_whitelisted ? 'ALLOW' : 'BLOCK'} +
+ `).join('')} diff --git a/tests/test_market_intel_skeleton.py b/tests/test_market_intel_skeleton.py index 79848ee..a0173ba 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_contract import build_mcp_tool_contract_preview from services.market_intel.mcp_readiness import build_mcp_readiness_plan from services.market_intel.platform_seed_db_diff import build_platform_seed_db_diff_plan from services.market_intel.schema_db_probe import build_schema_db_probe_plan @@ -386,6 +387,7 @@ def test_market_intel_preview_template_uses_safe_fetch_false_endpoint(): assert "data-market-intel-mcp-servers" in template assert "data-market-intel-mcp-checks" in template assert "data-market-intel-mcp-tools" in template + assert "market_intel_contract" in template assert "data-market-intel-migration" in template assert "data-market-intel-migration-tables" in template assert "data-market-intel-approval" in template @@ -425,7 +427,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_28_mcp_readiness_preview" + assert bridge["phase"] == "phase_29_internal_mcp_contract_preview" assert bridge["execute_requested"] is False assert bridge["read_only_query_executed"] is False assert bridge["database_connection_opened"] is False @@ -579,18 +581,80 @@ def test_legacy_source_bridge_read_only_sqlite_counts_sources(): ) +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_29_internal_mcp_contract_preview" + assert contract["caller"] == "market_intel" + assert contract["contract_ready"] is True + assert contract["blocked_reasons"] == [] + assert contract["tool_count"] == 3 + assert contract["database_session_created"] is False + assert contract["database_write_executed"] is False + assert contract["database_commit_executed"] is False + assert contract["external_network_executed"] is False + assert contract["scheduler_attached"] is False + assert contract["writes_executed"] is False + assert contract["would_write_database"] is False + assert contract["checks"]["market_intel_caller_registered"] is True + assert contract["checks"]["all_router_tools_whitelisted"] is True + assert contract["checks"]["all_tools_read_only"] is True + assert contract["checks"]["no_database_write_allowed"] is True + assert contract["checks"]["no_scheduler_attach_allowed"] is True + assert {item["name"] for item in contract["tools"]} == { + "market_campaign_search", + "market_campaign_scrape", + "market_product_match_lookup", + } + assert all(item["router_tools_whitelisted"] is True for item in contract["tools"]) + assert all(item["write_risk"] is False for item in contract["tools"]) + + +def test_mcp_tool_contract_detects_missing_router_whitelist(): + contract = build_mcp_tool_contract_preview(tool_registry={}) + + assert contract["contract_ready"] is False + assert "market_intel_caller_registered" in contract["blocked_reasons"] + assert "all_router_tools_whitelisted" in contract["blocked_reasons"] + + +def test_mcp_tool_contract_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_tool_contract") + data = response.get_json() + + assert response.status_code == 200 + assert data["mode"] == "mcp_tool_contract_preview" + assert data["contract_ready"] is True + assert data["database_session_created"] is False + assert data["database_write_executed"] is False + assert data["database_commit_executed"] is False + assert data["external_network_executed"] is False + assert data["scheduler_attached"] is False + + def test_mcp_readiness_default_is_planned_only(monkeypatch): monkeypatch.delenv("MCP_ROUTER_ENABLED", raising=False) readiness = MarketIntelService().build_mcp_readiness() assert readiness["mode"] == "mcp_readiness_planned" - assert readiness["phase"] == "phase_28_mcp_readiness_preview" + assert readiness["phase"] == "phase_29_internal_mcp_contract_preview" assert readiness["execute_requested"] is False assert readiness["router_enabled"] is False assert readiness["external_mcp_complete"] is False assert readiness["internal_mcp_complete"] is False - assert readiness["market_intel_mcp_integrated"] is False + assert readiness["market_intel_mcp_integrated"] is True + assert readiness["mcp_tool_contract"]["contract_ready"] is True assert readiness["database_session_created"] is False assert readiness["database_write_executed"] is False assert readiness["database_commit_executed"] is False @@ -602,12 +666,15 @@ def test_mcp_readiness_default_is_planned_only(monkeypatch): assert readiness["telemetry"]["database_session_created"] is False assert readiness["telemetry"]["database_write_executed"] is False assert readiness["readiness_checks"]["base_callers_registered"] is True - assert readiness["readiness_checks"]["market_intel_caller_registered"] is False - assert readiness["readiness_checks"]["market_intel_tools_registered"] is False + assert readiness["readiness_checks"]["market_intel_caller_registered"] is True + assert readiness["readiness_checks"]["market_intel_tools_registered"] is True + assert readiness["readiness_checks"]["market_intel_tool_contract_ready"] is True assert len(readiness["server_statuses"]) == 4 assert all(item["health_checked"] is False for item in readiness["server_statuses"]) assert all(item["healthy"] is False for item in readiness["server_statuses"]) assert "execute_false_planned_only" in readiness["blocked_reasons"] + assert "market_intel_caller_registered" not in readiness["blocked_reasons"] + assert "market_intel_tools_registered" not in readiness["blocked_reasons"] def test_mcp_readiness_sqlite_read_only_counts_telemetry(monkeypatch): @@ -689,7 +756,9 @@ def test_mcp_readiness_sqlite_read_only_counts_telemetry(monkeypatch): assert readiness["execute_requested"] is True assert readiness["router_enabled"] is False assert readiness["external_mcp_complete"] is False - assert readiness["internal_mcp_complete"] is False + assert readiness["internal_mcp_complete"] is True + assert readiness["market_intel_mcp_integrated"] is True + assert readiness["mcp_tool_contract"]["contract_ready"] is True assert readiness["telemetry"]["mode"] == "mcp_telemetry_read_only" assert readiness["telemetry"]["table_exists"] is True assert readiness["telemetry"]["total_calls"] == 3 @@ -710,7 +779,7 @@ def test_mcp_readiness_sqlite_read_only_counts_telemetry(monkeypatch): "postgres": 1, } assert "mcp_router_enabled" in readiness["blocked_reasons"] - assert "market_intel_caller_registered" in readiness["blocked_reasons"] + assert "market_intel_caller_registered" not in readiness["blocked_reasons"] def test_market_intel_schema_smoke_checks_platform_columns(): @@ -962,6 +1031,7 @@ def test_deployment_readiness_reports_app_only_release_gate(): assert readiness["checks"]["platform_seed_db_diff_planned_safe"] is True assert readiness["checks"]["legacy_source_bridge_planned_safe"] is True assert readiness["checks"]["mcp_readiness_planned_safe"] is True + assert readiness["checks"]["mcp_tool_contract_ready"] 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"] @@ -978,6 +1048,7 @@ def test_deployment_readiness_reports_app_only_release_gate(): assert "/api/market_intel/platform_seed_db_diff" in readiness["production_smoke_targets"] assert "/api/market_intel/legacy_source_bridge" in readiness["production_smoke_targets"] assert "/api/market_intel/mcp_readiness" in readiness["production_smoke_targets"] + assert "/api/market_intel/mcp_tool_contract" 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 @@ -988,6 +1059,8 @@ def test_deployment_readiness_reports_app_only_release_gate(): assert readiness["legacy_source_bridge"]["read_only_query_executed"] is False assert readiness["mcp_readiness"]["mode"] == "mcp_readiness_planned" assert readiness["mcp_readiness"]["telemetry"]["read_only_query_executed"] is False + assert readiness["mcp_tool_contract"]["contract_ready"] is True + assert readiness["mcp_tool_contract"]["database_write_executed"] is False def test_write_approval_runbook_is_read_only_and_blocks_real_write():