feat(market-intel): add internal mcp contract
All checks were successful
CD Pipeline / deploy (push) Successful in 1m1s
All checks were successful
CD Pipeline / deploy (push) Successful in 1m1s
This commit is contained in:
128
services/market_intel/mcp_contract.py
Normal file
128
services/market_intel/mcp_contract.py
Normal 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,
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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(),
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user