#!/usr/bin/env python3 from __future__ import annotations import argparse import json import os import sys from pathlib import Path from typing import Any ROOT = Path(__file__).resolve().parents[1] sys.path.insert(0, str(ROOT / "scripts")) sys.path.insert(0, str(ROOT / "plugins" / "1c" / "connector")) sys.path.insert(0, str(ROOT / "plugins" / "1c" / "mcp")) import adapter_1c_mcp as adapter_mcp # noqa: E402 import adapter_1c_server as adapter_server # noqa: E402 import smoke_1c_mcp_selector_chain as selector_chain_smoke # noqa: E402 FORBIDDEN_CONCRETE_SELECTOR_VALUES = ( "ПечатьЭтикеток", "УОП_Печать", "ЦенаСоСкидкой", "fs_Отчеты", ) PRODUCTION_SELECTOR_CONTRACT_ROOTS = ( ROOT / "plugins" / "1c", ROOT / "docs", ) PRODUCTION_SELECTOR_CONTRACT_SUFFIXES = { ".css", ".html", ".js", ".json", ".md", ".ps1", ".py", ".toml", ".txt", ".yaml", ".yml", } SYNTHETIC_SELECTOR_GUID = "00000000-0000-4000-8000-000000000001" PRODUCTION_SELECTOR_CONTRACT_EXCLUDED_PARTS = { "__pycache__", ".pytest_cache", "reports", "node_modules", ".git", } def find_tool(name: str) -> dict[str, Any] | None: for tool in adapter_mcp.TOOLS: if tool.get("name") == name: return tool return None def iter_production_selector_contract_files() -> list[Path]: files: list[Path] = [] for root in PRODUCTION_SELECTOR_CONTRACT_ROOTS: if not root.exists(): continue for path in root.rglob("*"): if not path.is_file(): continue try: relative_parts = set(path.relative_to(ROOT).parts) except ValueError: relative_parts = set(path.parts) if relative_parts.intersection(PRODUCTION_SELECTOR_CONTRACT_EXCLUDED_PARTS): continue if path.suffix.lower() not in PRODUCTION_SELECTOR_CONTRACT_SUFFIXES: continue files.append(path) return sorted(files) def contract_checks(issues: list[dict[str, Any]], *, onec_request_present: bool) -> dict[str, bool]: return { "generic_onec_request_schema": onec_request_present and not any( issue["code"].startswith("mcp_onec_request") or issue["code"] in {"mcp_payload_not_open", "mcp_payload_selector_guidance_missing"} for issue in issues ), "mcp_tool_selector_guidance": not any(issue["code"] in {"mcp_tool_selector_guidance_missing", "mcp_examples_public_ref_placeholder_missing"} for issue in issues), "mcp_saved_state_examples_name_first": not any(issue["code"].startswith("mcp_saved_state_example_") for issue in issues), "all_adapter_methods_forward_over_rpc": not any(issue["code"].startswith("mcp_method") or issue["code"] == "mcp_rpc_body_method_mismatch" for issue in issues), "no_unified_shadowing_adapter_methods": not any(issue["code"] == "mcp_unified_shadows_adapter_methods" for issue in issues), "adapter_help_selector_guidance": not any(issue["code"] == "adapter_help_selector_guidance_missing" for issue in issues), "adapter_selector_argument_type_validation": not any(issue["code"] == "adapter_selector_argument_type_not_validated" for issue in issues), "adapter_selector_normalizer_type_validation": not any(issue["code"] == "adapter_selector_normalizer_type_not_validated" for issue in issues), "adapter_selector_required_message": not any( issue["code"] in {"adapter_selector_required_message_incomplete", "adapter_selector_message_constant_incomplete"} for issue in issues ), "adapter_selector_presence_aliases": not any(issue["code"] == "adapter_selector_presence_alias_mismatch" for issue in issues), "adapter_selector_capability_descriptor": not any( issue["code"] in {"adapter_selector_capability_descriptor_mismatch", "adapter_help_selector_capabilities_missing"} for issue in issues ), "adapter_definition_read_selector_public_ref": not any(issue["code"] == "adapter_definition_read_selector_public_ref_missing" for issue in issues), "adapter_module_read_selector_public_ref": not any(issue["code"] == "adapter_module_read_selector_public_ref_missing" for issue in issues), "agent_facing_object_methods_name_first": not any(issue["code"] == "agent_facing_object_method_not_name_first" for issue in issues), "adapter_parse_ordinal_unpacked": not any(issue["code"] == "adapter_parse_ordinal_not_unpacked" for issue in issues), "adapter_contract_version": not any( issue["code"] in {"adapter_contract_version_missing", "mcp_contract_version_mismatch", "adapter_help_contract_version_missing"} for issue in issues ), "mcp_policy_blocks_missing_base_id": not any(issue["code"] == "mcp_policy_missing_base_id_not_blocked" for issue in issues), "mcp_policy_blocks_diagnostic_fallback": not any(issue["code"] == "mcp_policy_diagnostic_fallback_not_blocked" for issue in issues), "mcp_policy_allows_explicit_diagnostics": not any(issue["code"] == "mcp_policy_explicit_diagnostic_not_forwarded" for issue in issues), "mcp_selector_schema_template": not any( issue["code"] in {"mcp_selector_schema_properties_mismatch", "mcp_schema_requires_object_type_name_selector"} for issue in issues ), "selector_chain_live_coverage_schema": not any(issue["code"].startswith("selector_chain_coverage_") for issue in issues), "selector_chain_strict_composition_gate": not any(issue["code"].startswith("selector_chain_strict_") for issue in issues), "selector_chain_working_state": not any(issue["code"].startswith("selector_chain_working_state_") for issue in issues), "no_concrete_selector_values_in_production": not any(issue["code"] == "production_concrete_selector_value_present" for issue in issues), } def check_contract() -> dict[str, Any]: issues: list[dict[str, Any]] = [] methods = [str(row.get("name") or "") for row in adapter_server.METHODS if row.get("name")] method_set = set(methods) selector_guidance_terms = tuple(getattr(adapter_server, "OBJECT_SELECTOR_GUIDANCE_TERMS", ("ref", "kind/name/guid", "object_type/object_name/object_guid"))) adapter_contract_version = str(getattr(adapter_server, "ADAPTER_CONTRACT_VERSION", "")) mcp_contract_version = str(getattr(adapter_mcp, "MCP_CONTRACT_VERSION", "")) if not adapter_contract_version: issues.append({"code": "adapter_contract_version_missing"}) if adapter_contract_version != mcp_contract_version: issues.append({"code": "mcp_contract_version_mismatch", "adapter": adapter_contract_version, "mcp": mcp_contract_version}) duplicate_methods = sorted({name for name in methods if methods.count(name) > 1}) if duplicate_methods: issues.append({"code": "adapter_methods_not_unique", "methods": duplicate_methods}) shadowed_methods = sorted(method_set.intersection(getattr(adapter_mcp, "UNIFIED_METHODS", set()))) if shadowed_methods: issues.append({"code": "mcp_unified_shadows_adapter_methods", "methods": shadowed_methods}) mcp_selector_schema_args = set(getattr(adapter_mcp, "OBJECT_SELECTOR_SCHEMA_PROPERTIES", {})) adapter_selector_args = set(getattr(adapter_server, "OBJECT_SELECTOR_ARGUMENTS", [])) if mcp_selector_schema_args != adapter_selector_args: issues.append( { "code": "mcp_selector_schema_properties_mismatch", "mcp_arguments": sorted(mcp_selector_schema_args), "adapter_arguments": sorted(adapter_selector_args), } ) selector_capability_methods = set(getattr(adapter_server, "OBJECT_SELECTOR_METHOD_CAPABILITIES", {})) selector_alias_methods = set(getattr(adapter_server, "OBJECT_SELECTOR_ALIAS_METHODS", set())) if selector_capability_methods != selector_alias_methods: issues.append( { "code": "adapter_selector_capability_descriptor_mismatch", "capability_methods": sorted(selector_capability_methods), "alias_methods": sorted(selector_alias_methods), } ) mcp_source = Path(adapter_mcp.__file__).read_text(encoding="utf-8") adapter_source = Path(adapter_server.__file__).read_text(encoding="utf-8") for line_number, line in enumerate(adapter_source.splitlines(), start=1): if "parse_ordinal(" not in line or "=" not in line: continue left_side = line.split("=", 1)[0].strip() if "," not in left_side and not left_side.startswith(("return ", "raise ")): issues.append( { "code": "adapter_parse_ordinal_not_unpacked", "line": line_number, "source": line.strip(), } ) if '"required": ["base_id", "object_type", "object_name"]' in mcp_source: issues.append({"code": "mcp_schema_requires_object_type_name_selector"}) normalizer_check = adapter_server.normalize_object_selector_aliases({"kind": 123}, "contract.selector") if not isinstance(normalizer_check, dict) or normalizer_check.get("status") != "invalid_argument" or normalizer_check.get("argument") != "kind": issues.append( { "code": "adapter_selector_normalizer_type_not_validated", "result": normalizer_check, } ) required_message = str(getattr(adapter_server, "OBJECT_SELECTOR_REQUIRED_MESSAGE", "")) for term in (*selector_guidance_terms, "ordinal"): if term not in required_message: issues.append( { "code": "adapter_selector_required_message_incomplete", "term": term, "message": required_message, } ) selector_message_constants = { "OBJECT_SELECTOR_REQUIRED_MESSAGE_NO_ORDINAL": selector_guidance_terms, "OBJECT_SELECTOR_GLOBAL_REQUIRED_MESSAGE": (*selector_guidance_terms, "areas metadata/extensions"), "OBJECT_SELECTOR_OR_MODULE_REQUIRED_MESSAGE": (*selector_guidance_terms, "module_ref", "module_id"), "MODULE_READ_SELECTOR_OR_MODULE_ID_MESSAGE": (*selector_guidance_terms, "ordinal", "module_id"), } for constant_name, required_terms in selector_message_constants.items(): message = str(getattr(adapter_server, constant_name, "")) for term in required_terms: if term not in message: issues.append( { "code": "adapter_selector_message_constant_incomplete", "constant": constant_name, "term": term, "message": message, } ) selector_presence_cases = [ ({"ref": "Document.ObjectName"}, True), ({"object_name": "ObjectName"}, True), ({"object_guid": SYNTHETIC_SELECTOR_GUID}, True), ({"kind": "Document"}, False), ({"object_type": "Document"}, False), ] for payload, expected in selector_presence_cases: actual = adapter_server.has_object_selector(payload) if actual is not expected: issues.append( { "code": "adapter_selector_presence_alias_mismatch", "payload": payload, "expected": expected, "actual": actual, } ) read_selector = adapter_server.definition_read_selector( "contract_base", { "kind": "DataProcessor", "name": "ObjectName", "guid": SYNTHETIC_SELECTOR_GUID, }, method="metadata.object.get", ) if read_selector.get("ref") != "DataProcessor.ObjectName": issues.append( { "code": "adapter_definition_read_selector_public_ref_missing", "selector": read_selector, } ) module_read_selector = adapter_server.enrich_selector_with_object_ref( { "base_id": "contract_base", "method": "modules.read", "kind": "DataProcessor", "guid": SYNTHETIC_SELECTOR_GUID, "module_ordinal": 1, }, { "kind": "DataProcessor", "name": "ObjectName", "guid": SYNTHETIC_SELECTOR_GUID, }, ) if module_read_selector.get("ref") != "DataProcessor.ObjectName" or module_read_selector.get("name") != "ObjectName": issues.append( { "code": "adapter_module_read_selector_public_ref_missing", "selector": module_read_selector, } ) synthetic_live_steps = [ { "name": "metadata.resolve_overrides", "status": "not_found", "write_plan_evidence": True, "next_method": adapter_server.SAVED_STATE_MODULES_SEARCH_METHOD, }, { "name": adapter_server.SAVED_STATE_MODULES_SEARCH_METHOD, "status": "ok", "modules": 0, "write_plan_target": False, }, { "name": adapter_server.METADATA_WRITE_PLAN_METHOD, "status": "skipped_no_saved_state_target", "from_write_plan_target": False, }, ] live_coverage = selector_chain_smoke.live_coverage_from_steps(synthetic_live_steps) expected_coverage = { "resolve_overrides": { "attempted": True, "status": "not_found", "write_plan_evidence": True, "next_method": adapter_server.SAVED_STATE_MODULES_SEARCH_METHOD, }, "saved_state_resolution": { "attempted": True, "status": "ok", "modules": 0, "write_plan_target": False, }, "write_plan_composition": { "attempted": True, "status": "skipped_no_saved_state_target", "composed": False, "from_write_plan_target": False, }, } for section, expected in expected_coverage.items(): actual = live_coverage.get(section) if actual != expected: issues.append( { "code": "selector_chain_coverage_section_mismatch", "section": section, "expected": expected, "actual": actual, } ) if live_coverage.get("skips") != [{"step": adapter_server.METADATA_WRITE_PLAN_METHOD, "status": "skipped_no_saved_state_target"}]: issues.append({"code": "selector_chain_coverage_skips_mismatch", "actual": live_coverage.get("skips")}) strict_skip_issue = selector_chain_smoke.live_write_plan_composition_required_issue(live_coverage) if not isinstance(strict_skip_issue, dict) or strict_skip_issue.get("code") != "live_write_plan_composition_required": issues.append({"code": "selector_chain_strict_skip_not_blocked", "actual": strict_skip_issue}) composed_coverage = selector_chain_smoke.live_coverage_from_steps( [ { "name": "metadata.resolve_overrides", "status": "ok", "write_plan_evidence": True, "next_method": adapter_server.SAVED_STATE_MODULES_SEARCH_METHOD, }, { "name": adapter_server.SAVED_STATE_MODULES_SEARCH_METHOD, "status": "ok", "modules": 1, "write_plan_target": True, }, { "name": adapter_server.METADATA_WRITE_PLAN_METHOD, "status": "planned", "allowed": True, "from_write_plan_target": True, }, ] ) strict_composed_issue = selector_chain_smoke.live_write_plan_composition_required_issue(composed_coverage) if strict_composed_issue is not None: issues.append({"code": "selector_chain_strict_composed_blocked", "actual": strict_composed_issue}) selector_chain_report = selector_chain_smoke.build_report() if selector_chain_report.get("passed") is not True: issues.append({"code": "selector_chain_working_state_smoke_failed", "report": selector_chain_report}) for chain in selector_chain_report.get("chains") or []: if not isinstance(chain, dict): continue for step in chain.get("steps") or []: if not isinstance(step, dict) or step.get("method") not in selector_chain_smoke.WORKING_STATE_METHODS: continue payload = step.get("payload") if isinstance(step.get("payload"), dict) else {} if payload.get("source_state") != "working" and payload.get("state") != "working": issues.append( { "code": "selector_chain_working_state_missing", "chain": chain.get("name"), "method": step.get("method"), } ) production_selector_files = iter_production_selector_contract_files() for path in production_selector_files: if not path.exists(): continue text = path.read_text(encoding="utf-8", errors="replace") for value in FORBIDDEN_CONCRETE_SELECTOR_VALUES: if value in text: issues.append( { "code": "production_concrete_selector_value_present", "path": str(path.relative_to(ROOT)), "value": value, } ) public_methods = { str(row.get("name") or ""): row for row in adapter_server.call_method("help.methods", {}).get("methods", []) if isinstance(row, dict) and row.get("name") } help_result = adapter_server.call_method("help.methods", {}) if help_result.get("contract_version") != adapter_contract_version: issues.append( { "code": "adapter_help_contract_version_missing", "expected": adapter_contract_version, "actual": help_result.get("contract_version"), } ) agent_facing_object_terms = ("object", "module", "form", "template", "code") for row in adapter_server.METHODS: method = str(row.get("name") or "") description = str(row.get("description") or "") normalized_description = description.casefold() if "agent-facing" not in normalized_description: continue if not any(term in normalized_description for term in agent_facing_object_terms): continue if method not in selector_alias_methods: issues.append( { "code": "agent_facing_object_method_not_name_first", "method": method, "description": description, } ) for method in sorted(getattr(adapter_server, "OBJECT_SELECTOR_ALIAS_METHODS", set())): capabilities = (public_methods.get(method) or {}).get("selector_capabilities") if not isinstance(capabilities, dict) or not capabilities.get("accepts_ref") or not capabilities.get("accepts_object_aliases"): issues.append( { "code": "adapter_help_selector_capabilities_missing", "method": method, "selector_capabilities": capabilities, } ) description = str((public_methods.get(method) or {}).get("description") or "") for term in selector_guidance_terms: if term not in description: issues.append( { "code": "adapter_help_selector_guidance_missing", "method": method, "term": term, "description": description, } ) for argument in getattr(adapter_server, "OBJECT_SELECTOR_ARGUMENTS", ["ref"]): selector_check = adapter_server.validate_adapter_job_payload(method, {"base_id": "contract_base", argument: 123}) if ( not isinstance(selector_check, dict) or selector_check.get("status") != "invalid_argument" or selector_check.get("argument") != argument ): issues.append( { "code": "adapter_selector_argument_type_not_validated", "method": method, "argument": argument, "result": selector_check, } ) onec_request = find_tool("onec_request") if not onec_request: issues.append({"code": "mcp_onec_request_missing"}) else: schema = onec_request.get("inputSchema") or {} tool_description = str(onec_request.get("description") or "") for term in selector_guidance_terms: if term not in tool_description: issues.append( { "code": "mcp_tool_selector_guidance_missing", "term": term, "description": tool_description, } ) examples_json = json.dumps(schema.get("examples") or [], ensure_ascii=False) if "." not in examples_json: issues.append({"code": "mcp_examples_public_ref_placeholder_missing"}) examples_by_method = { str(example.get("method") or ""): example.get("payload") for example in schema.get("examples") or [] if isinstance(example, dict) and isinstance(example.get("payload"), dict) } saved_state_example_contract = { "metadata.saved_state.prepare": {"required": {"layer", "ref"}, "forbidden": {"target_table", "table", "file_name", "module_ref"}}, "metadata.saved_state.diff": {"required": {"ref", "module_ordinal"}, "forbidden": {"target_table", "table", "file_name", "module_ref"}}, "metadata.saved_state.status": {"required": {"layer"}, "forbidden": {"target_table", "table"}}, "metadata.saved_state.changes.list": {"required": {"layer"}, "forbidden": {"target_table", "table"}}, } for method, requirements in saved_state_example_contract.items(): example_payload = examples_by_method.get(method) if not isinstance(example_payload, dict): issues.append({"code": "mcp_saved_state_example_missing", "method": method}) continue missing = sorted(requirements["required"] - set(example_payload)) forbidden = sorted(requirements["forbidden"].intersection(example_payload)) if missing: issues.append({"code": "mcp_saved_state_example_public_selector_missing", "method": method, "fields": missing}) if forbidden: issues.append({"code": "mcp_saved_state_example_storage_selector_present", "method": method, "fields": forbidden}) if schema.get("required") != ["method"]: issues.append({"code": "mcp_onec_request_required_not_generic", "required": schema.get("required")}) if "oneOf" in schema: issues.append({"code": "mcp_onec_request_enumerates_adapter_methods"}) payload_schema = (schema.get("properties") or {}).get("payload") or {} if payload_schema.get("additionalProperties") is not True: issues.append({"code": "mcp_payload_not_open", "payload_schema": payload_schema}) payload_description = str(payload_schema.get("description") or "") for term in selector_guidance_terms: if term not in payload_description: issues.append( { "code": "mcp_payload_selector_guidance_missing", "term": term, "description": payload_description, } ) calls: list[tuple[str, str, Any]] = [] def fake_http_json(method: str, path: str, body: Any | None = None, *, timeout: float | None = None, request_id: str | None = None) -> dict[str, Any]: calls.append((method, path, body)) return {"status": "ok", "method": body.get("method") if isinstance(body, dict) else "health"} original_http_json = adapter_mcp.http_json previous_diagnostic_mode = os.environ.get("ONEC_MCP_ALLOW_DIAGNOSTIC") os.environ["ONEC_MCP_ALLOW_DIAGNOSTIC"] = "true" adapter_mcp.http_json = fake_http_json try: calls.clear() missing_base_id_result = adapter_mcp.run_or_enqueue_adapter_method("metadata.write.plan", {}) if calls or not isinstance(missing_base_id_result, dict) or missing_base_id_result.get("schema") != "adapter_1c_mcp_policy.v1" or missing_base_id_result.get("reason") != "base_id_required": issues.append( { "code": "mcp_policy_missing_base_id_not_blocked", "result": missing_base_id_result, "calls": calls, } ) calls.clear() diagnostic_fallback_result = adapter_mcp.run_or_enqueue_adapter_method("storage.files.list", {"base_id": "contract_base"}) if calls or not isinstance(diagnostic_fallback_result, dict) or diagnostic_fallback_result.get("schema") != "adapter_1c_mcp_policy.v1" or diagnostic_fallback_result.get("reason") != "diagnostic_method": issues.append( { "code": "mcp_policy_diagnostic_fallback_not_blocked", "result": diagnostic_fallback_result, "calls": calls, } ) calls.clear() adapter_mcp.run_or_enqueue_adapter_method("storage.files.list", {"base_id": "contract_base", "diagnostic": True}) if not calls: issues.append({"code": "mcp_policy_explicit_diagnostic_not_forwarded"}) for method in methods: calls.clear() adapter_mcp.call_adapter_method(method, {"base_id": "contract_base"}) if method == "health": expected = ("GET", "/health?base_id=contract_base") if not calls or calls[0][0:2] != expected: issues.append({"code": "mcp_health_not_get_health", "method": method, "calls": calls}) continue if not calls: issues.append({"code": "mcp_method_not_forwarded", "method": method}) continue http_method, path, body = calls[0] if http_method != "POST" or path != "/rpc": issues.append({"code": "mcp_method_not_rpc", "method": method, "calls": calls}) continue if not isinstance(body, dict) or body.get("method") != method: issues.append({"code": "mcp_rpc_body_method_mismatch", "method": method, "body": body}) finally: adapter_mcp.http_json = original_http_json if previous_diagnostic_mode is None: os.environ.pop("ONEC_MCP_ALLOW_DIAGNOSTIC", None) else: os.environ["ONEC_MCP_ALLOW_DIAGNOSTIC"] = previous_diagnostic_mode return { "schema": "onec_mcp_adapter_contract_check.v1", "passed": not issues, "adapter_methods": len(methods), "production_selector_files": len(production_selector_files), "mcp_tools": [tool.get("name") for tool in adapter_mcp.TOOLS], "checks": contract_checks(issues, onec_request_present=onec_request is not None), "issues": issues, } def main() -> int: parser = argparse.ArgumentParser(description="Check that adapter-1c MCP stays a generic proxy for REST adapter methods.") parser.add_argument("--json", action="store_true", help="Print JSON report.") args = parser.parse_args() report = check_contract() if args.json: print(json.dumps(report, ensure_ascii=False, indent=2)) elif report["passed"]: print(f"OK: MCP generic proxy contract passed for {report['adapter_methods']} adapter methods.") else: print(json.dumps(report, ensure_ascii=False, indent=2), file=sys.stderr) return 0 if report["passed"] else 1 if __name__ == "__main__": raise SystemExit(main())