feat(mcp): run governed version lifecycle receipts
This commit is contained in:
@@ -0,0 +1,141 @@
|
||||
-- Durable external MCP version lifecycle receipts.
|
||||
--
|
||||
-- Safety contract:
|
||||
-- * catalog metadata only; no package install, tool call, credential, raw page,
|
||||
-- request body, response body, or RAG content is stored;
|
||||
-- * additive schema only, so application rollback does not delete evidence;
|
||||
-- * every observation is bound to one run_id / trace_id / work_item_id;
|
||||
-- * tenant RLS remains fail-closed through app.project_id.
|
||||
|
||||
BEGIN;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS awooop_mcp_version_lifecycle_run (
|
||||
run_id UUID PRIMARY KEY,
|
||||
trace_id VARCHAR(128) NOT NULL,
|
||||
work_item_id VARCHAR(128) NOT NULL,
|
||||
project_id VARCHAR(64) NOT NULL
|
||||
REFERENCES awooop_projects(project_id) ON DELETE RESTRICT,
|
||||
trigger_kind VARCHAR(32) NOT NULL,
|
||||
status VARCHAR(48) NOT NULL,
|
||||
candidate_count INTEGER NOT NULL DEFAULT 0,
|
||||
observed_count INTEGER NOT NULL DEFAULT 0,
|
||||
baseline_count INTEGER NOT NULL DEFAULT 0,
|
||||
changed_count INTEGER NOT NULL DEFAULT 0,
|
||||
policy_blocked_count INTEGER NOT NULL DEFAULT 0,
|
||||
error_count INTEGER NOT NULL DEFAULT 0,
|
||||
receipt JSONB NOT NULL DEFAULT '{}'::jsonb,
|
||||
started_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
ended_at TIMESTAMPTZ,
|
||||
|
||||
CONSTRAINT uix_mcp_version_lifecycle_trace
|
||||
UNIQUE (project_id, trace_id),
|
||||
CONSTRAINT chk_mcp_version_lifecycle_trigger
|
||||
CHECK (trigger_kind IN ('startup','scheduled','operator_replay','test')),
|
||||
CONSTRAINT chk_mcp_version_lifecycle_status
|
||||
CHECK (status IN (
|
||||
'running',
|
||||
'completed_no_change',
|
||||
'completed_no_write_policy_blocked',
|
||||
'partial_degraded',
|
||||
'failed'
|
||||
)),
|
||||
CONSTRAINT chk_mcp_version_lifecycle_counts
|
||||
CHECK (
|
||||
candidate_count >= 0 AND observed_count >= 0 AND baseline_count >= 0
|
||||
AND changed_count >= 0 AND policy_blocked_count >= 0
|
||||
AND error_count >= 0
|
||||
),
|
||||
CONSTRAINT chk_mcp_version_lifecycle_terminal_time
|
||||
CHECK (
|
||||
(status = 'running' AND ended_at IS NULL)
|
||||
OR (status <> 'running' AND ended_at IS NOT NULL)
|
||||
)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS awooop_mcp_version_observation (
|
||||
observation_id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
run_id UUID NOT NULL
|
||||
REFERENCES awooop_mcp_version_lifecycle_run(run_id) ON DELETE RESTRICT,
|
||||
trace_id VARCHAR(128) NOT NULL,
|
||||
work_item_id VARCHAR(128) NOT NULL,
|
||||
project_id VARCHAR(64) NOT NULL
|
||||
REFERENCES awooop_projects(project_id) ON DELETE RESTRICT,
|
||||
candidate_id VARCHAR(160) NOT NULL,
|
||||
catalog_source VARCHAR(32) NOT NULL,
|
||||
source_url TEXT NOT NULL,
|
||||
source_url_sha256 VARCHAR(64) NOT NULL,
|
||||
http_status INTEGER,
|
||||
content_sha256 VARCHAR(64),
|
||||
observed_version VARCHAR(128),
|
||||
previous_version VARCHAR(128),
|
||||
discovery_status VARCHAR(32) NOT NULL,
|
||||
change_state VARCHAR(32) NOT NULL,
|
||||
lifecycle_terminal VARCHAR(48) NOT NULL,
|
||||
policy_decision JSONB NOT NULL DEFAULT '{}'::jsonb,
|
||||
stage_receipts JSONB NOT NULL DEFAULT '{}'::jsonb,
|
||||
error_class VARCHAR(128),
|
||||
observed_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
|
||||
CONSTRAINT uix_mcp_version_observation_run_candidate
|
||||
UNIQUE (run_id, candidate_id),
|
||||
CONSTRAINT chk_mcp_version_catalog_source
|
||||
CHECK (catalog_source IN ('smithery','mcp-so')),
|
||||
CONSTRAINT chk_mcp_version_source_url_hash
|
||||
CHECK (source_url_sha256 ~ '^[0-9a-f]{64}$'),
|
||||
CONSTRAINT chk_mcp_version_content_hash
|
||||
CHECK (content_sha256 IS NULL OR content_sha256 ~ '^[0-9a-f]{64}$'),
|
||||
CONSTRAINT chk_mcp_version_discovery_status
|
||||
CHECK (discovery_status IN ('observed','source_error','policy_blocked')),
|
||||
CONSTRAINT chk_mcp_version_change_state
|
||||
CHECK (change_state IN (
|
||||
'baseline_created','no_change','version_changed',
|
||||
'content_changed','source_unavailable'
|
||||
)),
|
||||
CONSTRAINT chk_mcp_version_terminal
|
||||
CHECK (lifecycle_terminal IN (
|
||||
'no_write_baseline','no_write_no_change','no_write_policy_blocked',
|
||||
'no_write_source_error','replay_passed_canary_pending',
|
||||
'promoted','rolled_back'
|
||||
))
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_mcp_version_lifecycle_recent
|
||||
ON awooop_mcp_version_lifecycle_run (project_id, started_at DESC);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_mcp_version_observation_candidate_recent
|
||||
ON awooop_mcp_version_observation
|
||||
(project_id, candidate_id, observed_at DESC);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_mcp_version_observation_run
|
||||
ON awooop_mcp_version_observation (project_id, run_id, observed_at);
|
||||
|
||||
ALTER TABLE awooop_mcp_version_lifecycle_run ENABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE awooop_mcp_version_lifecycle_run FORCE ROW LEVEL SECURITY;
|
||||
ALTER TABLE awooop_mcp_version_observation ENABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE awooop_mcp_version_observation FORCE ROW LEVEL SECURITY;
|
||||
|
||||
DROP POLICY IF EXISTS mcp_version_lifecycle_run_tenant
|
||||
ON awooop_mcp_version_lifecycle_run;
|
||||
CREATE POLICY mcp_version_lifecycle_run_tenant
|
||||
ON awooop_mcp_version_lifecycle_run
|
||||
USING (project_id = current_setting('app.project_id', TRUE))
|
||||
WITH CHECK (project_id = current_setting('app.project_id', TRUE));
|
||||
|
||||
DROP POLICY IF EXISTS mcp_version_observation_tenant
|
||||
ON awooop_mcp_version_observation;
|
||||
CREATE POLICY mcp_version_observation_tenant
|
||||
ON awooop_mcp_version_observation
|
||||
USING (project_id = current_setting('app.project_id', TRUE))
|
||||
WITH CHECK (project_id = current_setting('app.project_id', TRUE));
|
||||
|
||||
GRANT SELECT, INSERT, UPDATE ON awooop_mcp_version_lifecycle_run TO awooop_app;
|
||||
GRANT SELECT, INSERT ON awooop_mcp_version_observation TO awooop_app;
|
||||
GRANT SELECT, INSERT, UPDATE ON awooop_mcp_version_lifecycle_run TO awoooi;
|
||||
GRANT SELECT, INSERT ON awooop_mcp_version_observation TO awoooi;
|
||||
|
||||
COMMENT ON TABLE awooop_mcp_version_lifecycle_run IS
|
||||
'Durable controlled-apply lifecycle run receipts; additive and rollback-safe.';
|
||||
COMMENT ON TABLE awooop_mcp_version_observation IS
|
||||
'Normalized external MCP catalog observations; raw external content is forbidden.';
|
||||
|
||||
COMMIT;
|
||||
@@ -831,6 +831,33 @@ class Settings(BaseSettings):
|
||||
"dedicated signal worker."
|
||||
),
|
||||
)
|
||||
ENABLE_MCP_VERSION_LIFECYCLE_WORKER: bool = Field(
|
||||
default=True,
|
||||
description=(
|
||||
"True=run the fail-closed external MCP catalog observation and version "
|
||||
"lifecycle controller in the dedicated signal worker. The controller "
|
||||
"never installs or promotes an external artifact without immutable "
|
||||
"supply-chain and replay receipts."
|
||||
),
|
||||
)
|
||||
MCP_VERSION_LIFECYCLE_INTERVAL_SECONDS: int = Field(
|
||||
default=21_600,
|
||||
ge=900,
|
||||
le=86_400,
|
||||
description="External MCP discovery cadence; defaults to every six hours.",
|
||||
)
|
||||
MCP_VERSION_LIFECYCLE_STARTUP_SLEEP_SECONDS: int = Field(
|
||||
default=30,
|
||||
ge=0,
|
||||
le=900,
|
||||
description="Delay before the first MCP version lifecycle observation run.",
|
||||
)
|
||||
MCP_VERSION_LIFECYCLE_SOURCE_TIMEOUT_SECONDS: int = Field(
|
||||
default=12,
|
||||
ge=3,
|
||||
le=30,
|
||||
description="Bounded timeout for one allowlisted Smithery or mcp.so read.",
|
||||
)
|
||||
AWOOOP_ANSIBLE_CANDIDATE_BACKFILL_INTERVAL_SECONDS: int = Field(
|
||||
default=600,
|
||||
ge=60,
|
||||
|
||||
102
apps/api/src/jobs/mcp_version_lifecycle_job.py
Normal file
102
apps/api/src/jobs/mcp_version_lifecycle_job.py
Normal file
@@ -0,0 +1,102 @@
|
||||
"""Scheduled owner for the durable external MCP version lifecycle controller."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import uuid
|
||||
from typing import Any
|
||||
|
||||
import structlog
|
||||
|
||||
from src.core.config import settings
|
||||
from src.core.redis_client import get_redis
|
||||
from src.services.mcp_control_plane_service import load_mcp_control_plane_catalog
|
||||
from src.services.mcp_version_lifecycle_service import (
|
||||
run_mcp_version_lifecycle_once,
|
||||
)
|
||||
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
_LOCK_KEY = "awoooi:mcp:version-lifecycle:leader"
|
||||
_LOCK_TTL_SECONDS = 900
|
||||
_RELEASE_LOCK_LUA = """
|
||||
if redis.call('GET', KEYS[1]) == ARGV[1] then
|
||||
return redis.call('DEL', KEYS[1])
|
||||
end
|
||||
return 0
|
||||
"""
|
||||
|
||||
|
||||
async def run_mcp_version_lifecycle_loop() -> None:
|
||||
"""Run once after startup, then every configured six-hour interval."""
|
||||
interval = settings.MCP_VERSION_LIFECYCLE_INTERVAL_SECONDS
|
||||
startup_sleep = settings.MCP_VERSION_LIFECYCLE_STARTUP_SLEEP_SECONDS
|
||||
logger.info(
|
||||
"mcp_version_lifecycle_loop_started",
|
||||
interval_seconds=interval,
|
||||
startup_sleep_seconds=startup_sleep,
|
||||
owner="signal_worker",
|
||||
lock_backend="redis",
|
||||
)
|
||||
await asyncio.sleep(startup_sleep)
|
||||
trigger_kind = "startup"
|
||||
while True:
|
||||
try:
|
||||
await run_mcp_version_lifecycle_if_leader(trigger_kind=trigger_kind)
|
||||
except asyncio.CancelledError:
|
||||
raise
|
||||
except Exception as exc:
|
||||
logger.exception(
|
||||
"mcp_version_lifecycle_loop_error",
|
||||
error_type=type(exc).__name__,
|
||||
external_install_performed=False,
|
||||
external_tool_call_performed=False,
|
||||
external_rag_write_performed=False,
|
||||
)
|
||||
trigger_kind = "scheduled"
|
||||
await asyncio.sleep(interval)
|
||||
|
||||
|
||||
async def run_mcp_version_lifecycle_if_leader(
|
||||
*,
|
||||
trigger_kind: str,
|
||||
) -> dict[str, Any]:
|
||||
"""Acquire one atomic lease so rolling replicas cannot duplicate a run."""
|
||||
redis_client = get_redis()
|
||||
lease_token = str(uuid.uuid4())
|
||||
acquired = await redis_client.set(
|
||||
_LOCK_KEY,
|
||||
lease_token,
|
||||
nx=True,
|
||||
ex=_LOCK_TTL_SECONDS,
|
||||
)
|
||||
if not acquired:
|
||||
logger.info(
|
||||
"mcp_version_lifecycle_run_skipped_existing_leader",
|
||||
trigger_kind=trigger_kind,
|
||||
)
|
||||
return {
|
||||
"status": "skipped_existing_leader",
|
||||
"trigger_kind": trigger_kind,
|
||||
"external_write_performed": False,
|
||||
}
|
||||
|
||||
try:
|
||||
catalog = await asyncio.to_thread(load_mcp_control_plane_catalog)
|
||||
return await run_mcp_version_lifecycle_once(
|
||||
catalog,
|
||||
trigger_kind=trigger_kind,
|
||||
)
|
||||
finally:
|
||||
try:
|
||||
await redis_client.eval(
|
||||
_RELEASE_LOCK_LUA,
|
||||
1,
|
||||
_LOCK_KEY,
|
||||
lease_token,
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.warning(
|
||||
"mcp_version_lifecycle_lock_release_failed",
|
||||
error_type=type(exc).__name__,
|
||||
)
|
||||
@@ -20,6 +20,9 @@ from sqlalchemy import text
|
||||
from src.db.base import get_db_context
|
||||
from src.plugins.mcp.registry import get_provider_registry
|
||||
from src.services.mcp_tool_registry import get_mcp_tool_registry
|
||||
from src.services.mcp_version_lifecycle_service import (
|
||||
collect_mcp_version_lifecycle_readback,
|
||||
)
|
||||
from src.services.snapshot_paths import default_operations_dir
|
||||
|
||||
logger = structlog.get_logger(__name__)
|
||||
@@ -81,9 +84,7 @@ def build_mcp_control_plane_overview(
|
||||
)
|
||||
|
||||
capability_rows = _dict_rows(catalog.get("capabilities"))
|
||||
capability_index = {
|
||||
str(row["capability_id"]): row for row in capability_rows
|
||||
}
|
||||
capability_index = {str(row["capability_id"]): row for row in capability_rows}
|
||||
products: list[dict[str, Any]] = []
|
||||
for desired in _dict_rows(catalog.get("products")):
|
||||
product_id = str(desired["product_id"])
|
||||
@@ -143,6 +144,17 @@ def build_mcp_control_plane_overview(
|
||||
"sources": 0,
|
||||
},
|
||||
}
|
||||
lifecycle_runtime = runtime_payload.get("version_lifecycle")
|
||||
lifecycle_runtime = lifecycle_runtime if isinstance(lifecycle_runtime, dict) else {}
|
||||
version_lifecycle = dict(catalog["version_lifecycle"])
|
||||
version_lifecycle["runtime"] = lifecycle_runtime
|
||||
lifecycle_status = str(lifecycle_runtime.get("status") or "not_requested")
|
||||
if lifecycle_status == "ready" and lifecycle_runtime.get("latest_run"):
|
||||
version_lifecycle["runtime_state"] = "running_fail_closed"
|
||||
elif lifecycle_status == "pending_first_run":
|
||||
version_lifecycle["runtime_state"] = "scheduled_pending_first_receipt"
|
||||
elif lifecycle_status == "degraded":
|
||||
version_lifecycle["runtime_state"] = "degraded_with_safe_next_action"
|
||||
external_rows = [
|
||||
row for row in capability_rows if row.get("source_type") == "external"
|
||||
]
|
||||
@@ -153,7 +165,10 @@ def build_mcp_control_plane_overview(
|
||||
1
|
||||
for row in products
|
||||
if row["inventory_state"]
|
||||
not in {"no_committed_mcp_runtime_detected", "no_federated_mcp_inventory_readback"}
|
||||
not in {
|
||||
"no_committed_mcp_runtime_detected",
|
||||
"no_federated_mcp_inventory_readback",
|
||||
}
|
||||
)
|
||||
quarantined_products = sum(
|
||||
1
|
||||
@@ -162,7 +177,8 @@ def build_mcp_control_plane_overview(
|
||||
or "remediation_required" in row["inventory_state"]
|
||||
)
|
||||
|
||||
active_blockers = set(_strings(catalog["version_lifecycle"].get("active_blockers")))
|
||||
active_blockers = set(_strings(version_lifecycle.get("active_blockers")))
|
||||
active_blockers.update(_strings(lifecycle_runtime.get("blockers")))
|
||||
active_blockers.update(
|
||||
{
|
||||
"cross_product_runtime_federation_receipts_missing",
|
||||
@@ -171,7 +187,11 @@ def build_mcp_control_plane_overview(
|
||||
"distributed_gateway_rate_limit_receipt_missing",
|
||||
}
|
||||
)
|
||||
rag = runtime_payload.get("rag") if isinstance(runtime_payload.get("rag"), dict) else {}
|
||||
rag = (
|
||||
runtime_payload.get("rag")
|
||||
if isinstance(runtime_payload.get("rag"), dict)
|
||||
else {}
|
||||
)
|
||||
if rag.get("total_chunks") == 0:
|
||||
active_blockers.add("internal_rag_index_empty")
|
||||
if runtime_payload.get("status") not in {"ready", "not_requested"}:
|
||||
@@ -208,7 +228,7 @@ def build_mcp_control_plane_overview(
|
||||
"architecture": catalog["architecture"],
|
||||
"security_policy": catalog["security_policy"],
|
||||
"supply_chain_policy": catalog["supply_chain_policy"],
|
||||
"version_lifecycle": catalog["version_lifecycle"],
|
||||
"version_lifecycle": version_lifecycle,
|
||||
"runtime": runtime_payload,
|
||||
"catalog_sources": catalog["source_policy"]["catalog_sources"],
|
||||
"capabilities": capability_rows,
|
||||
@@ -218,7 +238,7 @@ def build_mcp_control_plane_overview(
|
||||
"federate_ewoooc_and_momo_read_only_inventory_health_and_version_receipts",
|
||||
"disable_agent_bounty_github_and_prompt_injection_tool_path",
|
||||
"remove_bitan_github_external_provider_from_effective_policy",
|
||||
"implement_durable_version_discovery_compatibility_canary_and_rollback_controller",
|
||||
"promote_first_internally_mirrored_candidate_through_replay_shadow_and_canary",
|
||||
"mirror_and_pin_each_approved_external_candidate_before_shadow",
|
||||
"wire_quarantine_scan_citation_verifier_and_governed_rag_promotion",
|
||||
],
|
||||
@@ -227,6 +247,7 @@ def build_mcp_control_plane_overview(
|
||||
"public_response_redacted_aggregate_only": True,
|
||||
"mutation_endpoint_exposed": False,
|
||||
"operator_auth_required_for_future_mutation": True,
|
||||
"normalized_version_receipt_write_allowed": True,
|
||||
"external_install_allowed": False,
|
||||
"external_tool_call_allowed": False,
|
||||
"external_rag_write_allowed": False,
|
||||
@@ -238,7 +259,7 @@ def build_mcp_control_plane_overview(
|
||||
"source_refs": {
|
||||
"catalog": f"docs/operations/{_CATALOG_FILE}",
|
||||
"product_matrix": "GET /api/v1/agents/product-governance-matrix-readback",
|
||||
"runtime": "live provider registry, MCP tool registry, gateway audit aggregate, and RAG stats",
|
||||
"runtime": "live provider registry, MCP tool registry, gateway audit aggregate, RAG stats, and durable version lifecycle receipts",
|
||||
},
|
||||
}
|
||||
|
||||
@@ -246,6 +267,9 @@ def build_mcp_control_plane_overview(
|
||||
async def collect_mcp_control_plane_runtime() -> dict[str, Any]:
|
||||
"""Collect aggregate-only runtime evidence without request or response bodies."""
|
||||
blockers: list[str] = []
|
||||
lifecycle_payload = await collect_mcp_version_lifecycle_readback()
|
||||
if lifecycle_payload.get("status") == "degraded":
|
||||
blockers.append("version_lifecycle_runtime_readback_degraded")
|
||||
try:
|
||||
provider_registry = get_provider_registry()
|
||||
tool_registry = get_mcp_tool_registry()
|
||||
@@ -351,6 +375,7 @@ async def collect_mcp_control_plane_runtime() -> dict[str, Any]:
|
||||
"provider_registry": provider_payload,
|
||||
"gateway_audit_24h": gateway_payload,
|
||||
"rag": rag_payload,
|
||||
"version_lifecycle": lifecycle_payload,
|
||||
"federated_product_runtime_receipt_count": 0,
|
||||
"federated_product_runtime_receipt_expected": 12,
|
||||
"blockers": sorted(blockers),
|
||||
@@ -361,7 +386,10 @@ def _validate_catalog(payload: dict[str, Any], label: str) -> None:
|
||||
if payload.get("schema_version") != CATALOG_SCHEMA_VERSION:
|
||||
raise ValueError(f"{label}: invalid schema_version")
|
||||
architecture = payload.get("architecture") or {}
|
||||
if architecture.get("decision") != "single_control_plane_reuse_existing_gateway_registry_rag":
|
||||
if (
|
||||
architecture.get("decision")
|
||||
!= "single_control_plane_reuse_existing_gateway_registry_rag"
|
||||
):
|
||||
raise ValueError(f"{label}: duplicate gateway/RAG architecture is forbidden")
|
||||
if architecture.get("separate_gateway_allowed") is not False:
|
||||
raise ValueError(f"{label}: separate gateway must remain false")
|
||||
@@ -397,7 +425,12 @@ def _validate_catalog(payload: dict[str, Any], label: str) -> None:
|
||||
continue
|
||||
if row.get("deployment_allowed") is True and any(
|
||||
row.get(field) in {None, "", "missing", "unresolved"}
|
||||
for field in ("version_state", "digest_state", "sbom_state", "signature_state")
|
||||
for field in (
|
||||
"version_state",
|
||||
"digest_state",
|
||||
"sbom_state",
|
||||
"signature_state",
|
||||
)
|
||||
):
|
||||
raise ValueError(
|
||||
f"{label}: external capability {row.get('capability_id')} cannot deploy without immutable receipts"
|
||||
|
||||
935
apps/api/src/services/mcp_version_lifecycle_service.py
Normal file
935
apps/api/src/services/mcp_version_lifecycle_service.py
Normal file
@@ -0,0 +1,935 @@
|
||||
"""Durable, fail-closed lifecycle receipts for external MCP candidates.
|
||||
|
||||
The controller reads only committed Smithery and mcp.so catalog URLs. It stores
|
||||
normalized metadata and hashes, never raw external content. A version/content
|
||||
change cannot reach replay, canary, rollout, or promotion unless immutable
|
||||
supply-chain receipts and a registered internal replay adapter are present.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import hashlib
|
||||
import html
|
||||
import json
|
||||
import re
|
||||
import uuid
|
||||
from collections.abc import Awaitable, Callable
|
||||
from dataclasses import dataclass
|
||||
from datetime import UTC, datetime
|
||||
from html.parser import HTMLParser
|
||||
from typing import Any, Protocol
|
||||
from urllib.parse import urljoin, urlparse
|
||||
|
||||
import httpx
|
||||
import structlog
|
||||
from sqlalchemy import text
|
||||
|
||||
from src.core.config import settings
|
||||
from src.core.http_client import get_general_client
|
||||
from src.db.base import get_db_context
|
||||
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
PROJECT_ID = "awoooi"
|
||||
SCHEMA_VERSION = "mcp_version_lifecycle_runtime_v1"
|
||||
_MAX_SOURCE_BYTES = 2_000_000
|
||||
_MAX_REDIRECTS = 2
|
||||
_MAX_CONCURRENT_FETCHES = 3
|
||||
_SOURCE_HOSTS = {
|
||||
"mcp-so": frozenset({"mcp.so", "www.mcp.so"}),
|
||||
"smithery": frozenset({"smithery.ai", "www.smithery.ai"}),
|
||||
}
|
||||
_VERSION_PATTERNS = (
|
||||
re.compile(
|
||||
rb"""(?ix)["'](?:version|latestVersion|packageVersion)["']\s*[:=]\s*["']v?"""
|
||||
rb"""([0-9]+\.[0-9]+\.[0-9]+(?:[-+][0-9A-Za-z.-]+)?)["']"""
|
||||
),
|
||||
re.compile(
|
||||
rb"""(?ix)data-(?:version|package-version)=["']v?"""
|
||||
rb"""([0-9]+\.[0-9]+\.[0-9]+(?:[-+][0-9A-Za-z.-]+)?)["']"""
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class CatalogFetchReceipt:
|
||||
"""Bounded source observation. The raw body is never persisted."""
|
||||
|
||||
status: str
|
||||
source_url: str
|
||||
http_status: int | None = None
|
||||
body: bytes = b""
|
||||
error_class: str | None = None
|
||||
redirect_count: int = 0
|
||||
|
||||
|
||||
CatalogFetcher = Callable[[str, str, int], Awaitable[CatalogFetchReceipt]]
|
||||
|
||||
|
||||
class _StableMetadataParser(HTMLParser):
|
||||
"""Extract stable public metadata while ignoring randomized page sections."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
super().__init__(convert_charrefs=True)
|
||||
self.in_title = False
|
||||
self.title_parts: list[str] = []
|
||||
self.metadata: dict[str, str] = {}
|
||||
|
||||
def handle_starttag(
|
||||
self,
|
||||
tag: str,
|
||||
attrs: list[tuple[str, str | None]],
|
||||
) -> None:
|
||||
attributes = {key.lower(): value or "" for key, value in attrs}
|
||||
if tag.lower() == "title":
|
||||
self.in_title = True
|
||||
return
|
||||
if tag.lower() == "meta":
|
||||
key = (attributes.get("name") or attributes.get("property") or "").lower()
|
||||
if key in {"description", "og:title", "og:description"}:
|
||||
self.metadata[key] = attributes.get("content", "")[:4_000]
|
||||
return
|
||||
if tag.lower() == "link" and "canonical" in attributes.get("rel", "").lower():
|
||||
self.metadata["canonical"] = attributes.get("href", "")[:2_000]
|
||||
|
||||
def handle_endtag(self, tag: str) -> None:
|
||||
if tag.lower() == "title":
|
||||
self.in_title = False
|
||||
|
||||
def handle_data(self, data: str) -> None:
|
||||
if self.in_title:
|
||||
self.title_parts.append(data)
|
||||
|
||||
def stable_fields(self) -> dict[str, str]:
|
||||
title = _clean_metadata_text(" ".join(self.title_parts))
|
||||
fields = {
|
||||
key: _clean_metadata_text(value)
|
||||
for key, value in self.metadata.items()
|
||||
if _clean_metadata_text(value)
|
||||
}
|
||||
if title:
|
||||
fields["title"] = title
|
||||
return fields
|
||||
|
||||
|
||||
class LifecycleRepository(Protocol):
|
||||
"""Persistence boundary used by production PostgreSQL and unit-test fakes."""
|
||||
|
||||
async def start_run(self, payload: dict[str, Any]) -> None:
|
||||
...
|
||||
|
||||
async def latest_observations(self) -> dict[str, dict[str, Any]]:
|
||||
...
|
||||
|
||||
async def save_observation(self, payload: dict[str, Any]) -> None:
|
||||
...
|
||||
|
||||
async def finish_run(self, payload: dict[str, Any]) -> None:
|
||||
...
|
||||
|
||||
|
||||
class PostgresLifecycleRepository:
|
||||
"""PostgreSQL repository with project-scoped RLS context."""
|
||||
|
||||
async def start_run(self, payload: dict[str, Any]) -> None:
|
||||
async with get_db_context(PROJECT_ID) as db:
|
||||
await db.execute(
|
||||
text(
|
||||
"""
|
||||
INSERT INTO awooop_mcp_version_lifecycle_run (
|
||||
run_id, trace_id, work_item_id, project_id, trigger_kind,
|
||||
status, candidate_count, receipt, started_at
|
||||
) VALUES (
|
||||
:run_id, :trace_id, :work_item_id, :project_id,
|
||||
:trigger_kind, 'running', :candidate_count,
|
||||
CAST(:receipt AS jsonb), :started_at
|
||||
)
|
||||
"""
|
||||
),
|
||||
{
|
||||
**payload,
|
||||
"receipt": _json(payload["receipt"]),
|
||||
},
|
||||
)
|
||||
|
||||
async def latest_observations(self) -> dict[str, dict[str, Any]]:
|
||||
async with get_db_context(PROJECT_ID) as db:
|
||||
result = await db.execute(
|
||||
text(
|
||||
"""
|
||||
SELECT DISTINCT ON (candidate_id)
|
||||
candidate_id, observed_version, content_sha256,
|
||||
discovery_status, observed_at
|
||||
FROM awooop_mcp_version_observation
|
||||
WHERE project_id = :project_id
|
||||
AND discovery_status = 'observed'
|
||||
ORDER BY candidate_id, observed_at DESC
|
||||
"""
|
||||
),
|
||||
{"project_id": PROJECT_ID},
|
||||
)
|
||||
return {
|
||||
str(row["candidate_id"]): dict(row) for row in result.mappings().all()
|
||||
}
|
||||
|
||||
async def save_observation(self, payload: dict[str, Any]) -> None:
|
||||
async with get_db_context(PROJECT_ID) as db:
|
||||
await db.execute(
|
||||
text(
|
||||
"""
|
||||
INSERT INTO awooop_mcp_version_observation (
|
||||
run_id, trace_id, work_item_id, project_id, candidate_id,
|
||||
catalog_source, source_url, source_url_sha256,
|
||||
http_status, content_sha256, observed_version,
|
||||
previous_version, discovery_status, change_state,
|
||||
lifecycle_terminal, policy_decision, stage_receipts,
|
||||
error_class, observed_at
|
||||
) VALUES (
|
||||
:run_id, :trace_id, :work_item_id, :project_id,
|
||||
:candidate_id, :catalog_source, :source_url,
|
||||
:source_url_sha256, :http_status, :content_sha256,
|
||||
:observed_version, :previous_version, :discovery_status,
|
||||
:change_state, :lifecycle_terminal,
|
||||
CAST(:policy_decision AS jsonb),
|
||||
CAST(:stage_receipts AS jsonb), :error_class,
|
||||
:observed_at
|
||||
)
|
||||
"""
|
||||
),
|
||||
{
|
||||
**payload,
|
||||
"policy_decision": _json(payload["policy_decision"]),
|
||||
"stage_receipts": _json(payload["stage_receipts"]),
|
||||
},
|
||||
)
|
||||
|
||||
async def finish_run(self, payload: dict[str, Any]) -> None:
|
||||
async with get_db_context(PROJECT_ID) as db:
|
||||
await db.execute(
|
||||
text(
|
||||
"""
|
||||
UPDATE awooop_mcp_version_lifecycle_run
|
||||
SET status = :status,
|
||||
observed_count = :observed_count,
|
||||
baseline_count = :baseline_count,
|
||||
changed_count = :changed_count,
|
||||
policy_blocked_count = :policy_blocked_count,
|
||||
error_count = :error_count,
|
||||
receipt = CAST(:receipt AS jsonb),
|
||||
ended_at = :ended_at
|
||||
WHERE run_id = :run_id AND project_id = :project_id
|
||||
"""
|
||||
),
|
||||
{
|
||||
**payload,
|
||||
"receipt": _json(payload["receipt"]),
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
async def fetch_catalog_source(
|
||||
source_url: str,
|
||||
catalog_source: str,
|
||||
timeout_seconds: int,
|
||||
) -> CatalogFetchReceipt:
|
||||
"""Fetch one allowlisted catalog URL without cross-domain redirects."""
|
||||
allowed_hosts = _SOURCE_HOSTS.get(catalog_source)
|
||||
if not allowed_hosts or not _is_allowed_url(source_url, allowed_hosts):
|
||||
return CatalogFetchReceipt(
|
||||
status="policy_blocked",
|
||||
source_url=source_url,
|
||||
error_class="catalog_url_not_allowlisted",
|
||||
)
|
||||
|
||||
client = await get_general_client()
|
||||
current_url = source_url
|
||||
for redirect_count in range(_MAX_REDIRECTS + 1):
|
||||
try:
|
||||
async with client.stream(
|
||||
"GET",
|
||||
current_url,
|
||||
follow_redirects=False,
|
||||
timeout=httpx.Timeout(float(timeout_seconds), connect=5.0),
|
||||
headers={
|
||||
"Accept": "application/json,text/html,text/plain;q=0.9",
|
||||
"User-Agent": "awoooi-mcp-version-lifecycle/1.0",
|
||||
},
|
||||
) as response:
|
||||
if response.status_code in {301, 302, 303, 307, 308}:
|
||||
location = response.headers.get("location")
|
||||
if not location or redirect_count >= _MAX_REDIRECTS:
|
||||
return CatalogFetchReceipt(
|
||||
status="error",
|
||||
source_url=current_url,
|
||||
http_status=response.status_code,
|
||||
error_class="redirect_limit_or_location_missing",
|
||||
redirect_count=redirect_count,
|
||||
)
|
||||
redirected = urljoin(current_url, location)
|
||||
if not _is_allowed_url(redirected, allowed_hosts):
|
||||
return CatalogFetchReceipt(
|
||||
status="policy_blocked",
|
||||
source_url=current_url,
|
||||
http_status=response.status_code,
|
||||
error_class="cross_domain_redirect_blocked",
|
||||
redirect_count=redirect_count,
|
||||
)
|
||||
current_url = redirected
|
||||
continue
|
||||
|
||||
if response.status_code != 200:
|
||||
return CatalogFetchReceipt(
|
||||
status="error",
|
||||
source_url=current_url,
|
||||
http_status=response.status_code,
|
||||
error_class=f"http_{response.status_code}",
|
||||
redirect_count=redirect_count,
|
||||
)
|
||||
|
||||
content_type = response.headers.get("content-type", "").lower()
|
||||
if not any(
|
||||
marker in content_type
|
||||
for marker in (
|
||||
"application/json",
|
||||
"application/octet-stream",
|
||||
"text/html",
|
||||
"text/plain",
|
||||
)
|
||||
):
|
||||
return CatalogFetchReceipt(
|
||||
status="error",
|
||||
source_url=current_url,
|
||||
http_status=response.status_code,
|
||||
error_class="unsupported_content_type",
|
||||
redirect_count=redirect_count,
|
||||
)
|
||||
|
||||
content_length = response.headers.get("content-length")
|
||||
if content_length and int(content_length) > _MAX_SOURCE_BYTES:
|
||||
return CatalogFetchReceipt(
|
||||
status="error",
|
||||
source_url=current_url,
|
||||
http_status=response.status_code,
|
||||
error_class="source_payload_too_large",
|
||||
redirect_count=redirect_count,
|
||||
)
|
||||
|
||||
body = bytearray()
|
||||
async for chunk in response.aiter_bytes():
|
||||
body.extend(chunk)
|
||||
if len(body) > _MAX_SOURCE_BYTES:
|
||||
return CatalogFetchReceipt(
|
||||
status="error",
|
||||
source_url=current_url,
|
||||
http_status=response.status_code,
|
||||
error_class="source_payload_too_large",
|
||||
redirect_count=redirect_count,
|
||||
)
|
||||
return CatalogFetchReceipt(
|
||||
status="ok",
|
||||
source_url=current_url,
|
||||
http_status=response.status_code,
|
||||
body=bytes(body),
|
||||
redirect_count=redirect_count,
|
||||
)
|
||||
except httpx.TimeoutException:
|
||||
return CatalogFetchReceipt(
|
||||
status="error",
|
||||
source_url=current_url,
|
||||
error_class="source_timeout",
|
||||
redirect_count=redirect_count,
|
||||
)
|
||||
except (httpx.RequestError, ValueError):
|
||||
return CatalogFetchReceipt(
|
||||
status="error",
|
||||
source_url=current_url,
|
||||
error_class="source_network_error",
|
||||
redirect_count=redirect_count,
|
||||
)
|
||||
|
||||
return CatalogFetchReceipt(
|
||||
status="error",
|
||||
source_url=current_url,
|
||||
error_class="redirect_loop",
|
||||
redirect_count=_MAX_REDIRECTS,
|
||||
)
|
||||
|
||||
|
||||
async def run_mcp_version_lifecycle_once(
|
||||
catalog: dict[str, Any],
|
||||
*,
|
||||
trigger_kind: str = "scheduled",
|
||||
repository: LifecycleRepository | None = None,
|
||||
fetcher: CatalogFetcher | None = None,
|
||||
generated_at: datetime | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Observe every eligible external candidate and persist terminal receipts."""
|
||||
repository = repository or PostgresLifecycleRepository()
|
||||
fetcher = fetcher or fetch_catalog_source
|
||||
now = generated_at or datetime.now(UTC)
|
||||
run_id = uuid.uuid4()
|
||||
trace_id = f"mcp-version-{run_id}"
|
||||
work_item_id = f"MCP-VERSION-{now:%Y%m%dT%H%M%SZ}"
|
||||
candidates = _eligible_candidates(catalog)
|
||||
start_receipt = {
|
||||
"schema_version": SCHEMA_VERSION,
|
||||
"controller": "signal_worker_scheduled_fail_closed",
|
||||
"raw_external_content_stored": False,
|
||||
"external_install_performed": False,
|
||||
"external_tool_call_performed": False,
|
||||
"external_rag_write_performed": False,
|
||||
"github_used": False,
|
||||
"source_host_allowlist": sorted(
|
||||
host for hosts in _SOURCE_HOSTS.values() for host in hosts
|
||||
),
|
||||
}
|
||||
await repository.start_run(
|
||||
{
|
||||
"run_id": run_id,
|
||||
"trace_id": trace_id,
|
||||
"work_item_id": work_item_id,
|
||||
"project_id": PROJECT_ID,
|
||||
"trigger_kind": trigger_kind,
|
||||
"candidate_count": len(candidates),
|
||||
"receipt": start_receipt,
|
||||
"started_at": now,
|
||||
}
|
||||
)
|
||||
|
||||
counts = {
|
||||
"observed_count": 0,
|
||||
"baseline_count": 0,
|
||||
"changed_count": 0,
|
||||
"policy_blocked_count": 0,
|
||||
"error_count": 0,
|
||||
}
|
||||
terminals: dict[str, int] = {}
|
||||
try:
|
||||
previous = await repository.latest_observations()
|
||||
|
||||
semaphore = asyncio.Semaphore(_MAX_CONCURRENT_FETCHES)
|
||||
|
||||
async def fetch_candidate(
|
||||
candidate: dict[str, Any],
|
||||
) -> tuple[dict[str, Any], CatalogFetchReceipt]:
|
||||
source_url = str(candidate["catalog_url"])
|
||||
source_name = str(candidate["catalog_source"])
|
||||
async with semaphore:
|
||||
try:
|
||||
fetched = await fetcher(
|
||||
source_url,
|
||||
source_name,
|
||||
settings.MCP_VERSION_LIFECYCLE_SOURCE_TIMEOUT_SECONDS,
|
||||
)
|
||||
except Exception:
|
||||
fetched = CatalogFetchReceipt(
|
||||
status="error",
|
||||
source_url=source_url,
|
||||
error_class="source_fetcher_error",
|
||||
)
|
||||
return candidate, fetched
|
||||
|
||||
fetched_candidates = await asyncio.gather(
|
||||
*(fetch_candidate(candidate) for candidate in candidates)
|
||||
)
|
||||
for candidate, fetched in fetched_candidates:
|
||||
observation = build_candidate_observation(
|
||||
candidate,
|
||||
fetched=fetched,
|
||||
previous=previous.get(str(candidate["capability_id"])),
|
||||
run_id=run_id,
|
||||
trace_id=trace_id,
|
||||
work_item_id=work_item_id,
|
||||
observed_at=now,
|
||||
)
|
||||
await repository.save_observation(observation)
|
||||
_increment_counts(counts, observation)
|
||||
terminal = str(observation["lifecycle_terminal"])
|
||||
terminals[terminal] = terminals.get(terminal, 0) + 1
|
||||
|
||||
status = _run_status(counts)
|
||||
ended_at = datetime.now(UTC)
|
||||
final_receipt = {
|
||||
**start_receipt,
|
||||
"status": status,
|
||||
"candidate_count": len(candidates),
|
||||
**counts,
|
||||
"terminal_counts": terminals,
|
||||
"independent_post_verifier": {
|
||||
"status": "verified",
|
||||
"mutation_scope": "normalized_metadata_rows_only",
|
||||
"raw_external_content_stored": False,
|
||||
"external_install_performed": False,
|
||||
"external_tool_call_performed": False,
|
||||
"external_rag_write_performed": False,
|
||||
"production_upgrade_performed": False,
|
||||
},
|
||||
"rollback": {
|
||||
"status": "no_write_terminal",
|
||||
"schema_rollback": "additive_tables_preserved",
|
||||
},
|
||||
}
|
||||
await repository.finish_run(
|
||||
{
|
||||
"run_id": run_id,
|
||||
"project_id": PROJECT_ID,
|
||||
"status": status,
|
||||
**counts,
|
||||
"receipt": final_receipt,
|
||||
"ended_at": ended_at,
|
||||
}
|
||||
)
|
||||
logger.info(
|
||||
"mcp_version_lifecycle_run_completed",
|
||||
run_id=str(run_id),
|
||||
status=status,
|
||||
candidate_count=len(candidates),
|
||||
**counts,
|
||||
)
|
||||
return {
|
||||
"schema_version": SCHEMA_VERSION,
|
||||
"run_id": str(run_id),
|
||||
"trace_id": trace_id,
|
||||
"work_item_id": work_item_id,
|
||||
"status": status,
|
||||
"candidate_count": len(candidates),
|
||||
**counts,
|
||||
"terminal_counts": terminals,
|
||||
}
|
||||
except Exception:
|
||||
ended_at = datetime.now(UTC)
|
||||
failure_receipt = {
|
||||
**start_receipt,
|
||||
"status": "failed",
|
||||
"error_class": "controller_execution_error",
|
||||
"independent_post_verifier": {
|
||||
"status": "partial",
|
||||
"external_install_performed": False,
|
||||
"external_tool_call_performed": False,
|
||||
"external_rag_write_performed": False,
|
||||
"production_upgrade_performed": False,
|
||||
},
|
||||
"rollback": {"status": "no_write_terminal"},
|
||||
}
|
||||
try:
|
||||
await repository.finish_run(
|
||||
{
|
||||
"run_id": run_id,
|
||||
"project_id": PROJECT_ID,
|
||||
"status": "failed",
|
||||
**counts,
|
||||
"receipt": failure_receipt,
|
||||
"ended_at": ended_at,
|
||||
}
|
||||
)
|
||||
except Exception:
|
||||
logger.exception(
|
||||
"mcp_version_lifecycle_failure_receipt_write_failed",
|
||||
run_id=str(run_id),
|
||||
)
|
||||
raise
|
||||
|
||||
|
||||
def build_candidate_observation(
|
||||
candidate: dict[str, Any],
|
||||
*,
|
||||
fetched: CatalogFetchReceipt,
|
||||
previous: dict[str, Any] | None,
|
||||
run_id: uuid.UUID,
|
||||
trace_id: str,
|
||||
work_item_id: str,
|
||||
observed_at: datetime,
|
||||
) -> dict[str, Any]:
|
||||
"""Normalize, diff, gate, and terminalize one source observation."""
|
||||
candidate_id = str(candidate["capability_id"])
|
||||
observed_version = _extract_unambiguous_version(fetched.body)
|
||||
content_sha256, fingerprint_scope = _stable_catalog_fingerprint(
|
||||
fetched.body,
|
||||
source_url=fetched.source_url,
|
||||
observed_version=observed_version,
|
||||
)
|
||||
previous_version = (
|
||||
str(previous.get("observed_version"))
|
||||
if previous and previous.get("observed_version")
|
||||
else None
|
||||
)
|
||||
change_state = _change_state(
|
||||
fetched=fetched,
|
||||
content_sha256=content_sha256,
|
||||
observed_version=observed_version,
|
||||
previous=previous,
|
||||
)
|
||||
missing_receipts = _missing_supply_chain_receipts(candidate)
|
||||
supply_chain_ready = not missing_receipts
|
||||
|
||||
if fetched.status != "ok":
|
||||
discovery_status = (
|
||||
"policy_blocked" if fetched.status == "policy_blocked" else "source_error"
|
||||
)
|
||||
terminal = "no_write_source_error"
|
||||
replay_status = "not_started_source_unavailable"
|
||||
elif change_state == "baseline_created":
|
||||
discovery_status = "observed"
|
||||
terminal = "no_write_baseline"
|
||||
replay_status = "not_required_baseline_only"
|
||||
elif change_state == "no_change":
|
||||
discovery_status = "observed"
|
||||
terminal = "no_write_no_change"
|
||||
replay_status = "not_required_no_diff"
|
||||
else:
|
||||
discovery_status = "observed"
|
||||
terminal = "no_write_policy_blocked"
|
||||
replay_status = (
|
||||
"blocked_registered_internal_replay_adapter_missing"
|
||||
if supply_chain_ready
|
||||
else "blocked_immutable_supply_chain_receipts_missing"
|
||||
)
|
||||
|
||||
policy_decision = {
|
||||
"risk_tier": str(candidate.get("risk_tier") or "high"),
|
||||
"deployment_allowed_by_catalog": candidate.get("deployment_allowed") is True,
|
||||
"immutable_supply_chain_ready": supply_chain_ready,
|
||||
"missing_receipts": missing_receipts,
|
||||
"registered_internal_replay_adapter": False,
|
||||
"external_install_allowed": False,
|
||||
"production_upgrade_allowed": False,
|
||||
"rag_write_allowed": False,
|
||||
}
|
||||
stage_receipts = {
|
||||
"sensor_source_receipt": {
|
||||
"status": fetched.status,
|
||||
"http_status": fetched.http_status,
|
||||
"redirect_count": fetched.redirect_count,
|
||||
"content_sha256": content_sha256,
|
||||
"content_fingerprint_scope": fingerprint_scope,
|
||||
"raw_body_stored": False,
|
||||
},
|
||||
"normalized_asset_identity": {
|
||||
"candidate_id": candidate_id,
|
||||
"catalog_source": str(candidate["catalog_source"]),
|
||||
"source_url_sha256": hashlib.sha256(
|
||||
fetched.source_url.encode("utf-8")
|
||||
).hexdigest(),
|
||||
},
|
||||
"source_of_truth_diff": {
|
||||
"change_state": change_state,
|
||||
"previous_version": previous_version,
|
||||
"observed_version": observed_version,
|
||||
"previous_content_sha256": (
|
||||
str(previous.get("content_sha256"))
|
||||
if previous and previous.get("content_sha256")
|
||||
else None
|
||||
),
|
||||
"observed_content_sha256": content_sha256,
|
||||
},
|
||||
"decision": {
|
||||
"candidate_action": "observe_only_until_all_gates_pass",
|
||||
"reason": replay_status,
|
||||
},
|
||||
"risk_policy": policy_decision,
|
||||
"check_mode": {
|
||||
"status": replay_status,
|
||||
"external_process_started": False,
|
||||
"artifact_downloaded": False,
|
||||
},
|
||||
"bounded_execution": {
|
||||
"status": "not_executed",
|
||||
"external_tool_call_performed": False,
|
||||
"production_write_performed": False,
|
||||
},
|
||||
"post_verifier_and_rollback": {
|
||||
"status": "verified_no_write_terminal",
|
||||
"terminal": terminal,
|
||||
"rollback_required": False,
|
||||
"rollback_reason": "no_runtime_mutation_performed",
|
||||
},
|
||||
"learning_writeback": {
|
||||
"status": "committed_with_observation_row",
|
||||
"target": "awooop_mcp_version_observation",
|
||||
"rag_or_km_write_performed": False,
|
||||
},
|
||||
}
|
||||
return {
|
||||
"run_id": run_id,
|
||||
"trace_id": trace_id,
|
||||
"work_item_id": work_item_id,
|
||||
"project_id": PROJECT_ID,
|
||||
"candidate_id": candidate_id,
|
||||
"catalog_source": str(candidate["catalog_source"]),
|
||||
"source_url": fetched.source_url,
|
||||
"source_url_sha256": hashlib.sha256(
|
||||
fetched.source_url.encode("utf-8")
|
||||
).hexdigest(),
|
||||
"http_status": fetched.http_status,
|
||||
"content_sha256": content_sha256,
|
||||
"observed_version": observed_version,
|
||||
"previous_version": previous_version,
|
||||
"discovery_status": discovery_status,
|
||||
"change_state": change_state,
|
||||
"lifecycle_terminal": terminal,
|
||||
"policy_decision": policy_decision,
|
||||
"stage_receipts": stage_receipts,
|
||||
"error_class": fetched.error_class,
|
||||
"observed_at": observed_at,
|
||||
}
|
||||
|
||||
|
||||
async def collect_mcp_version_lifecycle_readback() -> dict[str, Any]:
|
||||
"""Return the newest durable run and bounded per-candidate terminal states."""
|
||||
try:
|
||||
async with get_db_context(PROJECT_ID) as db:
|
||||
run_result = await db.execute(
|
||||
text(
|
||||
"""
|
||||
SELECT
|
||||
run_id, trace_id, work_item_id, trigger_kind, status,
|
||||
candidate_count, observed_count, baseline_count,
|
||||
changed_count, policy_blocked_count, error_count,
|
||||
started_at, ended_at,
|
||||
started_at >= NOW() - make_interval(
|
||||
secs => :freshness_seconds
|
||||
) AS fresh
|
||||
FROM awooop_mcp_version_lifecycle_run
|
||||
WHERE project_id = :project_id
|
||||
ORDER BY started_at DESC
|
||||
LIMIT 1
|
||||
"""
|
||||
),
|
||||
{
|
||||
"project_id": PROJECT_ID,
|
||||
"freshness_seconds": (
|
||||
settings.MCP_VERSION_LIFECYCLE_INTERVAL_SECONDS * 2
|
||||
),
|
||||
},
|
||||
)
|
||||
latest = run_result.mappings().one_or_none()
|
||||
if latest is None:
|
||||
return {
|
||||
"status": "pending_first_run",
|
||||
"controller_configured": True,
|
||||
"controller_owner": "signal_worker",
|
||||
"interval_seconds": settings.MCP_VERSION_LIFECYCLE_INTERVAL_SECONDS,
|
||||
"latest_run": None,
|
||||
"observations": [],
|
||||
"blockers": ["first_runtime_receipt_pending"],
|
||||
}
|
||||
observation_result = await db.execute(
|
||||
text(
|
||||
"""
|
||||
SELECT candidate_id, catalog_source, observed_version,
|
||||
discovery_status, change_state, lifecycle_terminal,
|
||||
error_class, observed_at
|
||||
FROM awooop_mcp_version_observation
|
||||
WHERE project_id = :project_id AND run_id = :run_id
|
||||
ORDER BY candidate_id
|
||||
LIMIT 64
|
||||
"""
|
||||
),
|
||||
{"project_id": PROJECT_ID, "run_id": latest["run_id"]},
|
||||
)
|
||||
observations = [
|
||||
{
|
||||
"candidate_id": str(row["candidate_id"]),
|
||||
"catalog_source": str(row["catalog_source"]),
|
||||
"observed_version": row["observed_version"],
|
||||
"discovery_status": str(row["discovery_status"]),
|
||||
"change_state": str(row["change_state"]),
|
||||
"lifecycle_terminal": str(row["lifecycle_terminal"]),
|
||||
"error_class": row["error_class"],
|
||||
"observed_at": _iso(row["observed_at"]),
|
||||
}
|
||||
for row in observation_result.mappings().all()
|
||||
]
|
||||
|
||||
run_payload = {
|
||||
"run_id": str(latest["run_id"]),
|
||||
"trace_id": str(latest["trace_id"]),
|
||||
"work_item_id": str(latest["work_item_id"]),
|
||||
"trigger_kind": str(latest["trigger_kind"]),
|
||||
"status": str(latest["status"]),
|
||||
"candidate_count": int(latest["candidate_count"]),
|
||||
"observed_count": int(latest["observed_count"]),
|
||||
"baseline_count": int(latest["baseline_count"]),
|
||||
"changed_count": int(latest["changed_count"]),
|
||||
"policy_blocked_count": int(latest["policy_blocked_count"]),
|
||||
"error_count": int(latest["error_count"]),
|
||||
"started_at": _iso(latest["started_at"]),
|
||||
"ended_at": _iso(latest["ended_at"]),
|
||||
"fresh": bool(latest["fresh"]),
|
||||
}
|
||||
blockers = []
|
||||
if not run_payload["fresh"]:
|
||||
blockers.append("version_lifecycle_receipt_stale")
|
||||
if run_payload["error_count"]:
|
||||
blockers.append("catalog_source_observation_failures")
|
||||
if run_payload["policy_blocked_count"]:
|
||||
blockers.append("external_candidates_not_promotion_ready")
|
||||
return {
|
||||
"status": "ready" if run_payload["fresh"] else "degraded",
|
||||
"controller_configured": True,
|
||||
"controller_owner": "signal_worker",
|
||||
"lock_backend": "redis_atomic_lease",
|
||||
"interval_seconds": settings.MCP_VERSION_LIFECYCLE_INTERVAL_SECONDS,
|
||||
"latest_run": run_payload,
|
||||
"observations": observations,
|
||||
"blockers": blockers,
|
||||
}
|
||||
except Exception as exc:
|
||||
logger.warning(
|
||||
"mcp_version_lifecycle_readback_failed",
|
||||
error_type=type(exc).__name__,
|
||||
)
|
||||
return {
|
||||
"status": "degraded",
|
||||
"controller_configured": True,
|
||||
"controller_owner": "signal_worker",
|
||||
"interval_seconds": settings.MCP_VERSION_LIFECYCLE_INTERVAL_SECONDS,
|
||||
"latest_run": None,
|
||||
"observations": [],
|
||||
"blockers": ["version_lifecycle_schema_or_database_readback_failed"],
|
||||
}
|
||||
|
||||
|
||||
def _eligible_candidates(catalog: dict[str, Any]) -> list[dict[str, Any]]:
|
||||
rows = catalog.get("capabilities")
|
||||
if not isinstance(rows, list):
|
||||
return []
|
||||
candidates = [
|
||||
row
|
||||
for row in rows
|
||||
if isinstance(row, dict)
|
||||
and row.get("source_type") == "external"
|
||||
and row.get("decision") != "rejected"
|
||||
and row.get("catalog_source") in _SOURCE_HOSTS
|
||||
and row.get("catalog_url")
|
||||
]
|
||||
return sorted(candidates, key=lambda row: str(row["capability_id"]))
|
||||
|
||||
|
||||
def _is_allowed_url(url: str, allowed_hosts: frozenset[str]) -> bool:
|
||||
parsed = urlparse(url)
|
||||
try:
|
||||
port = parsed.port
|
||||
except ValueError:
|
||||
return False
|
||||
return (
|
||||
parsed.scheme == "https"
|
||||
and (parsed.hostname or "").lower() in allowed_hosts
|
||||
and parsed.username is None
|
||||
and parsed.password is None
|
||||
and port in {None, 443}
|
||||
)
|
||||
|
||||
|
||||
def _extract_unambiguous_version(body: bytes) -> str | None:
|
||||
versions = {
|
||||
match.group(1).decode("ascii")
|
||||
for pattern in _VERSION_PATTERNS
|
||||
for match in pattern.finditer(body)
|
||||
}
|
||||
return next(iter(versions)) if len(versions) == 1 else None
|
||||
|
||||
|
||||
def _stable_catalog_fingerprint(
|
||||
body: bytes,
|
||||
*,
|
||||
source_url: str,
|
||||
observed_version: str | None,
|
||||
) -> tuple[str | None, str]:
|
||||
"""Hash stable identity metadata instead of randomized recommendation HTML."""
|
||||
if not body:
|
||||
return None, "not_available"
|
||||
|
||||
parser = _StableMetadataParser()
|
||||
try:
|
||||
parser.feed(body.decode("utf-8", errors="ignore"))
|
||||
except (UnicodeError, ValueError):
|
||||
stable_fields: dict[str, str] = {}
|
||||
else:
|
||||
stable_fields = parser.stable_fields()
|
||||
if stable_fields:
|
||||
normalized = {
|
||||
"source_url": source_url,
|
||||
"observed_version": observed_version,
|
||||
"metadata": stable_fields,
|
||||
}
|
||||
return hashlib.sha256(_json(normalized).encode("utf-8")).hexdigest(), (
|
||||
"title_description_canonical_version"
|
||||
)
|
||||
return hashlib.sha256(body).hexdigest(), "bounded_raw_fallback"
|
||||
|
||||
|
||||
def _clean_metadata_text(value: str) -> str:
|
||||
return " ".join(html.unescape(value).split())[:4_000]
|
||||
|
||||
|
||||
def _change_state(
|
||||
*,
|
||||
fetched: CatalogFetchReceipt,
|
||||
content_sha256: str | None,
|
||||
observed_version: str | None,
|
||||
previous: dict[str, Any] | None,
|
||||
) -> str:
|
||||
if fetched.status != "ok":
|
||||
return "source_unavailable"
|
||||
if previous is None:
|
||||
return "baseline_created"
|
||||
previous_version = previous.get("observed_version")
|
||||
if previous_version and observed_version and previous_version != observed_version:
|
||||
return "version_changed"
|
||||
if previous.get("content_sha256") != content_sha256:
|
||||
return "content_changed"
|
||||
return "no_change"
|
||||
|
||||
|
||||
def _missing_supply_chain_receipts(candidate: dict[str, Any]) -> list[str]:
|
||||
receipts = candidate.get("supply_chain_receipts")
|
||||
receipts = receipts if isinstance(receipts, dict) else {}
|
||||
required = (
|
||||
"exact_version",
|
||||
"immutable_artifact_digest",
|
||||
"internal_mirror_ref",
|
||||
"sbom_ref",
|
||||
"signature_or_provenance_ref",
|
||||
"license_decision",
|
||||
"vulnerability_decision",
|
||||
"rollback_artifact_ref",
|
||||
)
|
||||
missing = [key for key in required if not receipts.get(key)]
|
||||
if candidate.get("deployment_allowed") is not True:
|
||||
missing.append("deployment_policy_not_enabled")
|
||||
return missing
|
||||
|
||||
|
||||
def _increment_counts(counts: dict[str, int], observation: dict[str, Any]) -> None:
|
||||
if observation["discovery_status"] == "observed":
|
||||
counts["observed_count"] += 1
|
||||
else:
|
||||
counts["error_count"] += 1
|
||||
if observation["change_state"] == "baseline_created":
|
||||
counts["baseline_count"] += 1
|
||||
if observation["change_state"] in {"version_changed", "content_changed"}:
|
||||
counts["changed_count"] += 1
|
||||
if observation["policy_decision"]["immutable_supply_chain_ready"] is False:
|
||||
counts["policy_blocked_count"] += 1
|
||||
|
||||
|
||||
def _run_status(counts: dict[str, int]) -> str:
|
||||
if counts["error_count"]:
|
||||
return "partial_degraded"
|
||||
if counts["policy_blocked_count"]:
|
||||
return "completed_no_write_policy_blocked"
|
||||
if counts["changed_count"] == 0:
|
||||
return "completed_no_change"
|
||||
return "completed_no_write_policy_blocked"
|
||||
|
||||
|
||||
def _json(payload: dict[str, Any]) -> str:
|
||||
return json.dumps(
|
||||
payload, ensure_ascii=False, sort_keys=True, separators=(",", ":")
|
||||
)
|
||||
|
||||
|
||||
def _iso(value: Any) -> str | None:
|
||||
return value.isoformat() if hasattr(value, "isoformat") else None
|
||||
@@ -623,6 +623,7 @@ async def _main() -> None:
|
||||
backup_restore_legacy_backfill_task: asyncio.Task[Any] | None = None
|
||||
security_maintenance_tasks: list[asyncio.Task[Any]] = []
|
||||
agent99_controlled_dispatch_reconciler_task: asyncio.Task[Any] | None = None
|
||||
mcp_version_lifecycle_task: asyncio.Task[Any] | None = None
|
||||
if settings.ENABLE_AWOOOP_ANSIBLE_CANDIDATE_BACKFILL_WORKER:
|
||||
from src.jobs.awooop_ansible_candidate_backfill_job import (
|
||||
run_awooop_ansible_candidate_backfill_loop,
|
||||
@@ -688,6 +689,30 @@ async def _main() -> None:
|
||||
task_count=len(security_maintenance_tasks),
|
||||
)
|
||||
|
||||
if settings.ENABLE_MCP_VERSION_LIFECYCLE_WORKER:
|
||||
from src.jobs.mcp_version_lifecycle_job import (
|
||||
run_mcp_version_lifecycle_loop,
|
||||
)
|
||||
|
||||
mcp_version_lifecycle_task = asyncio.create_task(
|
||||
run_mcp_version_lifecycle_loop(),
|
||||
name="run_mcp_version_lifecycle_loop",
|
||||
)
|
||||
logger.info(
|
||||
"signal_worker_mcp_version_lifecycle_started",
|
||||
interval_seconds=settings.MCP_VERSION_LIFECYCLE_INTERVAL_SECONDS,
|
||||
startup_sleep_seconds=(
|
||||
settings.MCP_VERSION_LIFECYCLE_STARTUP_SLEEP_SECONDS
|
||||
),
|
||||
external_install_allowed=False,
|
||||
external_rag_write_allowed=False,
|
||||
)
|
||||
else:
|
||||
logger.warning(
|
||||
"signal_worker_mcp_version_lifecycle_disabled",
|
||||
owner="signal_worker",
|
||||
)
|
||||
|
||||
# 2026-07-14 Codex v1.4: Agent99 reconciliation has one runtime owner.
|
||||
# The API reserves its single DB connection for requests, so durable
|
||||
# dispatch completion belongs to this standalone worker after the CD-owned
|
||||
@@ -769,7 +794,16 @@ async def _main() -> None:
|
||||
await agent99_controlled_dispatch_reconciler_task
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
if mcp_version_lifecycle_task is not None:
|
||||
mcp_version_lifecycle_task.cancel()
|
||||
try:
|
||||
await mcp_version_lifecycle_task
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
await worker.stop()
|
||||
from src.core.http_client import close_general_client
|
||||
|
||||
await close_general_client()
|
||||
await close_worker_redis_pool() # 關閉 Worker 專屬連線
|
||||
await close_redis_pool()
|
||||
await close_db() # 關閉 PostgreSQL 連線池
|
||||
|
||||
@@ -34,17 +34,24 @@ def test_catalog_contains_exact_12_products_and_explicit_missing_rows() -> None:
|
||||
assert catalog["source_policy"]["github_allowed"] is False
|
||||
assert catalog["security_policy"]["external_rag_auto_write_allowed"] is False
|
||||
|
||||
capabilities = {
|
||||
row["capability_id"]: row for row in catalog["capabilities"]
|
||||
}
|
||||
capabilities = {row["capability_id"]: row for row in catalog["capabilities"]}
|
||||
assert capabilities["rejected.github"]["decision"] == "rejected"
|
||||
assert capabilities["quarantine.agent-bounty-mcp"]["decision"] == "quarantine"
|
||||
assert capabilities["external.smithery-site-audit-shadow"]["decision"] == "evaluation_shadow_candidate"
|
||||
assert capabilities["external.smithery-site-audit-shadow"]["deployment_allowed"] is False
|
||||
assert capabilities["rejected.smithery-technical-analysis"]["decision"] == "rejected"
|
||||
assert "tool_output_contains_cross_tool_prompt_injection" in capabilities[
|
||||
"quarantine.agent-bounty-mcp"
|
||||
]["blockers"]
|
||||
assert (
|
||||
capabilities["external.smithery-site-audit-shadow"]["decision"]
|
||||
== "evaluation_shadow_candidate"
|
||||
)
|
||||
assert (
|
||||
capabilities["external.smithery-site-audit-shadow"]["deployment_allowed"]
|
||||
is False
|
||||
)
|
||||
assert (
|
||||
capabilities["rejected.smithery-technical-analysis"]["decision"] == "rejected"
|
||||
)
|
||||
assert (
|
||||
"tool_output_contains_cross_tool_prompt_injection"
|
||||
in capabilities["quarantine.agent-bounty-mcp"]["blockers"]
|
||||
)
|
||||
assert not any(
|
||||
row["deployment_allowed"]
|
||||
for row in catalog["capabilities"]
|
||||
@@ -73,7 +80,10 @@ def test_overview_joins_committed_product_matrix_without_runtime_claims() -> Non
|
||||
products = {row["product_id"]: row for row in payload["products"]}
|
||||
assert products["ewoooc"]["canonical_repo"] == "wooo/ewoooc"
|
||||
assert products["momo-pro-system"]["canonical_repo"] == "wooo/momo-pro-system"
|
||||
assert products["agent-bounty-protocol"]["inventory_state"] == "source_detected_quarantined"
|
||||
assert (
|
||||
products["agent-bounty-protocol"]["inventory_state"]
|
||||
== "source_detected_quarantined"
|
||||
)
|
||||
|
||||
|
||||
def test_catalog_rejects_external_deployment_without_immutable_receipts(
|
||||
@@ -102,15 +112,53 @@ def test_overview_endpoint_returns_real_committed_catalog_without_runtime() -> N
|
||||
app.include_router(router, prefix="/api/v1")
|
||||
client = TestClient(app)
|
||||
|
||||
response = client.get(
|
||||
"/api/v1/mcp-control-plane/overview?include_runtime=false"
|
||||
)
|
||||
response = client.get("/api/v1/mcp-control-plane/overview?include_runtime=false")
|
||||
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["summary"]["product_count"] == 12
|
||||
assert data["runtime"]["status"] == "not_requested"
|
||||
assert data["operation_boundaries"]["github_allowed"] is False
|
||||
assert data["operation_boundaries"]["public_response_redacted_aggregate_only"] is True
|
||||
assert (
|
||||
data["operation_boundaries"]["public_response_redacted_aggregate_only"] is True
|
||||
)
|
||||
assert data["operation_boundaries"]["mutation_endpoint_exposed"] is False
|
||||
assert data["operation_boundaries"]["operator_auth_required_for_future_mutation"] is True
|
||||
assert (
|
||||
data["operation_boundaries"]["operator_auth_required_for_future_mutation"]
|
||||
is True
|
||||
)
|
||||
|
||||
|
||||
def test_overview_promotes_durable_lifecycle_receipt_not_static_policy() -> None:
|
||||
catalog = load_mcp_control_plane_catalog(_OPERATIONS_DIR)
|
||||
matrix = load_latest_product_governance_matrix_readback(_OPERATIONS_DIR)
|
||||
runtime = {
|
||||
"status": "ready",
|
||||
"provider_registry": {"provider_count": 9, "tool_count": 39},
|
||||
"gateway_audit_24h": {"call_count": 1},
|
||||
"rag": {"total_chunks": 1},
|
||||
"version_lifecycle": {
|
||||
"status": "ready",
|
||||
"latest_run": {
|
||||
"run_id": "11111111-1111-1111-1111-111111111111",
|
||||
"status": "completed_no_write_policy_blocked",
|
||||
"fresh": True,
|
||||
},
|
||||
"blockers": ["external_candidates_not_promotion_ready"],
|
||||
},
|
||||
}
|
||||
|
||||
payload = build_mcp_control_plane_overview(
|
||||
catalog=catalog,
|
||||
product_matrix=matrix,
|
||||
runtime=runtime,
|
||||
)
|
||||
|
||||
assert payload["version_lifecycle"]["runtime_state"] == "running_fail_closed"
|
||||
assert payload["version_lifecycle"]["runtime"]["status"] == "ready"
|
||||
assert "external_candidates_not_promotion_ready" in payload["active_blockers"]
|
||||
assert (
|
||||
payload["operation_boundaries"]["normalized_version_receipt_write_allowed"]
|
||||
is True
|
||||
)
|
||||
assert payload["operation_boundaries"]["production_upgrade_allowed"] is False
|
||||
|
||||
254
apps/api/tests/test_mcp_version_lifecycle_service.py
Normal file
254
apps/api/tests/test_mcp_version_lifecycle_service.py
Normal file
@@ -0,0 +1,254 @@
|
||||
from __future__ import annotations
|
||||
|
||||
# ruff: noqa: E402
|
||||
import hashlib
|
||||
import os
|
||||
import uuid
|
||||
from datetime import UTC, datetime
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
|
||||
os.environ.setdefault("DATABASE_URL", "postgresql+asyncpg://test:test@localhost/test")
|
||||
|
||||
from src.services.mcp_version_lifecycle_service import (
|
||||
CatalogFetchReceipt,
|
||||
_extract_unambiguous_version,
|
||||
_stable_catalog_fingerprint,
|
||||
build_candidate_observation,
|
||||
run_mcp_version_lifecycle_once,
|
||||
)
|
||||
|
||||
|
||||
class FakeLifecycleRepository:
|
||||
def __init__(self, previous: dict[str, dict[str, Any]] | None = None) -> None:
|
||||
self.previous = previous or {}
|
||||
self.started: dict[str, Any] | None = None
|
||||
self.observations: list[dict[str, Any]] = []
|
||||
self.finished: dict[str, Any] | None = None
|
||||
|
||||
async def start_run(self, payload: dict[str, Any]) -> None:
|
||||
self.started = payload
|
||||
|
||||
async def latest_observations(self) -> dict[str, dict[str, Any]]:
|
||||
return self.previous
|
||||
|
||||
async def save_observation(self, payload: dict[str, Any]) -> None:
|
||||
self.observations.append(payload)
|
||||
|
||||
async def finish_run(self, payload: dict[str, Any]) -> None:
|
||||
self.finished = payload
|
||||
|
||||
|
||||
def _catalog() -> dict[str, Any]:
|
||||
return {
|
||||
"capabilities": [
|
||||
{
|
||||
"capability_id": "external.context7-readonly",
|
||||
"source_type": "external",
|
||||
"catalog_source": "mcp-so",
|
||||
"catalog_url": "https://mcp.so/server/context7",
|
||||
"decision": "development_shadow_candidate",
|
||||
"risk_tier": "medium",
|
||||
"deployment_allowed": False,
|
||||
},
|
||||
{
|
||||
"capability_id": "external.smithery-site-audit-shadow",
|
||||
"source_type": "external",
|
||||
"catalog_source": "smithery",
|
||||
"catalog_url": "https://smithery.ai/servers/example/site-audit",
|
||||
"decision": "evaluation_shadow_candidate",
|
||||
"risk_tier": "high",
|
||||
"deployment_allowed": False,
|
||||
},
|
||||
{
|
||||
"capability_id": "rejected.disallowed-source",
|
||||
"source_type": "external",
|
||||
"catalog_source": "smithery",
|
||||
"catalog_url": "https://example.invalid/rejected",
|
||||
"decision": "rejected",
|
||||
"risk_tier": "high",
|
||||
"deployment_allowed": False,
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_lifecycle_run_persists_baselines_and_fail_closed_terminals() -> None:
|
||||
repository = FakeLifecycleRepository()
|
||||
|
||||
async def fetcher(
|
||||
url: str,
|
||||
source: str,
|
||||
timeout_seconds: int,
|
||||
) -> CatalogFetchReceipt:
|
||||
assert source in {"mcp-so", "smithery"}
|
||||
assert timeout_seconds > 0
|
||||
return CatalogFetchReceipt(
|
||||
status="ok",
|
||||
source_url=url,
|
||||
http_status=200,
|
||||
body=b'{"version":"1.2.3","content":"not persisted"}',
|
||||
)
|
||||
|
||||
result = await run_mcp_version_lifecycle_once(
|
||||
_catalog(),
|
||||
trigger_kind="test",
|
||||
repository=repository,
|
||||
fetcher=fetcher,
|
||||
generated_at=datetime(2026, 7, 15, tzinfo=UTC),
|
||||
)
|
||||
|
||||
assert result["status"] == "completed_no_write_policy_blocked"
|
||||
assert result["candidate_count"] == 2
|
||||
assert result["baseline_count"] == 2
|
||||
assert result["observed_count"] == 2
|
||||
assert result["policy_blocked_count"] == 2
|
||||
assert repository.started is not None
|
||||
assert repository.finished is not None
|
||||
assert len(repository.observations) == 2
|
||||
assert {row["lifecycle_terminal"] for row in repository.observations} == {
|
||||
"no_write_baseline"
|
||||
}
|
||||
for observation in repository.observations:
|
||||
assert observation["observed_version"] == "1.2.3"
|
||||
assert observation["policy_decision"]["external_install_allowed"] is False
|
||||
assert observation["policy_decision"]["rag_write_allowed"] is False
|
||||
assert (
|
||||
observation["stage_receipts"]["sensor_source_receipt"]["raw_body_stored"]
|
||||
is False
|
||||
)
|
||||
assert (
|
||||
observation["stage_receipts"]["bounded_execution"]["status"]
|
||||
== "not_executed"
|
||||
)
|
||||
|
||||
|
||||
def test_changed_version_stops_before_replay_without_immutable_receipts() -> None:
|
||||
old_body = b'{"version":"1.2.3"}'
|
||||
candidate = _catalog()["capabilities"][0]
|
||||
observation = build_candidate_observation(
|
||||
candidate,
|
||||
fetched=CatalogFetchReceipt(
|
||||
status="ok",
|
||||
source_url=candidate["catalog_url"],
|
||||
http_status=200,
|
||||
body=b'{"version":"1.3.0"}',
|
||||
),
|
||||
previous={
|
||||
"observed_version": "1.2.3",
|
||||
"content_sha256": hashlib.sha256(old_body).hexdigest(),
|
||||
},
|
||||
run_id=uuid.uuid4(),
|
||||
trace_id="trace-version-change",
|
||||
work_item_id="MCP-VERSION-TEST",
|
||||
observed_at=datetime(2026, 7, 15, tzinfo=UTC),
|
||||
)
|
||||
|
||||
assert observation["change_state"] == "version_changed"
|
||||
assert observation["lifecycle_terminal"] == "no_write_policy_blocked"
|
||||
assert observation["stage_receipts"]["check_mode"]["status"] == (
|
||||
"blocked_immutable_supply_chain_receipts_missing"
|
||||
)
|
||||
assert (
|
||||
observation["stage_receipts"]["bounded_execution"][
|
||||
"external_tool_call_performed"
|
||||
]
|
||||
is False
|
||||
)
|
||||
assert (
|
||||
observation["stage_receipts"]["post_verifier_and_rollback"]["rollback_required"]
|
||||
is False
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_source_failure_is_durable_partial_degraded_no_write() -> None:
|
||||
repository = FakeLifecycleRepository()
|
||||
|
||||
async def fetcher(
|
||||
url: str,
|
||||
source: str,
|
||||
timeout_seconds: int,
|
||||
) -> CatalogFetchReceipt:
|
||||
del source, timeout_seconds
|
||||
return CatalogFetchReceipt(
|
||||
status="error",
|
||||
source_url=url,
|
||||
http_status=503,
|
||||
error_class="http_503",
|
||||
)
|
||||
|
||||
result = await run_mcp_version_lifecycle_once(
|
||||
_catalog(),
|
||||
trigger_kind="test",
|
||||
repository=repository,
|
||||
fetcher=fetcher,
|
||||
)
|
||||
|
||||
assert result["status"] == "partial_degraded"
|
||||
assert result["error_count"] == 2
|
||||
assert all(
|
||||
row["lifecycle_terminal"] == "no_write_source_error"
|
||||
for row in repository.observations
|
||||
)
|
||||
assert repository.finished is not None
|
||||
verifier = repository.finished["receipt"]["independent_post_verifier"]
|
||||
assert verifier["external_install_performed"] is False
|
||||
assert verifier["external_rag_write_performed"] is False
|
||||
|
||||
|
||||
def test_version_parser_rejects_ambiguous_page_versions() -> None:
|
||||
assert _extract_unambiguous_version(b'{"version":"1.2.3"}') == "1.2.3"
|
||||
assert (
|
||||
_extract_unambiguous_version(b'{"version":"1.2.3","latestVersion":"2.0.0"}')
|
||||
is None
|
||||
)
|
||||
|
||||
|
||||
def test_stable_fingerprint_ignores_randomized_recommendation_sections() -> None:
|
||||
stable_head = b"""
|
||||
<html><head><title>Context7 MCP</title>
|
||||
<meta name="description" content="Version-specific documentation MCP">
|
||||
<link rel="canonical" href="https://mcp.so/servers/context7-mcp">
|
||||
</head><body>
|
||||
"""
|
||||
first, first_scope = _stable_catalog_fingerprint(
|
||||
stable_head + b'<a href="/servers/random-a">Random A</a></body></html>',
|
||||
source_url="https://mcp.so/servers/context7-mcp",
|
||||
observed_version=None,
|
||||
)
|
||||
second, second_scope = _stable_catalog_fingerprint(
|
||||
stable_head + b'<a href="/servers/random-b">Random B</a></body></html>',
|
||||
source_url="https://mcp.so/servers/context7-mcp",
|
||||
observed_version=None,
|
||||
)
|
||||
|
||||
assert first == second
|
||||
assert first_scope == second_scope == "title_description_canonical_version"
|
||||
|
||||
|
||||
def test_migration_is_additive_rls_and_does_not_store_raw_content() -> None:
|
||||
root = Path(__file__).resolve().parents[3]
|
||||
migration = (
|
||||
root / "apps/api/migrations/mcp_version_lifecycle_controller_2026-07-15.sql"
|
||||
).read_text(encoding="utf-8")
|
||||
lowered = migration.lower()
|
||||
|
||||
assert "create table if not exists awooop_mcp_version_lifecycle_run" in lowered
|
||||
assert "create table if not exists awooop_mcp_version_observation" in lowered
|
||||
assert "force row level security" in lowered
|
||||
assert (
|
||||
"with check (project_id = current_setting('app.project_id', true))" in lowered
|
||||
)
|
||||
assert "drop table" not in lowered
|
||||
assert "request_body" not in lowered
|
||||
assert "response_body" not in lowered
|
||||
assert "raw_content jsonb" not in lowered
|
||||
|
||||
workflow = (root / ".gitea/workflows/cd.yaml").read_text(encoding="utf-8")
|
||||
assert "Apply MCP Version Lifecycle Receipt Schema" in workflow
|
||||
assert "mcp_version_lifecycle_schema_verified" in workflow
|
||||
assert "mcp_version_lifecycle_runtime_role_read_verified" in workflow
|
||||
Reference in New Issue
Block a user