Files
llm/scripts/smoke_1c_mcp_selector_chain.py
T

1085 lines
56 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env python3
from __future__ import annotations
import argparse
import json
import os
import re
import socket
import sys
from pathlib import Path
from typing import Any
from urllib.error import HTTPError, URLError
from urllib.request import Request, urlopen
if hasattr(sys.stdout, "reconfigure"):
sys.stdout.reconfigure(encoding="utf-8")
if hasattr(sys.stderr, "reconfigure"):
sys.stderr.reconfigure(encoding="utf-8")
ROOT = Path(__file__).resolve().parents[1]
DEFAULT_REPORT = ROOT / "reports" / "1c-mcp-selector-chain-smoke.json"
EXPECTED_CONTRACT_VERSION = "onec-selector-contract.v1"
FORBIDDEN_CONCRETE_SELECTOR_VALUES = (
"ПечатьЭтикеток",
"УОП_Печать",
"ЦенаСоСкидкой",
"fs_Отчеты",
)
DEFAULT_DISCOVERY_KINDS = ("DataProcessor", "Report", "Document", "Catalog", "InformationRegister")
LIVE_HELP_SELECTOR_METHODS = (
"metadata.definition.find",
"metadata.object.modules",
"modules.read",
"code.search",
"code.read",
)
WORKING_STATE_METHODS = {
"extension.objects.find",
"metadata.resolve_overrides",
"modules.search",
"code.search",
}
SEARCH_TOKEN_SKIP = {
"procedure",
"function",
"endprocedure",
"endfunction",
"процедура",
"функция",
"конецпроцедуры",
"конецфункции",
}
IDENTIFIER_LIKE_RE = re.compile(r"^[A-Za-zА-Яа-я_][A-Za-zА-Яа-я0-9_]*$")
def is_identifier_like(value: Any) -> bool:
return bool(IDENTIFIER_LIKE_RE.fullmatch(str(value or "").strip()))
def text_from_module_read(result: dict[str, Any]) -> str:
for key in ("text", "preview"):
value = result.get(key)
if isinstance(value, str) and value.strip():
return value
selection = result.get("selection")
if isinstance(selection, dict):
for key in ("text", "preview"):
value = selection.get(key)
if isinstance(value, str) and value.strip():
return value
return ""
def search_token_from_text(text: str) -> str | None:
for match in re.finditer(r"[A-Za-zА-Яа-я_][A-Za-zА-Яа-я0-9_]{3,}", text):
token = match.group(0)
if token.casefold() not in SEARCH_TOKEN_SKIP:
return token
return None
def search_token_from_module_read(result: dict[str, Any]) -> tuple[str | None, str | None]:
text_token = search_token_from_text(text_from_module_read(result))
if text_token:
return text_token, "modules.read.text"
for routine in result.get("routines") or []:
if not isinstance(routine, dict):
continue
name = routine.get("name")
if is_identifier_like(name):
return str(name), "modules.read.routines"
return "Процедура", "generic_bsl_keyword"
def routine_name_from_module_read(result: dict[str, Any]) -> str | None:
for routine in result.get("routines") or []:
if not isinstance(routine, dict):
continue
name = routine.get("name")
if is_identifier_like(name):
return str(name)
selection = result.get("selection")
if isinstance(selection, dict):
for key in ("routine_name", "name"):
name = selection.get(key)
if is_identifier_like(name):
return str(name)
return None
def first_write_plan_target_from_saved_state(result: dict[str, Any]) -> dict[str, Any] | None:
for module in result.get("modules") or []:
if not isinstance(module, dict):
continue
for stream in module.get("streams") or []:
if not isinstance(stream, dict):
continue
target = stream.get("write_plan_target")
if not isinstance(target, dict):
continue
has_internal_handle = bool(target.get("module_ref") and target.get("expected_sha1"))
has_public_selector = bool(target.get("ref") and target.get("module"))
if has_internal_handle or has_public_selector:
return target
return None
def live_coverage_from_steps(steps: list[dict[str, Any]]) -> dict[str, Any]:
def last_step(name: str) -> dict[str, Any]:
for step in reversed(steps):
if isinstance(step, dict) and step.get("name") == name:
return step
return {}
resolve = last_step("metadata.resolve_overrides")
saved_state = last_step("metadata.saved_state.modules.search")
write_plan = last_step("metadata.write.plan")
skips = [
{"step": str(step.get("name")), "status": step.get("status"), **({"reason": step.get("reason")} if step.get("reason") else {})}
for step in steps
if isinstance(step.get("status"), str) and str(step.get("status")).startswith("skipped")
]
return {
"resolve_overrides": {
"attempted": bool(resolve),
"status": resolve.get("status"),
"write_plan_evidence": bool(resolve.get("write_plan_evidence")),
"next_method": resolve.get("next_method"),
},
"saved_state_resolution": {
"attempted": bool(saved_state),
"status": saved_state.get("status"),
"modules": int(saved_state.get("modules") or 0),
"write_plan_target": bool(saved_state.get("write_plan_target")),
},
"write_plan_composition": {
"attempted": bool(write_plan),
"status": write_plan.get("status"),
"composed": write_plan.get("allowed") is True,
"from_write_plan_target": bool(write_plan.get("from_write_plan_target")),
},
"skips": skips,
}
def live_write_plan_composition_required_issue(coverage: dict[str, Any]) -> dict[str, Any] | None:
composition = coverage.get("write_plan_composition") if isinstance(coverage.get("write_plan_composition"), dict) else {}
if composition.get("composed") is True:
return None
return {
"code": "live_write_plan_composition_required",
"status": composition.get("status"),
"saved_state_resolution": coverage.get("saved_state_resolution"),
}
def live_object_summary(*sources: dict[str, Any] | None) -> dict[str, Any]:
summary: dict[str, Any] = {}
for source in sources:
if not isinstance(source, dict):
continue
for key in ("kind", "name", "guid", "ref"):
value = source.get(key)
if value is not None and value != "":
summary[key] = value
if "ref" not in summary and summary.get("kind") and summary.get("name"):
summary["ref"] = f"{summary['kind']}.{summary['name']}"
return summary
def selector_from_ref(ref: str | None) -> dict[str, str]:
text = str(ref or "").strip()
if "." not in text:
return {}
kind, name = text.split(".", 1)
if not kind.strip() or not name.strip():
return {}
return {"kind": kind.strip(), "name": name.strip(), "ref": text}
def post_json(url: str, payload: dict[str, Any], *, timeout: float, headers: dict[str, str] | None = None) -> tuple[dict[str, str], dict[str, Any]]:
request = Request(
url,
data=json.dumps(payload, ensure_ascii=False).encode("utf-8"),
method="POST",
headers={"Content-Type": "application/json", **(headers or {})},
)
try:
with urlopen(request, timeout=timeout) as response:
raw = response.read().decode("utf-8")
data = json.loads(raw) if raw else {}
return dict(response.headers), data if isinstance(data, dict) else {"status": "error", "error": "response is not an object", "response": data}
except (TimeoutError, socket.timeout) as exc:
return {}, {"status": "error", "error": "timeout", "message": str(exc)}
def rest_rpc_call(adapter_url: str, method: str, payload: dict[str, Any], *, timeout: float) -> dict[str, Any]:
body = json.dumps({"method": method, "payload": payload}, ensure_ascii=False).encode("utf-8")
service_token = os.environ.get("ONEC_ADAPTER_SERVICE_TOKEN", "").strip()
headers = {"Content-Type": "application/json"}
if service_token:
headers["Authorization"] = f"Bearer {service_token}"
request = Request(
adapter_url.rstrip("/") + "/rpc",
data=body,
method="POST",
headers=headers,
)
try:
with urlopen(request, timeout=timeout) as response:
raw = response.read().decode("utf-8")
data = json.loads(raw) if raw else {}
return data if isinstance(data, dict) else {"status": "error", "error": "rpc response is not an object", "response": data}
except HTTPError as exc:
raw = exc.read().decode("utf-8", errors="replace")
return {"status": "error", "error": "http_error", "http_status": exc.code, "response": raw}
except URLError as exc:
return {"status": "error", "error": "url_error", "message": str(exc)}
except (TimeoutError, socket.timeout) as exc:
return {"status": "error", "error": "timeout", "message": str(exc)}
def mcp_rpc_call(mcp_url: str, method: str, payload: dict[str, Any], *, timeout: float, session_id: str | None = None) -> dict[str, Any]:
headers = {"Accept": "application/json, text/event-stream"}
if session_id:
headers["Mcp-Session-Id"] = session_id
body = {
"jsonrpc": "2.0",
"id": f"smoke-{method}",
"method": "tools/call",
"params": {"name": "onec_request", "arguments": {"method": method, "payload": payload}},
}
try:
_, data = post_json(mcp_url.rstrip("/") + "/mcp", body, timeout=timeout, headers=headers)
except HTTPError as exc:
raw = exc.read().decode("utf-8", errors="replace")
return {"status": "error", "error": "mcp_http_error", "http_status": exc.code, "response": raw}
except URLError as exc:
return {"status": "error", "error": "mcp_url_error", "message": str(exc)}
if "error" in data:
return {"status": "error", "error": "mcp_jsonrpc_error", "details": data.get("error")}
result = data.get("result") if isinstance(data.get("result"), dict) else {}
content = result.get("content") if isinstance(result, dict) else None
if not isinstance(content, list) or not content:
return {"status": "error", "error": "mcp_tool_content_missing", "result": result}
first = content[0] if isinstance(content[0], dict) else {}
text = first.get("text")
if not isinstance(text, str):
return {"status": "error", "error": "mcp_tool_text_missing", "content": content}
try:
decoded = json.loads(text)
except json.JSONDecodeError as exc:
return {"status": "error", "error": "mcp_tool_text_not_json", "message": str(exc), "text": text[:500]}
return decoded if isinstance(decoded, dict) else {"status": "error", "error": "mcp_tool_payload_not_object", "payload": decoded}
def mcp_initialize(mcp_url: str, *, timeout: float) -> tuple[str | None, dict[str, Any]]:
body = {
"jsonrpc": "2.0",
"id": "smoke-initialize",
"method": "initialize",
"params": {
"protocolVersion": "2025-06-18",
"capabilities": {},
"clientInfo": {"name": "smoke_1c_mcp_selector_chain", "version": "1"},
},
}
try:
headers, data = post_json(
mcp_url.rstrip("/") + "/mcp",
body,
timeout=timeout,
headers={"Accept": "application/json, text/event-stream"},
)
except HTTPError as exc:
raw = exc.read().decode("utf-8", errors="replace")
return None, {"status": "error", "error": "mcp_initialize_http_error", "http_status": exc.code, "response": raw}
except URLError as exc:
return None, {"status": "error", "error": "mcp_initialize_url_error", "message": str(exc)}
except (TimeoutError, socket.timeout) as exc:
return None, {"status": "error", "error": "mcp_initialize_timeout", "message": str(exc)}
if "error" in data:
return None, {"status": "error", "error": "mcp_initialize_jsonrpc_error", "details": data.get("error")}
return headers.get("Mcp-Session-Id"), data
def mcp_onec_request_schema_check(mcp_url: str, *, timeout: float, session_id: str | None) -> tuple[dict[str, Any], list[dict[str, Any]]]:
headers = {"Accept": "application/json, text/event-stream"}
if session_id:
headers["Mcp-Session-Id"] = session_id
body = {"jsonrpc": "2.0", "id": "smoke-tools-list", "method": "tools/list", "params": {}}
try:
_, data = post_json(mcp_url.rstrip("/") + "/mcp", body, timeout=timeout, headers=headers)
except HTTPError as exc:
raw = exc.read().decode("utf-8", errors="replace")
return {"status": "error", "error": "mcp_tools_list_http_error", "http_status": exc.code}, [{"code": "live_mcp_tools_list_failed", "response": raw}]
except URLError as exc:
return {"status": "error", "error": "mcp_tools_list_url_error", "message": str(exc)}, [{"code": "live_mcp_tools_list_failed", "message": str(exc)}]
if "error" in data:
return {"status": "error", "error": "mcp_tools_list_jsonrpc_error", "details": data.get("error")}, [{"code": "live_mcp_tools_list_failed", "details": data.get("error")}]
result = data.get("result") if isinstance(data.get("result"), dict) else {}
tools = result.get("tools") or []
onec_request = next((tool for tool in tools if isinstance(tool, dict) and tool.get("name") == "onec_request"), None)
contract_version = result.get("contract_version")
summary = {
"status": "ok",
"contract_version": contract_version,
"tools": len(tools),
"onec_request": bool(onec_request),
"selector_guidance": False,
"placeholder_examples": False,
}
issues: list[dict[str, Any]] = []
if contract_version != EXPECTED_CONTRACT_VERSION:
issues.append({"code": "live_mcp_contract_version_mismatch", "expected": EXPECTED_CONTRACT_VERSION, "actual": contract_version})
if not isinstance(onec_request, dict):
issues.append({"code": "live_mcp_onec_request_missing"})
return summary, issues
schema = onec_request.get("inputSchema") if isinstance(onec_request.get("inputSchema"), dict) else {}
description = str(onec_request.get("description") or "")
payload_schema = (schema.get("properties") or {}).get("payload") or {}
payload_description = str(payload_schema.get("description") or "")
selector_terms = ("ref", "kind/name/guid", "object_type/object_name/object_guid")
summary["selector_guidance"] = all(term in description and term in payload_description for term in selector_terms)
summary["placeholder_examples"] = "<metadata-kind>.<metadata-object-name>" in json.dumps(schema.get("examples") or [], ensure_ascii=False)
if schema.get("required") != ["method"]:
issues.append({"code": "live_mcp_onec_request_required_not_generic", "required": schema.get("required")})
if "oneOf" in schema:
issues.append({"code": "live_mcp_onec_request_enumerates_methods"})
for term in selector_terms:
if term not in description or term not in payload_description:
issues.append({"code": "live_mcp_selector_guidance_missing", "term": term})
if not summary["placeholder_examples"]:
issues.append({"code": "live_mcp_placeholder_examples_missing"})
return summary, issues
def rpc_call(endpoint_url: str, method: str, payload: dict[str, Any], *, timeout: float, transport: str = "rest", session_id: str | None = None) -> dict[str, Any]:
if transport == "mcp":
return mcp_rpc_call(endpoint_url, method, payload, timeout=timeout, session_id=session_id)
return rest_rpc_call(endpoint_url, method, payload, timeout=timeout)
def with_working_state(payload: dict[str, Any], *, transport: str) -> dict[str, Any]:
if payload.get("state") or payload.get("source_state"):
return payload
if transport == "mcp":
return {**payload, "source_state": "working"}
return {**payload, "state": "working"}
def adapter_help_methods_contract(endpoint_url: str, *, timeout: float, transport: str, session_id: str | None = None) -> tuple[dict[str, Any], list[dict[str, Any]]]:
result = rpc_call(endpoint_url, "help.methods", {}, timeout=timeout, transport=transport, session_id=session_id)
methods = {
str(item.get("name") or ""): item
for item in result.get("methods") or []
if isinstance(item, dict) and item.get("name")
}
contract_version = result.get("contract_version")
summary = {"status": result.get("status") or ("ok" if methods else "error"), "contract_version": contract_version, "methods": len(methods), "selector_capabilities": False}
issues: list[dict[str, Any]] = []
if contract_version != EXPECTED_CONTRACT_VERSION:
issues.append({"code": "live_adapter_contract_version_mismatch", "expected": EXPECTED_CONTRACT_VERSION, "actual": contract_version})
if not methods or result.get("status") == "error":
return summary, [{"code": "live_adapter_help_methods_failed", "status": result.get("status"), "error": result.get("error"), "diagnostics": result.get("diagnostics")}]
for method in LIVE_HELP_SELECTOR_METHODS:
capabilities = (methods.get(method) or {}).get("selector_capabilities")
if not isinstance(capabilities, dict):
issues.append({"code": "live_adapter_selector_capabilities_missing", "method": method})
continue
if not capabilities.get("accepts_ref") or not capabilities.get("accepts_object_aliases"):
issues.append({"code": "live_adapter_selector_capabilities_incomplete", "method": method, "selector_capabilities": capabilities})
definition_capabilities = (methods.get("metadata.definition.find") or {}).get("selector_capabilities") or {}
if not definition_capabilities.get("allows_global_areas") or not definition_capabilities.get("requires_query"):
issues.append({"code": "live_adapter_definition_capabilities_incomplete", "selector_capabilities": definition_capabilities})
if not ((methods.get("modules.read") or {}).get("selector_capabilities") or {}).get("accepts_module_ref"):
issues.append({"code": "live_adapter_modules_read_module_ref_missing"})
if not ((methods.get("code.read") or {}).get("selector_capabilities") or {}).get("accepts_module_ref"):
issues.append({"code": "live_adapter_code_read_module_ref_missing"})
if not ((methods.get("code.search") or {}).get("selector_capabilities") or {}).get("requires_query"):
issues.append({"code": "live_adapter_code_search_requires_query_missing"})
summary["selector_capabilities"] = not issues
return summary, issues
def first_object_with_modules(endpoint_url: str, base_id: str, *, timeout: float, kinds: tuple[str, ...], transport: str = "rest", session_id: str | None = None) -> tuple[dict[str, Any] | None, list[dict[str, Any]]]:
attempts: list[dict[str, Any]] = []
for kind in kinds:
listed = rpc_call(
endpoint_url,
"metadata.objects.list",
{"base_id": base_id, "kind": kind, "limit": 50, "include_storage": False},
timeout=timeout,
transport=transport,
session_id=session_id,
)
objects = [item for item in listed.get("objects") or [] if isinstance(item, dict)]
objects = sorted(objects, key=lambda item: (not is_identifier_like(item.get("name")), str(item.get("name") or "")))
attempts.append({"method": "metadata.objects.list", "kind": kind, "status": listed.get("status"), "objects": len(objects)})
for obj in objects:
if not obj.get("name") and not obj.get("guid"):
continue
selector = {
"base_id": base_id,
"kind": obj.get("kind") or kind,
**({"name": obj.get("name")} if obj.get("name") else {}),
**({"guid": obj.get("guid")} if obj.get("guid") else {}),
"include_storage": False,
}
modules = rpc_call(endpoint_url, "metadata.object.modules", selector, timeout=timeout, transport=transport, session_id=session_id)
module_items = [item for item in modules.get("modules") or [] if isinstance(item, dict)]
attempts.append(
{
"method": "metadata.object.modules",
"kind": selector.get("kind"),
"status": modules.get("status"),
"modules": len(module_items),
}
)
if modules.get("status") == "ok" and module_items:
module_owner = modules.get("object") if isinstance(modules.get("object"), dict) else {}
object_card: dict[str, Any] = {}
if obj.get("guid") or module_owner.get("guid"):
card = rpc_call(
endpoint_url,
"metadata.object.get",
{
"base_id": base_id,
"kind": module_owner.get("kind") or obj.get("kind") or kind,
"guid": module_owner.get("guid") or obj.get("guid"),
"include_semantic": False,
},
timeout=timeout,
transport=transport,
session_id=session_id,
)
object_card = card.get("object") if isinstance(card.get("object"), dict) else {}
attempts.append({"method": "metadata.object.get", "kind": selector.get("kind"), "status": card.get("status"), "resolved": bool(object_card)})
merged_object = {
**obj,
**{key: value for key, value in module_owner.items() if value is not None and value != ""},
**{key: value for key, value in object_card.items() if value is not None and value != ""},
}
return {"object": merged_object, "modules": modules, "module": module_items[0]}, attempts
return None, attempts
def selected_object_with_modules(endpoint_url: str, base_id: str, selector: dict[str, Any], *, timeout: float, transport: str = "rest", session_id: str | None = None) -> tuple[dict[str, Any] | None, list[dict[str, Any]]]:
attempts: list[dict[str, Any]] = []
payload = {"base_id": base_id, **{key: value for key, value in selector.items() if value is not None and value != ""}, "include_storage": False}
modules = rpc_call(endpoint_url, "metadata.object.modules", payload, timeout=timeout, transport=transport, session_id=session_id)
module_items = [item for item in modules.get("modules") or [] if isinstance(item, dict)]
attempts.append({"method": "metadata.object.modules", "kind": payload.get("kind"), "status": modules.get("status"), "modules": len(module_items), "selected": True})
if modules.get("status") != "ok" or not module_items:
return None, attempts
module_owner = modules.get("object") if isinstance(modules.get("object"), dict) else {}
object_card: dict[str, Any] = {}
get_payload = {
"base_id": base_id,
**({"kind": module_owner.get("kind") or payload.get("kind")} if module_owner.get("kind") or payload.get("kind") else {}),
**({"name": module_owner.get("name") or payload.get("name")} if module_owner.get("name") or payload.get("name") else {}),
**({"guid": module_owner.get("guid") or payload.get("guid")} if module_owner.get("guid") or payload.get("guid") else {}),
**({"ref": module_owner.get("ref") or payload.get("ref")} if module_owner.get("ref") or payload.get("ref") else {}),
"include_semantic": False,
}
card = rpc_call(endpoint_url, "metadata.object.get", get_payload, timeout=timeout, transport=transport, session_id=session_id)
object_card = card.get("object") if isinstance(card.get("object"), dict) else {}
attempts.append({"method": "metadata.object.get", "kind": get_payload.get("kind"), "status": card.get("status"), "resolved": bool(object_card), "selected": True})
merged_object = {
**selector,
**{key: value for key, value in module_owner.items() if value is not None and value != ""},
**{key: value for key, value in object_card.items() if value is not None and value != ""},
}
return {"object": merged_object, "modules": modules, "module": module_items[0]}, attempts
def build_live_report(
endpoint_url: str,
base_id: str,
*,
timeout: float,
kinds: tuple[str, ...] = DEFAULT_DISCOVERY_KINDS,
transport: str = "rest",
object_selector: dict[str, Any] | None = None,
require_write_plan_composition: bool = False,
) -> dict[str, Any]:
steps: list[dict[str, Any]] = []
issues: list[dict[str, Any]] = []
session_id: str | None = None
if transport == "mcp":
session_id, initialize_result = mcp_initialize(endpoint_url, timeout=timeout)
initialize_contract_version = (((initialize_result.get("result") or {}).get("serverInfo") or {}).get("contract_version") if isinstance(initialize_result.get("result"), dict) else None)
steps.append(
{
"name": "mcp.initialize",
"status": "ok" if session_id and initialize_contract_version == EXPECTED_CONTRACT_VERSION else "error",
"session": bool(session_id),
"contract_version": initialize_contract_version,
}
)
if not session_id:
issues.append({"code": "live_mcp_initialize_failed", "result": initialize_result})
return {"schema": "onec_mcp_selector_chain_live_smoke.v1", "passed": False, "endpoint_url": endpoint_url, "transport": transport, "steps": steps, "issues": issues}
if initialize_contract_version != EXPECTED_CONTRACT_VERSION:
issues.append({"code": "live_mcp_initialize_contract_version_mismatch", "expected": EXPECTED_CONTRACT_VERSION, "actual": initialize_contract_version})
return {"schema": "onec_mcp_selector_chain_live_smoke.v1", "passed": False, "endpoint_url": endpoint_url, "transport": transport, "steps": steps, "issues": issues}
schema_summary, schema_issues = mcp_onec_request_schema_check(endpoint_url, timeout=timeout, session_id=session_id)
steps.append(
{
"name": "mcp.tools.list",
"status": "ok" if not schema_issues else "error",
"contract_version": schema_summary.get("contract_version"),
"onec_request": bool(schema_summary.get("onec_request")),
"selector_guidance": bool(schema_summary.get("selector_guidance")),
"placeholder_examples": bool(schema_summary.get("placeholder_examples")),
}
)
if schema_issues:
issues.extend(schema_issues)
return {"schema": "onec_mcp_selector_chain_live_smoke.v1", "passed": False, "endpoint_url": endpoint_url, "transport": transport, "steps": steps, "issues": issues}
help_summary, help_issues = adapter_help_methods_contract(endpoint_url, timeout=timeout, transport=transport, session_id=session_id)
steps.append(
{
"name": "adapter.help.methods",
"status": "ok" if not help_issues else "error",
"contract_version": help_summary.get("contract_version"),
"methods": int(help_summary.get("methods") or 0),
"selector_capabilities": bool(help_summary.get("selector_capabilities")),
}
)
if help_issues:
issues.extend(help_issues)
return {"schema": "onec_mcp_selector_chain_live_smoke.v1", "passed": False, "endpoint_url": endpoint_url, "transport": transport, "steps": steps, "issues": issues}
if object_selector:
discovered, discovery_attempts = selected_object_with_modules(endpoint_url, base_id, object_selector, timeout=timeout, transport=transport, session_id=session_id)
else:
discovered, discovery_attempts = first_object_with_modules(endpoint_url, base_id, timeout=timeout, kinds=kinds, transport=transport, session_id=session_id)
steps.extend({"name": "discover", **attempt} for attempt in discovery_attempts)
if not discovered:
issues.append({"code": "live_object_with_modules_not_found", **({"selector": object_selector} if object_selector else {"kinds": list(kinds)})})
return {"schema": "onec_mcp_selector_chain_live_smoke.v1", "passed": False, "steps": steps, "issues": issues}
obj = discovered["object"]
object_kind = obj.get("kind")
object_name = obj.get("name")
object_guid = obj.get("guid")
object_ref = f"{object_kind}.{object_name}" if object_kind and object_name else None
if not object_ref and not object_guid:
issues.append({"code": "live_discovered_object_ref_missing", "object": obj})
return {"schema": "onec_mcp_selector_chain_live_smoke.v1", "passed": False, "steps": steps, "issues": issues}
definition_query = str(object_guid or object_ref)
definition_payload = {
"base_id": base_id,
"query": definition_query,
"areas": ["metadata"],
"max_matches": 5,
"exact_only": True,
"timeout_seconds": int(timeout),
**({"kind": object_kind} if object_kind else {}),
**({"guid": object_guid} if object_guid else {}),
}
if object_ref and is_identifier_like(object_name):
definition_payload["ref"] = object_ref
definition = rpc_call(
endpoint_url,
"metadata.definition.find",
definition_payload,
timeout=timeout,
transport=transport,
session_id=session_id,
)
steps.append({"name": "metadata.definition.find", "status": definition.get("status"), "ref_present": bool(definition_payload.get("ref")), "guid_present": bool(definition_payload.get("guid"))})
related = definition.get("related_selectors") if isinstance(definition.get("related_selectors"), dict) else {}
modules_selector = related.get("modules") if isinstance(related.get("modules"), dict) else None
canonical_object = definition.get("object") if isinstance(definition.get("object"), dict) else {}
if definition.get("status") != "ok" or not modules_selector:
issues.append(
{
"code": "live_definition_related_modules_missing",
"status": definition.get("status"),
"error": definition.get("error"),
"diagnostics": definition.get("diagnostics"),
}
)
return {
"schema": "onec_mcp_selector_chain_live_smoke.v1",
"passed": False,
"endpoint_url": endpoint_url,
"transport": transport,
"base_id": base_id,
"object": live_object_summary({"kind": object_kind, "name": object_name, "guid": object_guid, "ref": object_ref}, canonical_object, modules_selector),
"steps": steps,
"issues": issues,
}
canonical_summary = live_object_summary({"kind": object_kind, "name": object_name, "guid": object_guid, "ref": object_ref}, canonical_object, modules_selector)
object_kind = canonical_summary.get("kind") or object_kind
object_name = canonical_summary.get("name") or object_name
object_guid = canonical_summary.get("guid") or object_guid
object_ref = canonical_summary.get("ref") or object_ref
if not modules_selector.get("ref"):
issues.append({"code": "live_related_selector_ref_missing", "selector": modules_selector})
modules = rpc_call(endpoint_url, str(modules_selector.get("method") or "metadata.object.modules"), modules_selector, timeout=timeout, transport=transport, session_id=session_id)
module_items = [item for item in modules.get("modules") or [] if isinstance(item, dict)]
steps.append({"name": "related_selectors.modules", "status": modules.get("status"), "modules": len(module_items)})
if modules.get("status") != "ok" or not module_items:
issues.append({"code": "live_related_modules_empty", "status": modules.get("status")})
return {"schema": "onec_mcp_selector_chain_live_smoke.v1", "passed": False, "steps": steps, "issues": issues}
module_ordinal = module_items[0].get("module_ordinal") or 1
read_payload = {
**{key: value for key, value in modules_selector.items() if key != "method" and value is not None and value != ""},
"base_id": modules_selector.get("base_id") or base_id,
"module_ordinal": module_ordinal,
"preview": True,
"max_chars": 1000,
}
if not read_payload.get("ref") and object_ref:
read_payload["ref"] = object_ref
read_result = rpc_call(endpoint_url, "modules.read", read_payload, timeout=timeout, transport=transport, session_id=session_id)
steps.append({"name": "modules.read", "status": read_result.get("status"), "module_ordinal": module_ordinal, "used_ref": bool(read_payload.get("ref"))})
if read_result.get("status") not in {"ok", "summary"}:
issues.append({"code": "live_modules_read_failed", "status": read_result.get("status"), "diagnostics": read_result.get("diagnostics")})
return {
"schema": "onec_mcp_selector_chain_live_smoke.v1",
"passed": False,
"endpoint_url": endpoint_url,
"transport": transport,
"base_id": base_id,
"object": live_object_summary({"kind": object_kind, "name": object_name, "guid": object_guid, "ref": object_ref}, canonical_object, modules_selector),
"steps": steps,
"issues": issues,
}
routine_name = routine_name_from_module_read(read_result)
if routine_name:
resolve_payload = {
"base_id": base_id,
"method_name": routine_name,
"max_items": 10,
"timeout_seconds": int(timeout),
**({"ref": object_ref} if object_ref else {}),
**({"object_type": object_kind} if object_kind else {}),
**({"object_name": object_name} if object_name else {}),
**({"object_guid": object_guid} if object_guid else {}),
}
resolve_payload = with_working_state(resolve_payload, transport=transport)
resolve_result = rpc_call(
endpoint_url,
"metadata.resolve_overrides",
resolve_payload,
timeout=timeout,
transport=transport,
session_id=session_id,
)
evidence = resolve_result.get("write_plan_evidence") if isinstance(resolve_result.get("write_plan_evidence"), dict) else {}
evidence_target = evidence.get("target") if isinstance(evidence.get("target"), dict) else {}
next_resolution = evidence.get("next_resolution") if isinstance(evidence.get("next_resolution"), dict) else {}
next_params = next_resolution.get("params") if isinstance(next_resolution.get("params"), dict) else {}
steps.append(
{
"name": "metadata.resolve_overrides",
"status": resolve_result.get("status"),
"routine": routine_name,
"chain": len([item for item in resolve_result.get("chain") or [] if isinstance(item, dict)]),
"write_plan_evidence": bool(evidence),
"next_method": next_resolution.get("method"),
"working_state": resolve_payload.get("source_state") or resolve_payload.get("state"),
}
)
if not evidence:
issues.append(
{
"code": "live_resolve_overrides_write_plan_evidence_missing",
"status": resolve_result.get("status"),
"error": resolve_result.get("error"),
"diagnostics": resolve_result.get("diagnostics"),
}
)
if evidence and evidence.get("method") != "metadata.write.plan":
issues.append({"code": "live_resolve_overrides_write_plan_method_mismatch", "actual": evidence.get("method")})
if evidence and evidence_target.get("routine_name") != routine_name:
issues.append(
{
"code": "live_resolve_overrides_evidence_routine_mismatch",
"expected": routine_name,
"actual": evidence_target.get("routine_name"),
}
)
if evidence and next_resolution.get("method") != "metadata.saved_state.modules.search":
issues.append({"code": "live_resolve_overrides_next_method_mismatch", "actual": next_resolution.get("method")})
if evidence and next_params.get("base_id") != base_id:
issues.append({"code": "live_resolve_overrides_next_base_id_mismatch", "expected": base_id, "actual": next_params.get("base_id")})
if evidence and next_params.get("query") != routine_name:
issues.append({"code": "live_resolve_overrides_next_query_mismatch", "expected": routine_name, "actual": next_params.get("query")})
if evidence and object_guid and evidence_target.get("object_guid") != object_guid:
issues.append(
{
"code": "live_resolve_overrides_evidence_owner_guid_mismatch",
"expected": object_guid,
"actual": evidence_target.get("object_guid"),
}
)
if evidence and next_resolution.get("method") == "metadata.saved_state.modules.search" and next_params:
saved_state = rpc_call(
endpoint_url,
"metadata.saved_state.modules.search",
next_params,
timeout=timeout,
transport=transport,
session_id=session_id,
)
saved_modules = [item for item in saved_state.get("modules") or [] if isinstance(item, dict)]
write_plan_target = first_write_plan_target_from_saved_state(saved_state)
steps.append(
{
"name": "metadata.saved_state.modules.search",
"status": saved_state.get("status"),
"modules": len(saved_modules),
"write_plan_target": bool(write_plan_target),
"from_next_resolution": True,
}
)
if saved_state.get("status") != "ok":
issues.append(
{
"code": "live_saved_state_modules_search_failed",
"status": saved_state.get("status"),
"error": saved_state.get("error"),
"diagnostics": saved_state.get("diagnostics"),
}
)
elif write_plan_target:
module_text = text_from_module_read(read_result)
control_fragment = search_token_from_text(module_text) or routine_name
plan_target = {**evidence_target, **write_plan_target}
plan_intent = evidence.get("intent") if isinstance(evidence.get("intent"), dict) else {}
plan_intent = {
"operation": plan_intent.get("operation") or "replace_with_control",
"control_fragment": plan_intent.get("control_fragment") or control_fragment,
"expected_old_contains": plan_intent.get("expected_old_contains") or control_fragment,
"new": plan_intent.get("new") or module_text or control_fragment,
}
write_plan = rpc_call(
endpoint_url,
"metadata.write.plan",
{
"base_id": base_id,
"target": plan_target,
"intent": plan_intent,
"resolve_origin": False,
},
timeout=timeout,
transport=transport,
session_id=session_id,
)
route = write_plan.get("route") if isinstance(write_plan.get("route"), dict) else {}
hint = route.get("apply_payload_hint") if isinstance(route.get("apply_payload_hint"), dict) else {}
hint_payload = hint.get("payload") if isinstance(hint.get("payload"), dict) else {}
steps.append(
{
"name": "metadata.write.plan",
"status": write_plan.get("status"),
"allowed": write_plan.get("allowed"),
"apply_method": route.get("apply_method"),
"ready_for_apply_method": hint.get("ready_for_apply_method"),
"from_write_plan_target": True,
}
)
if write_plan.get("allowed") is not True:
issues.append({"code": "live_composed_write_plan_not_allowed", "status": write_plan.get("status"), "problems": write_plan.get("problems")})
if route.get("apply_method") != "metadata.module.write_apply":
issues.append({"code": "live_composed_write_plan_apply_method_mismatch", "actual": route.get("apply_method")})
if hint.get("ready_for_apply_method") is not True:
issues.append({"code": "live_composed_write_plan_hint_not_ready", "hint": hint})
if write_plan_target.get("module_ref") and hint_payload.get("module_ref") != write_plan_target.get("module_ref"):
issues.append({"code": "live_composed_write_plan_module_ref_mismatch", "expected": write_plan_target.get("module_ref"), "actual": hint_payload.get("module_ref")})
if write_plan_target.get("expected_sha1") and hint_payload.get("expected_sha1") != write_plan_target.get("expected_sha1"):
issues.append({"code": "live_composed_write_plan_expected_sha1_mismatch"})
if write_plan_target.get("ref"):
name_resolution = route.get("name_resolution") if isinstance(route.get("name_resolution"), dict) else {}
resolution_selector = name_resolution.get("selector") if isinstance(name_resolution.get("selector"), dict) else {}
if resolution_selector.get("ref") != write_plan_target.get("ref"):
issues.append(
{
"code": "live_composed_write_plan_name_resolution_mismatch",
"expected": write_plan_target.get("ref"),
"actual": resolution_selector.get("ref"),
}
)
else:
steps.append({"name": "metadata.write.plan", "status": "skipped_no_saved_state_target", "from_write_plan_target": False})
else:
steps.append({"name": "metadata.resolve_overrides", "status": "skipped", "reason": "routine_name_not_found"})
search_token, search_token_source = search_token_from_module_read(read_result)
if not search_token:
issues.append({"code": "live_modules_read_search_token_missing", "module_ordinal": module_ordinal})
else:
code_search_payload = {
**{key: value for key, value in modules_selector.items() if key != "method" and value is not None and value != ""},
"base_id": modules_selector.get("base_id") or base_id,
"query": search_token,
"module_ordinal": module_ordinal,
"limit": 5,
}
if not code_search_payload.get("ref") and object_ref:
code_search_payload["ref"] = object_ref
code_search_payload = with_working_state(code_search_payload, transport=transport)
code_search = rpc_call(endpoint_url, "code.search", code_search_payload, timeout=timeout, transport=transport, session_id=session_id)
items = [item for item in code_search.get("items") or [] if isinstance(item, dict)]
steps.append(
{
"name": "code.search",
"status": code_search.get("status"),
"items": len(items),
"query_source": search_token_source,
"working_state": code_search_payload.get("source_state") or code_search_payload.get("state"),
}
)
if code_search.get("status") not in {"ok", "partial"} or not items:
issues.append({"code": "live_code_search_empty", "status": code_search.get("status"), "query_source": search_token_source, "error": code_search.get("error"), "diagnostics": code_search.get("diagnostics")})
else:
item_selector = items[0].get("read_selector") if isinstance(items[0].get("read_selector"), dict) else None
if not item_selector or item_selector.get("method") != "code.read":
issues.append({"code": "live_code_search_read_selector_missing", "selector": item_selector})
else:
code_read_payload = {key: value for key, value in item_selector.items() if key != "method"}
code_read_payload.setdefault("base_id", base_id)
code_read_payload.setdefault("preview", True)
code_read_payload.setdefault("max_chars", 1000)
code_read = rpc_call(endpoint_url, "code.read", code_read_payload, timeout=timeout, transport=transport, session_id=session_id)
steps.append({"name": "code.read", "status": code_read.get("status"), "used_read_selector": True})
if code_read.get("status") not in {"ok", "summary"}:
issues.append({"code": "live_code_read_failed", "status": code_read.get("status"), "diagnostics": code_read.get("diagnostics")})
coverage = live_coverage_from_steps(steps)
if require_write_plan_composition:
strict_issue = live_write_plan_composition_required_issue(coverage)
if strict_issue:
issues.append(strict_issue)
return {
"schema": "onec_mcp_selector_chain_live_smoke.v1",
"passed": not issues,
"endpoint_url": endpoint_url,
"transport": transport,
"base_id": base_id,
"object": live_object_summary({"kind": object_kind, "name": object_name, "guid": object_guid, "ref": object_ref}, canonical_object, modules_selector),
"coverage": coverage,
"steps": steps,
"issues": issues,
}
def selector_chain_examples() -> list[dict[str, Any]]:
return [
{
"name": "definition_to_related_modules",
"steps": [
{
"tool": "onec_request",
"method": "metadata.definition.find",
"payload": {
"base_id": "<base_id-from-project-context>",
"query": "<metadata-kind>.<metadata-object-name>",
"areas": ["metadata"],
},
"next": "Use related_selectors.modules or related_selectors.modules_search from the result.",
},
{
"tool": "onec_request",
"method": "<related_selectors.modules.method>",
"payload": "<related_selectors.modules>",
"next": "Use a module read_selector returned by modules or switch to related_selectors.code_search.",
},
],
},
{
"name": "definition_to_code_read",
"steps": [
{
"tool": "onec_request",
"method": "metadata.definition.find",
"payload": {
"base_id": "<base_id-from-project-context>",
"ref": "<metadata-kind>.<metadata-object-name>",
"areas": ["metadata", "object", "module"],
},
"next": "Use related_selectors.code_search and add query.",
},
{
"tool": "onec_request",
"method": "code.search",
"payload": {
"base_id": "<base_id-from-project-context>",
"ref": "<metadata-kind>.<metadata-object-name>",
"query": "<bsl-fragment-or-routine-name>",
"source_state": "working",
"limit": 10,
},
"next": "Pass item.read_selector directly to code.read.",
},
{
"tool": "onec_request",
"method": "<item.read_selector.method>",
"payload": "<item.read_selector>",
"expected_method": "code.read",
},
],
},
{
"name": "global_code_search_to_direct_read",
"steps": [
{
"tool": "onec_request",
"method": "code.search",
"payload": {
"base_id": "<base_id-from-project-context>",
"query": "<bsl-fragment-or-routine-name>",
"scope": "all",
"source_state": "working",
"resolve_owners": True,
"owner_scan_limit": 40,
},
"next": "If owner is unresolved, still pass item.read_selector to code.read before broadening search.",
},
{
"tool": "onec_request",
"method": "<item.read_selector.method>",
"payload": "<item.read_selector>",
"expected_method": "code.read",
},
],
},
]
def collect_strings(value: Any) -> list[str]:
if isinstance(value, str):
return [value]
if isinstance(value, dict):
strings: list[str] = []
for key, item in value.items():
strings.extend(collect_strings(key))
strings.extend(collect_strings(item))
return strings
if isinstance(value, list):
strings = []
for item in value:
strings.extend(collect_strings(item))
return strings
return []
def validate_chain(chain: dict[str, Any]) -> list[dict[str, Any]]:
issues: list[dict[str, Any]] = []
steps = chain.get("steps")
if not isinstance(steps, list) or not steps:
return [{"code": "chain_steps_missing", "chain": chain.get("name")}]
for index, step in enumerate(steps):
if not isinstance(step, dict):
issues.append({"code": "chain_step_not_object", "chain": chain.get("name"), "index": index})
continue
if step.get("tool") != "onec_request":
issues.append({"code": "chain_step_tool_not_onec_request", "chain": chain.get("name"), "index": index})
method = step.get("method")
if not isinstance(method, str) or not method:
issues.append({"code": "chain_step_method_missing", "chain": chain.get("name"), "index": index})
payload = step.get("payload")
if isinstance(payload, dict) and "base_id" not in payload:
issues.append({"code": "chain_step_base_id_missing", "chain": chain.get("name"), "index": index})
if method in {"code.search", "modules.search", "metadata.definition.find"} and isinstance(payload, dict):
selector_present = any(key in payload for key in ("ref", "query", "kind", "name", "guid", "object_type", "object_name", "object_guid"))
if not selector_present:
issues.append({"code": "chain_step_selector_or_query_missing", "chain": chain.get("name"), "index": index})
if method in WORKING_STATE_METHODS and isinstance(payload, dict) and not (payload.get("source_state") or payload.get("state")):
issues.append({"code": "chain_step_working_state_missing", "chain": chain.get("name"), "index": index, "method": method})
if step.get("expected_method") and "<item.read_selector.method>" not in str(method):
issues.append({"code": "chain_step_does_not_use_read_selector_method", "chain": chain.get("name"), "index": index})
for text in collect_strings(chain):
for forbidden in FORBIDDEN_CONCRETE_SELECTOR_VALUES:
if forbidden in text:
issues.append({"code": "chain_contains_concrete_selector_value", "chain": chain.get("name"), "value": forbidden})
return issues
def build_report() -> dict[str, Any]:
chains = selector_chain_examples()
issues: list[dict[str, Any]] = []
for chain in chains:
issues.extend(validate_chain(chain))
return {
"schema": "onec_mcp_selector_chain_smoke.v1",
"passed": not issues,
"chains": chains,
"counts": {"chains": len(chains), "steps": sum(len(chain.get("steps") or []) for chain in chains)},
"issues": issues,
}
def main() -> int:
parser = argparse.ArgumentParser(description="Smoke check for generic 1C MCP selector chains.")
parser.add_argument("--json", action="store_true", help="Print JSON report.")
parser.add_argument("--report", type=Path, default=DEFAULT_REPORT, help="Write JSON report.")
parser.add_argument("--no-report", action="store_true", help="Do not write a report file.")
parser.add_argument("--live", action="store_true", help="Run optional live adapter smoke through /rpc.")
parser.add_argument("--transport", choices=("rest", "mcp"), default="rest", help="Live smoke transport.")
parser.add_argument("--adapter-url", default="http://docker-gpu.cin.su:8011", help="1C REST adapter base URL for --live.")
parser.add_argument("--mcp-url", default="http://docker.cin.su:8021", help="1C MCP proxy base URL for --live --transport mcp.")
parser.add_argument("--base-id", help="Concrete 1C base id for --live.")
parser.add_argument("--ref", help="Optional concrete public object ref for deterministic --live checks, for example Kind.ObjectName.")
parser.add_argument("--kind", help="Optional concrete metadata kind for deterministic --live checks.")
parser.add_argument("--name", help="Optional concrete metadata object name for deterministic --live checks.")
parser.add_argument("--guid", help="Optional concrete metadata object GUID for deterministic --live checks.")
parser.add_argument("--timeout", type=float, default=15.0, help="HTTP timeout for --live.")
parser.add_argument("--require-write-plan-composition", action="store_true", help="Fail live smoke when saved-state lookup does not produce a concrete write_plan_target and composed metadata.write.plan.")
args = parser.parse_args()
if args.live and not args.base_id:
report = {"schema": "onec_mcp_selector_chain_live_smoke.v1", "passed": False, "issues": [{"code": "live_base_id_required"}]}
elif args.live:
endpoint_url = args.mcp_url if args.transport == "mcp" else args.adapter_url
object_selector = {**selector_from_ref(args.ref), **{key: value for key, value in {"kind": args.kind, "name": args.name, "guid": args.guid}.items() if value}}
report = build_live_report(
endpoint_url,
args.base_id,
timeout=args.timeout,
transport=args.transport,
object_selector=object_selector or None,
require_write_plan_composition=args.require_write_plan_composition,
)
else:
report = build_report()
if not args.no_report:
args.report.parent.mkdir(parents=True, exist_ok=True)
args.report.write_text(json.dumps(report, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
if args.json:
print(json.dumps(report, ensure_ascii=False, indent=2))
else:
status = "passed" if report["passed"] else "failed"
counts = report.get("counts") if isinstance(report.get("counts"), dict) else {}
if counts:
print(f"1C MCP selector chain smoke {status}: {counts.get('chains')} chains, {counts.get('steps')} steps.")
else:
print(
json.dumps(
{
"status": status,
"schema": report.get("schema"),
"issues": report.get("issues") or [],
"steps": report.get("steps") or [],
},
ensure_ascii=False,
indent=2,
)
)
return 0 if report["passed"] else 1
if __name__ == "__main__":
raise SystemExit(main())