Initial project import

This commit is contained in:
2026-08-14 09:40:51 +03:00
parent 00040e5ce4
commit d7099bf80d
146 changed files with 30509 additions and 1055 deletions
+1
View File
@@ -6,6 +6,7 @@ ENV PYTHONDONTWRITEBYTECODE=1 \
WORKDIR /app
COPY adapter_1c_mcp.py /app/adapter_1c_mcp.py
COPY analyze_audit.py /app/analyze_audit.py
EXPOSE 8021
+355 -41
View File
@@ -2,6 +2,8 @@ from __future__ import annotations
import argparse
import concurrent.futures
import datetime
import http.client
import json
import os
import queue
@@ -24,7 +26,7 @@ ROOT_DIR = THIS_FILE.parents[3] if len(THIS_FILE.parents) > 3 else THIS_FILE.par
if str(ROOT_DIR) not in sys.path:
sys.path.insert(0, str(ROOT_DIR))
DEFAULT_ADAPTER_URL = "http://docker-gpu.cin.su:8011"
DEFAULT_ADAPTER_URL = "http://docker.cin.su:8011"
DEFAULT_ACCESS_REPORT_ROOT = ROOT_DIR / "reports" / "1c-access"
PROTOCOL_VERSION = "2025-06-18"
MCP_CONTRACT_VERSION = "onec-selector-contract.v1"
@@ -32,8 +34,12 @@ MCP_CONTRACT_VERSION = "onec-selector-contract.v1"
SESSIONS: dict[str, "queue.Queue[dict[str, Any] | None]"] = {}
SESSION_LOCK = threading.Lock()
JOB_LOCK = threading.Lock()
MCP_AUDIT_LOCK = threading.Lock()
SELECTOR_TOKEN_LOCK = threading.Lock()
JOBS: dict[str, dict[str, Any]] = {}
NEW_METHOD_CACHE: dict[str, dict[str, Any]] = {}
SELECTOR_TOKENS: dict[str, dict[str, Any]] = {}
SELECTOR_TOKEN_TTL_SECONDS = 600
LONG_METHODS = {
"metadata.object.attributes",
"metadata.object.full",
@@ -122,6 +128,10 @@ REST_STATE_BY_SOURCE_STATE = {
"all": "both",
}
REST_STATE_METHODS = {
"metadata.object.forms",
"metadata.object.form.details",
"metadata.form.decode",
"metadata.object.full",
"metadata.resolve_overrides",
"modules.search",
"code.search",
@@ -150,6 +160,11 @@ GUID_RE = re.compile(r"^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]
FULL_METHOD_SECTIONS = {"card", "semantic", "forms", "templates", "commands", "modules", "parts_summary"}
FULL_METHOD_SECTION_ORDER = ["card", "semantic", "modules", "templates", "forms", "commands"]
FULL_METHOD_ALL_KEY = "all"
TECHNICAL_AGENT_FIELDS = {
"table", "file_name", "file_names", "module_ref", "module_id",
"stream_index", "bsl_offset", "cas_key", "storage_key",
"include_storage", "guid", "object_guid", "form_guid", "extension_guid",
}
TOOLS = [
@@ -186,17 +201,19 @@ TOOLS = [
"description": (
"Generic 1C adapter request. For live metadata/modules/code/templates/extensions/query methods, "
"payload.base_id is required; get it from user/project context or check a concrete base with onec_health first. "
"If you already have module_ref/read_selector, prefer direct read methods before global search. "
"Search results include read_selector.method; reuse that selector directly for the next read call. "
"Use complete public 1C names first: extension + object ref + child name where applicable. "
"Search results declare read_selector.method and include read_selector.selector_token; reuse that token with its declared method for the next read call. "
"For a name search across metadata objects, forms, attributes, commands, templates, routines, and extension definitions, use metadata.definition.find; "
"use code.search only when the query is BSL text. Scope extension objects with extension.objects.find or metadata.definition.find areas=extensions, never by SQL table names. "
"For unresolved module owners, inspect diagnostics.owner_resolution and adjust the object selector "
"(ref, kind/name/guid, or object_type/object_name/object_guid) or owner_scan_limit. "
"For unresolved module owners, inspect diagnostics.owner_resolution and adjust the public object selector "
"(ref, kind/name, or object_type/object_name). Global code/vector searches resolve "
"base module owners lazily from current metadata; use metadata.module_owner_cache.backfill for bounded "
"background warming instead of increasing owner_scan_limit on interactive searches. "
"The default agent view is configuration_view=effective_working with source_state=working: the logical Designer snapshot, with working changes and extension layers preferred; "
"it becomes executable after configuration update, not necessarily now. Use configuration_view=runtime_applied for code executable now, or compare to inspect both. "
"Do not select Config/ConfigSave tables in ordinary programming calls. "
"For saved-state methods, select base_saved_state or extension_saved_state with layer and identify objects by ref or kind/name; "
"use table/file_name/module_ref only when continuing an explicit include_storage diagnostic result. "
"For saved-state methods, select base_saved_state or extension_saved_state with layer and identify objects by ref or kind/name. "
"Do not send GUIDs, table/file_name/module_ref, stream indexes, CAS keys, or include_storage in ordinary agent requests. "
"Before writes, call metadata.write.preflight when you need a read-only route/freshness check; it reports "
"ready, needs_prepare, needs_resolution, or blocked and never applies SQL writes. "
"Repository manual-capture protocol: when repository.lock.request or repository.lock.request.status returns "
@@ -310,8 +327,7 @@ TOOLS = [
{
"method": "code.read",
"payload": {
"base_id": "<base_id-from-project-context>",
"module_ref": "<module_ref-from-code-search-read-selector>",
"selector_token": "<selector-token-from-code-search>",
"include_line_numbers": True,
"max_chars": 20000,
},
@@ -355,8 +371,7 @@ TOOLS = [
{
"method": "modules.read",
"payload": {
"base_id": "<base_id-from-project-context>",
"module_ref": "<module_ref-from-prior-result>",
"selector_token": "<selector-token-from-modules-search>",
"include_line_numbers": True,
"include_text": True,
},
@@ -510,11 +525,72 @@ TOOLS = [
"context_limit": 50,
},
},
{
"method": "configuration.activation.status",
"payload": {
"base_id": "<base_id-from-project-context>",
"layer": "all",
},
},
{
"method": "configuration.activation.plan",
"payload": {
"base_id": "<base_id-from-project-context>",
"layer": "extension_saved_state",
},
},
{
"method": "configuration.activation.request",
"payload": {
"base_id": "<base_id-from-project-context>",
"layer": "extension_saved_state",
},
},
{
"method": "configuration.activation.execute",
"payload": {
"base_id": "<base_id-from-project-context>",
"request_id": "<request-id-from-configuration.activation.request>",
"mode": "debug",
"confirm_activation": True,
"bridge_debug": True,
},
},
{
"method": "configuration.activation.request.cancel",
"payload": {
"base_id": "<base_id-from-project-context>",
"request_id": "<activation-request-id>",
"confirm_cancel": True,
},
},
{
"method": "configuration.activation.capabilities",
"payload": {
"base_id": "<base_id-from-project-context>",
"layer": "all",
},
},
{
"method": "configuration.activation.bridge.probe",
"payload": {
"base_id": "<base_id-from-project-context>",
"layer": "extension_saved_state",
"timeout_seconds": 10,
},
},
{
"method": "configuration.activation.verify",
"payload": {
"base_id": "<base_id-from-project-context>",
"request_id": "<activation-request-id>",
},
},
],
"properties": {
"method": {
"type": "string",
"description": "Adapter method, for example metadata.objects.list, metadata.object.full, metadata.definition.find, metadata.route.resolve, extension.objects.find, templates.read, templates.analyze, templates.map, metadata.saved_state.prepare, metadata.saved_state.status, metadata.saved_state.diff, metadata.saved_state.changes.list, metadata.saved_state.forms.search, metadata.saved_state.modules.search, metadata.form.write_target.resolve, metadata.form.write_target.verify, metadata.module.write_apply, metadata.write.plan, metadata.write.preflight, metadata.write.capabilities, metadata.write, metadata.write.history, metadata.write.rollback, metadata.form.command_button.write, metadata.form.command_button.verify, code.write, metadata.form.element.write_apply, metadata.write_learning.capture_before, metadata.write_learning.capture_after, metadata.write_learning.diff, metadata.write_learning.infer_rule, modules.search, modules.read, code.search, code.read, code.symbol.resolve, templates.bindings, diagnostics.call_chain, bulk.execute, changes.propose, storage.saved_state.apply_proposal, storage.saved_state.rollback, or mcp.job.get. Live database methods require payload.base_id; placeholders in examples must be replaced from project/user context.",
"description": "Adapter method, for example metadata.objects.list, metadata.object.full, metadata.definition.find, metadata.route.resolve, extension.objects.find, templates.read, templates.analyze, templates.map, metadata.module_owner_cache.backfill, metadata.saved_state.ensure, metadata.saved_state.ensure.rollback, metadata.saved_state.prepare, metadata.saved_state.status, metadata.saved_state.diff, metadata.saved_state.changes.list, configuration.activation.status, configuration.activation.plan, configuration.activation.request, configuration.activation.request.status, configuration.activation.request.cancel, configuration.activation.audit, configuration.activation.capabilities, configuration.activation.bridge.probe, configuration.activation.execute, configuration.activation.verify, metadata.saved_state.forms.search, metadata.saved_state.modules.search, metadata.form.write_target.resolve, metadata.form.write_target.verify, metadata.module.write_apply, metadata.write.plan, metadata.write.preflight, metadata.write.capabilities, metadata.write, metadata.write.history, metadata.write.rollback, metadata.form.command_button.write, metadata.form.command_button.verify, code.write, metadata.form.element.write_apply, metadata.write_learning.capture_before, metadata.write_learning.capture_after, metadata.write_learning.diff, metadata.write_learning.infer_rule, modules.search, modules.read, code.search, code.read, code.symbol.resolve, templates.bindings, diagnostics.call_chain, bulk.execute, changes.propose, storage.saved_state.apply_proposal, storage.saved_state.rollback, or mcp.job.get. Live database methods require payload.base_id; placeholders in examples must be replaced from project/user context.",
},
"payload": {
"type": "object",
@@ -641,6 +717,7 @@ TOOLS = [
"path": {"type": "string"},
"canonical_path": {"type": "string"},
"extension": {"type": "string"},
"module_ref": {"type": "string"},
"routine_name": {"type": "string"},
"routine_text": {"type": "string"},
"module_text": {"type": "string"},
@@ -648,6 +725,10 @@ TOOLS = [
"code": {"type": "string"},
"old": {"type": "string"},
"new": {"type": "string"},
"expected_sha1": {"type": "string"},
"expected_text_sha1": {"type": "string"},
"repository_lock": {"type": "object", "additionalProperties": True},
"write_context": {"type": "object", "additionalProperties": True},
"mode": {"type": "string", "enum": ["plan", "apply"]},
"include_storage": {"type": "boolean"},
},
@@ -2180,7 +2261,10 @@ def _run_bulk_execute(payload: dict[str, Any], request_start: float, request_id:
except AdapterError as exc:
results.append({"index": index, "method": submethod, "status": "error", "error": str(exc), "diagnostics": adapter_error_result(submethod or "unknown", exc)})
except Exception as exc:
results.append({"index": index, "method": submethod, "status": "error", "error": str(exc), "traceback": traceback.format_exc(limit=5)})
item = {"index": index, "method": submethod, "status": "error", "error": str(exc)}
if truthy(os.environ.get("ONEC_MCP_DEBUG_DIAGNOSTICS")):
item["traceback"] = traceback.format_exc(limit=5)
results.append(item)
requested_count = len(_as_list(payload.get("requests")))
failed_count = len([item for item in results if (item.get("status") in {"error", "invalid_argument"})])
@@ -2269,6 +2353,9 @@ def enrich_result_with_freshness(payload: dict[str, Any], method: str, result: A
if not isinstance(result, dict):
return result
context = build_freshness_context(payload)
if method.startswith("storage."):
context["cache_policy"] = "none"
context["force_refresh"] = True
context["request_id"] = str(payload.get("_mcp_request_id") or uuid.uuid4().hex)
context["method"] = method
context["base_id"] = payload.get("base_id")
@@ -2310,6 +2397,12 @@ def apply_freshness_request_policy(payload: dict[str, Any], method: str) -> dict
source_state = "working"
cache_policy = "none"
force_refresh = truthy(payload.get("force_refresh"))
if method.startswith("storage."):
# Storage methods always call the live SQL layer or adapter-local
# backup store directly; their result is never served from the
# metadata/vector cache.
cache_policy = "none"
force_refresh = True
transformed = dict(payload)
transformed["source_mode"] = source_mode
transformed["source_state"] = source_state
@@ -2343,7 +2436,39 @@ def apply_freshness_request_policy(payload: dict[str, Any], method: str) -> dict
return transformed
def http_json(method: str, path: str, payload: dict[str, Any] | None = None, timeout: float | None = None) -> Any:
def mcp_audit_event(event: dict[str, Any]) -> None:
"""Persist proxy telemetry without BSL text, payload bytes, or credentials."""
try:
path = Path(os.environ.get("ONEC_MCP_AUDIT_LOG_PATH") or "/data/mcp-audit.jsonl")
path.parent.mkdir(parents=True, exist_ok=True)
with MCP_AUDIT_LOCK:
max_bytes = max(1_048_576, int(os.environ.get("ONEC_MCP_AUDIT_MAX_BYTES") or 52_428_800))
keep_files = max(1, min(20, int(os.environ.get("ONEC_MCP_AUDIT_KEEP_FILES") or 10)))
if path.exists() and path.stat().st_size >= max_bytes:
for index in range(keep_files - 1, 0, -1):
source = path.with_name(f"{path.name}.{index}")
target = path.with_name(f"{path.name}.{index + 1}")
if source.exists():
source.replace(target)
path.replace(path.with_name(f"{path.name}.1"))
with path.open("a", encoding="utf-8") as handle:
handle.write(json.dumps(event, ensure_ascii=False, sort_keys=True, default=str) + "\n")
except (OSError, ValueError):
return
def mcp_audit_request_summary(payload: dict[str, Any]) -> dict[str, Any]:
keys = ("base_id", "extension", "extension_guid", "ref", "kind", "name", "object_type", "object_name", "module_ordinal", "mode")
return {key: payload.get(key) for key in keys if payload.get(key) not in {None, ""}}
def http_json(
method: str,
path: str,
payload: dict[str, Any] | None = None,
timeout: float | None = None,
request_id: str | None = None,
) -> Any:
url = f"{adapter_url()}{path}"
data = None if payload is None else json.dumps(payload, ensure_ascii=False).encode("utf-8")
headers = {"Accept": "application/json"}
@@ -2351,6 +2476,8 @@ def http_json(method: str, path: str, payload: dict[str, Any] | None = None, tim
headers["Content-Type"] = "application/json; charset=utf-8"
if adapter_token():
headers["Authorization"] = f"Bearer {adapter_token()}"
if request_id and re.fullmatch(r"[A-Za-z0-9_.-]{8,128}", request_id):
headers["X-Request-ID"] = request_id
request = urllib.request.Request(url, data=data, headers=headers, method=method)
effective_timeout = adapter_timeout() if timeout is None else timeout
try:
@@ -2362,33 +2489,69 @@ def http_json(method: str, path: str, payload: dict[str, Any] | None = None, tim
except urllib.error.HTTPError as exc:
body = exc.read().decode("utf-8", errors="replace")
raise AdapterError(f"REST adapter returned HTTP {exc.code}", status=exc.code, body=body) from exc
except urllib.error.URLError as exc:
raise AdapterError(f"REST adapter is unavailable: {exc.reason}") from exc
except (urllib.error.URLError, http.client.HTTPException, OSError) as exc:
reason = getattr(exc, "reason", None) or str(exc) or type(exc).__name__
raise AdapterError(f"REST adapter is unavailable: {reason}") from exc
def call_adapter_method(method: str, payload: dict[str, Any], *, timeout: float | None = None) -> Any:
if method == "health":
query = ""
if payload.get("base_id"):
query = "?" + urllib.parse.urlencode({"base_id": str(payload.get("base_id"))})
return http_json("GET", f"/health{query}", timeout=timeout)
if method == "help.methods":
try:
return http_json("POST", "/rpc", {"method": method, "payload": payload}, timeout=timeout)
except AdapterError as exc:
if exc.status not in {404, 405}:
raise
return http_json("GET", "/methods", timeout=timeout)
return http_json("POST", "/rpc", {"method": method, "payload": payload}, timeout=timeout)
request_id = str(payload.get("_mcp_request_id") or "").strip()
started = now_ts()
try:
if method == "health":
query = ""
if payload.get("base_id"):
query = "?" + urllib.parse.urlencode({"base_id": str(payload.get("base_id"))})
result = http_json("GET", f"/health{query}", timeout=timeout, request_id=request_id)
elif method == "help.methods":
try:
result = http_json("POST", "/rpc", {"method": method, "payload": payload}, timeout=timeout, request_id=request_id)
except AdapterError as exc:
if exc.status not in {404, 405}:
raise
result = http_json("GET", "/methods", timeout=timeout, request_id=request_id)
else:
result = http_json("POST", "/rpc", {"method": method, "payload": payload}, timeout=timeout, request_id=request_id)
except Exception as exc:
mcp_audit_event({
"event": "mcp_adapter_call", "time": datetime.datetime.now(datetime.timezone.utc).isoformat(),
"request_id": request_id or None, "method": method, "request": mcp_audit_request_summary(payload),
"status": "exception", "error": "adapter_unavailable" if isinstance(exc, AdapterError) else "mcp_request_exception",
"exception_type": type(exc).__name__, "duration_ms": int((now_ts() - started) * 1000),
})
raise
mcp_audit_event({
"event": "mcp_adapter_call", "time": datetime.datetime.now(datetime.timezone.utc).isoformat(),
"request_id": request_id or None, "method": method, "request": mcp_audit_request_summary(payload),
"status": result.get("status") if isinstance(result, dict) else None,
"error": result.get("error") if isinstance(result, dict) else None,
"duration_ms": int((now_ts() - started) * 1000),
})
return result
def public_error(method: str, error: str, diagnostics: Any | None = None, *, schema: str = "adapter_1c_mcp_error.v1") -> dict[str, Any]:
safe_diagnostics = diagnostics if diagnostics is not None else {"message": error}
if not truthy(os.environ.get("ONEC_MCP_DEBUG_DIAGNOSTICS")):
safe_diagnostics = strip_private_error_diagnostics(safe_diagnostics)
return {
"schema": schema,
"status": "error",
"method": method,
"error": error,
"diagnostics": diagnostics if diagnostics is not None else {"message": error},
"diagnostics": safe_diagnostics,
}
def strip_private_error_diagnostics(value: Any) -> Any:
if isinstance(value, list):
return [strip_private_error_diagnostics(item) for item in value]
if not isinstance(value, dict):
return value
return {
key: strip_private_error_diagnostics(item)
for key, item in value.items()
if key not in {"traceback", "stack", "stacktrace", "exception_repr"}
}
@@ -2934,15 +3097,36 @@ def metadata_write_code_guardrail(method: str, payload: dict[str, Any]) -> dict[
has_code_edit = any(payload.get(field) is not None for field in code_fields)
if not has_code_edit or target_kind not in {"module", "bsl_module", "bsl"}:
return None
owner_object_type = (
payload.get("object_type")
or target.get("object_type")
or payload.get("owner_kind")
or target.get("owner_kind")
)
if str(owner_object_type or "").strip().casefold() in {"module", "bsl_module", "bsl", "модуль"}:
owner_object_type = None
owner_object_name = (
payload.get("object_name")
or target.get("object_name")
or payload.get("owner_name")
or target.get("owner_name")
)
owner_object_guid = (
payload.get("object_guid")
or target.get("object_guid")
or payload.get("owner_guid")
or target.get("owner_guid")
)
suggested_payload = {
"base_id": payload.get("base_id"),
**{
key: value
for key, value in {
"ref": payload.get("ref") or target.get("ref"),
"object_type": payload.get("object_type") or target.get("object_type") or target.get("kind"),
"object_name": payload.get("object_name") or target.get("object_name") or target.get("name"),
"object_guid": payload.get("object_guid") or target.get("object_guid") or target.get("guid"),
"module_ref": payload.get("module_ref") or target.get("module_ref"),
"object_type": owner_object_type,
"object_name": owner_object_name,
"object_guid": owner_object_guid,
"routine_name": payload.get("routine_name") or target.get("routine_name"),
"routine_text": payload.get("routine_text"),
"module_text": payload.get("module_text"),
@@ -2968,6 +3152,125 @@ def metadata_write_code_guardrail(method: str, payload: dict[str, Any]) -> dict[
}
def purge_expired_selector_tokens() -> None:
cutoff = now_ts() - SELECTOR_TOKEN_TTL_SECONDS
with SELECTOR_TOKEN_LOCK:
expired = [token for token, entry in SELECTOR_TOKENS.items() if float(entry.get("created_at") or 0) < cutoff]
for token in expired:
SELECTOR_TOKENS.pop(token, None)
def issue_selector_token(selector: dict[str, Any]) -> str:
purge_expired_selector_tokens()
token = f"onecsel_{uuid.uuid4().hex}"
with SELECTOR_TOKEN_LOCK:
SELECTOR_TOKENS[token] = {"created_at": now_ts(), "selector": dict(selector)}
return token
def diagnostic_mode_authorized(payload: dict[str, Any]) -> bool:
"""Developer diagnostics are opt-in at deployment level, not an agent choice."""
return (
(truthy(payload.get("diagnostic")) or truthy(payload.get("_allow_diagnostic")))
and truthy(os.environ.get("ONEC_MCP_ALLOW_DIAGNOSTIC"))
)
def publicize_read_selectors(value: Any) -> Any:
"""Replace adapter-issued technical continuations with short-lived opaque tokens."""
if isinstance(value, list):
return [publicize_read_selectors(item) for item in value]
if not isinstance(value, dict):
return value
public: dict[str, Any] = {}
for key, item in value.items():
if key in TECHNICAL_AGENT_FIELDS:
continue
if key == "read_selector" and isinstance(item, dict) and str(item.get("method") or "").strip():
public[key] = {"method": str(item["method"]), "selector_token": issue_selector_token(item)}
elif key == "read_selectors" and isinstance(item, dict):
public[key] = {
name: (
{"method": str(selector["method"]), "selector_token": issue_selector_token(selector)}
if isinstance(selector, dict) and str(selector.get("method") or "").strip()
else publicize_read_selectors(selector)
)
for name, selector in item.items()
}
else:
public[key] = publicize_read_selectors(item)
return public
def resolve_selector_token(method: str, payload: dict[str, Any]) -> tuple[dict[str, Any] | None, dict[str, Any] | None]:
token = str(payload.get("selector_token") or "").strip()
if not token:
return payload, None
purge_expired_selector_tokens()
with SELECTOR_TOKEN_LOCK:
entry = SELECTOR_TOKENS.get(token)
selector = entry.get("selector") if isinstance(entry, dict) and isinstance(entry.get("selector"), dict) else None
if not selector:
return None, public_error(method, "selector_token_invalid", {"message": "selector_token is unknown or expired; repeat the public discovery call."})
selector_method = str(selector.get("method") or "").strip()
if selector_method != method:
return None, public_error(method, "selector_token_method_mismatch", {"message": f"selector_token is valid only for `{selector_method}`."})
explicit = {key: value for key, value in payload.items() if key != "selector_token"}
resolved = {**selector, **explicit, "_selector_token_resolved": True}
return resolved, None
def technical_selector_fields(payload: Any) -> list[str]:
"""Find technical selector keys at every JSON level supplied by an agent."""
found: set[str] = set()
if isinstance(payload, dict):
for key, value in payload.items():
if key in TECHNICAL_AGENT_FIELDS:
found.add(key)
found.update(technical_selector_fields(value))
elif isinstance(payload, list):
for value in payload:
found.update(technical_selector_fields(value))
return sorted(found)
def normal_agent_technical_field_guardrail(method: str, payload: dict[str, Any]) -> dict[str, Any] | None:
if diagnostic_mode_authorized(payload) or truthy(payload.get("_selector_token_resolved")):
return None
prohibited = technical_selector_fields(payload)
if not prohibited:
return None
return {
"schema": "adapter_1c_mcp_policy.v1",
"status": "blocked",
"method": method,
"reason": "technical_selector_forbidden",
"diagnostics": {
"fields": prohibited,
"message": "Use complete public 1C names (extension + ref + child name) or an adapter-issued selector_token. SQL/storage coordinates are developer diagnostics only.",
"suggested_request": {
"method": "metadata.object.full",
"payload": {"base_id": payload.get("base_id"), "ref": payload.get("ref"), "configuration_view": "effective_working"},
},
},
}
def runtime_form_inspection_unsupported(method: str, payload: dict[str, Any]) -> dict[str, Any] | None:
if method not in {"runtime.form.elements.inspect", "runtime.form.inspect"}:
return None
return {
"schema": "onec_runtime_form_inspection.v1",
"status": "unsupported",
"method": method,
"error": "runtime_inspection_unsupported",
"base_id": payload.get("base_id"),
"diagnostics": {
"message": "The SQL-only adapter does not open 1C forms, execute form handlers, or inspect runtime-generated controls. Read static metadata with metadata.form.decode; obtain runtime evidence through a separately authorised human-operated channel.",
},
}
def run_or_enqueue_adapter_method(method: str, payload: dict[str, Any]) -> Any:
request_start = now_ts()
request_id = uuid.uuid4().hex
@@ -2986,14 +3289,18 @@ def run_or_enqueue_adapter_method(method: str, payload: dict[str, Any]) -> Any:
}
request_payload["_mcp_request_id"] = request_id
payload = request_payload
runtime_guardrail = runtime_form_inspection_unsupported(method, payload)
if runtime_guardrail is not None:
return enrich_result_with_freshness(payload, method, runtime_guardrail, request_start)
technical_field_guardrail = normal_agent_technical_field_guardrail(method, payload)
if technical_field_guardrail is not None:
return enrich_result_with_freshness(payload, method, technical_field_guardrail, request_start)
code_guardrail = metadata_write_code_guardrail(method, payload)
if code_guardrail is not None:
return enrich_result_with_freshness(payload, method, code_guardrail, request_start)
if method_requires_base_id(method) and not str(payload.get("base_id") or "").strip():
return missing_base_id_policy(method)
if (method.startswith(DIAGNOSTIC_METHOD_PREFIXES) or method in DIAGNOSTIC_METHODS) and not (
truthy(payload.get("diagnostic")) or truthy(payload.get("_allow_diagnostic"))
):
if (method.startswith(DIAGNOSTIC_METHOD_PREFIXES) or method in DIAGNOSTIC_METHODS) and not diagnostic_mode_authorized(payload):
return {
"schema": "adapter_1c_mcp_policy.v1",
"status": "blocked",
@@ -3005,7 +3312,7 @@ def run_or_enqueue_adapter_method(method: str, payload: dict[str, Any]) -> Any:
"Use metadata.object.attributes, metadata.object.full, metadata.object.forms, metadata.form.decode, "
"metadata.resolve_overrides, code.search, code.read, modules.search, metadata.definition.find, templates.bindings, "
"or modules.read. "
"Pass diagnostic=true only for explicit adapter diagnostics."
"Developer diagnostics require diagnostic=true and ONEC_MCP_ALLOW_DIAGNOSTIC=true in the MCP deployment."
)
},
}
@@ -3343,6 +3650,10 @@ def handle_tool_call(name: str, arguments: dict[str, Any] | None) -> dict[str, A
payload = args.get("payload") or {}
if not isinstance(payload, dict):
return tool_text(public_error(method or "onec_request", "invalid_payload", {"message": "payload must be an object"}))
payload, selector_error = resolve_selector_token(method, payload)
if selector_error is not None:
return tool_text(selector_error)
assert payload is not None
if method in {"mcp.job.get", "adapter.job.get", "onec.job.get"}:
job_id = str(payload.get("job_id") or "").strip()
if not job_id:
@@ -3356,7 +3667,7 @@ def handle_tool_call(name: str, arguments: dict[str, Any] | None) -> dict[str, A
):
job = dict(job)
job["result"] = maybe_enrich_owner_fields(str(job.get("method") or "").strip(), job.get("payload"), job.get("result"))
return tool_text(job)
return tool_text(publicize_read_selectors(job))
except AdapterError as exc:
return tool_text(adapter_error_result("adapter.job.get", exc))
if method in {"mcp.job.cancel", "adapter.job.cancel", "onec.job.cancel"}:
@@ -3367,7 +3678,7 @@ def handle_tool_call(name: str, arguments: dict[str, Any] | None) -> dict[str, A
return tool_text(call_adapter_method("adapter.job.cancel", {"job_id": job_id}))
except AdapterError as exc:
return tool_text(adapter_error_result("adapter.job.cancel", exc))
return tool_text(run_or_enqueue_adapter_method(method, payload))
return tool_text(publicize_read_selectors(run_or_enqueue_adapter_method(method, payload)))
if name == "onec_job_get":
job_id = str(args.get("job_id") or "").strip()
if not job_id:
@@ -3381,7 +3692,7 @@ def handle_tool_call(name: str, arguments: dict[str, Any] | None) -> dict[str, A
):
job = dict(job)
job["result"] = maybe_enrich_owner_fields(str(job.get("method") or "").strip(), job.get("payload"), job.get("result"))
return tool_text(job)
return tool_text(publicize_read_selectors(job))
except AdapterError as exc:
return tool_text(adapter_error_result("adapter.job.get", exc))
if name == "onec_job_cancel":
@@ -3460,7 +3771,10 @@ def handle_jsonrpc(payload: dict[str, Any]) -> dict[str, Any] | None:
return jsonrpc_result(request_id, handle_tool_call(str(params.get("name") or ""), params.get("arguments") or {}))
return jsonrpc_error(request_id, -32601, f"Method not found: {method}")
except Exception as exc:
return jsonrpc_error(request_id, -32000, str(exc), traceback.format_exc())
data: dict[str, Any] = {"message": str(exc)}
if truthy(os.environ.get("ONEC_MCP_DEBUG_DIAGNOSTICS")):
data["traceback"] = traceback.format_exc()
return jsonrpc_error(request_id, -32000, "MCP request failed", data)
def payload_has_method(payload: Any, method: str) -> bool:
+34
View File
@@ -0,0 +1,34 @@
"""Small periodic summary for MCP-to-REST availability telemetry."""
from __future__ import annotations
import json
from collections import Counter
from pathlib import Path
path = Path("/data/mcp-audit.jsonl")
rows: list[dict] = []
malformed_rows = 0
if path.exists():
for line in path.read_text(encoding="utf-8", errors="replace").splitlines():
try:
row = json.loads(line)
except json.JSONDecodeError:
malformed_rows += 1
continue
if row.get("event") == "mcp_adapter_call":
rows.append(row)
failures = [row for row in rows if row.get("error") or row.get("status") == "exception"]
availability = [row for row in failures if row.get("error") == "adapter_unavailable"]
print(json.dumps({
"schema": "onec_mcp_audit_summary.v1", "status": "ok" if path.exists() else "log_not_found",
"events": len(rows), "malformed_rows": malformed_rows,
"bases": dict(Counter(str((row.get("request") or {}).get("base_id") or "<none>") for row in rows)),
"failures": len(failures),
"failure_methods": dict(Counter(str(row.get("method") or "<none>") for row in failures)),
"recent_failures": failures[-20:],
"findings": [
*([{"priority": "P1", "kind": "rest_unavailable_from_mcp", "count": len(availability), "next_action": "Check MCP-to-REST connectivity, then find the same request_id in REST telemetry if it exists."}] if availability else []),
*([{"priority": "P2", "kind": "malformed_audit_rows", "count": malformed_rows, "next_action": "Inspect proxy container restarts and log rotation."}] if malformed_rows else []),
],
}, ensure_ascii=False, indent=2))