feat(market-intel): add internal mcp contract
All checks were successful
CD Pipeline / deploy (push) Successful in 1m1s

This commit is contained in:
OoO
2026-05-18 14:42:25 +08:00
parent b8ca756090
commit 07b76870c9
9 changed files with 271 additions and 24 deletions

View File

@@ -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 4Coupang / Shopee Adapter

View File

@@ -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` |

View File

@@ -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():

View File

@@ -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,
}

View File

@@ -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,

View File

@@ -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(),
}

View File

@@ -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,

View File

@@ -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 = `
<div class="market-intel-empty mb-3">目前只做 MCP readiness planned preview不自動呼叫外部平台、不建立 DB session、不寫入 telemetry。${blockers ? `阻擋:${escapeHtml(blockers)}` : ''}</div>
@@ -929,7 +931,14 @@
</div>
<div class="market-intel-check">
<div>
<strong>market_intel_expected_tools</strong>
<strong>market_intel_contract</strong>
<small>${escapeHtml((toolContract.blocked_reasons || []).join(' / ') || 'ready')}</small>
</div>
<span>${toolContract.contract_ready ? 'READY' : 'PENDING'}</span>
</div>
<div class="market-intel-check">
<div>
<strong>expected_tool_names</strong>
<small>${escapeHtml(expectedTools.join(' / ') || 'none')}</small>
</div>
<span>${escapeHtml(data.market_intel_tool_count || 0)}</span>
@@ -941,6 +950,15 @@
</div>
<span>${telemetry.read_only_query_executed ? 'QUERY' : 'PLANNED'}</span>
</div>
${contractTools.map(item => `
<div class="market-intel-check">
<div>
<strong>${escapeHtml(item.name)}</strong>
<small>${escapeHtml(item.server)} / ${escapeHtml((item.router_tools || []).join(' + '))}</small>
</div>
<span>${item.router_tools_whitelisted ? 'ALLOW' : 'BLOCK'}</span>
</div>
`).join('')}
</div>
</div>
</div>

View File

@@ -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():