from __future__ import annotations import argparse import concurrent.futures import json import os import queue import sys import threading import time import traceback import hashlib import urllib.error import urllib.parse import urllib.request import uuid import re from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer from pathlib import Path from typing import Any THIS_FILE = Path(__file__).resolve() ROOT_DIR = THIS_FILE.parents[3] if len(THIS_FILE.parents) > 3 else THIS_FILE.parent 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_ACCESS_REPORT_ROOT = ROOT_DIR / "reports" / "1c-access" PROTOCOL_VERSION = "2025-06-18" MCP_CONTRACT_VERSION = "onec-selector-contract.v1" SESSIONS: dict[str, "queue.Queue[dict[str, Any] | None]"] = {} SESSION_LOCK = threading.Lock() JOB_LOCK = threading.Lock() JOBS: dict[str, dict[str, Any]] = {} NEW_METHOD_CACHE: dict[str, dict[str, Any]] = {} LONG_METHODS = { "metadata.object.attributes", "metadata.object.full", "metadata.objects.list", "metadata.form.decode", "metadata.definition.find", "metadata.resolve_overrides", "modules.search", "code.search", "code.read", "templates.bindings", "templates.read", "templates.analyze", "templates.map", "extension.objects.find", "metadata.route.resolve", "diagnostics.call_chain", "bulk.execute", } DIAGNOSTIC_METHOD_PREFIXES = ( "codec.", "storage.", "schema.", "query.", ) DIAGNOSTIC_METHODS = { "metadata.dbnames.summary", } BASE_ID_REQUIRED_METHOD_PREFIXES = ( "infobase.", "metadata.", "modules.", "code.", "templates.", "extension.", "diagnostics.", "extensions.", "access.", "query.", "storage.", "schema.", "codec.", ) BASE_ID_OPTIONAL_METHODS = { "health", "help.methods", "adapter.job.start", "adapter.job.get", "adapter.job.cancel", } HEAVY_TEXT_THRESHOLD_FOR_CODE_READ = 50000 OWNER_IDENTITY_LOOKUP_LIMIT = 24 NEW_API_VERSION = 1 UNIFIED_METHODS = { "bulk.execute", } NEW_METHOD_CACHE_TTL_SECONDS = {"short": 30, "normal": 120, "long": 600} BULK_MAX_REQUESTS = 30 SOURCE_MODES = {"auto", "runtime", "designer"} OBJECT_SELECTOR_SCHEMA_PROPERTIES = { "ref": {"type": "string"}, "kind": {"type": "string"}, "name": {"type": "string"}, "guid": {"type": "string"}, "object_type": {"type": "string"}, "object_name": {"type": "string"}, "object_guid": {"type": "string"}, } CACHE_POLICIES = {"none", "ttl", "snapshot", "stale_while_revalidate"} SOURCE_STATES = {"applied", "working", "all"} REST_STATE_BY_SOURCE_STATE = { "applied": "active", "working": "working", "all": "both", } REST_STATE_METHODS = { "metadata.resolve_overrides", "modules.search", "code.search", "code.read", "extension.objects.find", } DEFAULT_SOURCE_MODE = "auto" DEFAULT_CACHE_POLICY_BY_SOURCE_MODE = { "runtime": "ttl", "designer": "none", "auto": "ttl", } BSL_TEMPLATE_BINDING_RE = re.compile(r"&([^&;\n\r]+)&|\{([A-Za-zА-Яа-я0-9_\-.]+)\}|\%\%([A-Za-zА-Яа-я0-9_\-.]+)\%\%") ROUTINE_RESERVE_WORDS = { "и", "или", "не", "если", "иначе", "иначеесли", "конец", "для", "все", "иначе", "процедура", "функция", "конецпроцедуры", "конецфункции", "цикл", "пока", "по", "тогда", "возврат", "прервать", "продолжить", "попытка", "исключение", "return", "and", "or", "true", "false", "undefined", "null", } SYSTEM_CALL_HINT_WORDS = { "выполнить", "выполнитьинтерфейс", "выполнитьоператор", "получитьколичество", "новый", "new", "and", "or", "not", "если", "иначе", "конец", "пока", "for", "while", "foreach", "do", "then", "else", "true", "false", "null", "undefined", "return", "прервать", "продолжить", } GUID_RE = re.compile(r"^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$") 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" TOOLS = [ { "name": "onec_health", "description": "Check the 1C adapter MCP proxy and the configured REST adapter endpoint.", "inputSchema": { "type": "object", "properties": { "base_id": { "type": "string", "description": "Optional concrete 1C base id to check. The adapter does not use a default database.", } }, "additionalProperties": False, }, }, { "name": "onec_help", "description": "Return available 1C adapter methods from the REST adapter.", "inputSchema": { "type": "object", "properties": { "method": { "type": "string", "description": "Optional method name to inspect.", } }, "additionalProperties": False, }, }, { "name": "onec_request", "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. " "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 programming in Configurator/designer, use source_state=working to read saved-but-not-applied metadata; " "use source_state=applied for active metadata or source_state=all to compare both. " "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. " "For BSL edits, prefer high-level metadata.write: pass a 1C canonical path, routine_text, and routine_operation; " "the adapter prepares saved-state when needed, saves into working/saved-state metadata, and never activates it. " "For adding a form command with a visible button and handler routine, use metadata.form.command_button.write. " "Use code.write only as a compatibility shortcut for simple module edits. " "Long metadata calls return a job_id quickly; poll it with method mcp.job.get." ), "inputSchema": { "type": "object", "required": ["method"], "examples": [ { "method": "metadata.resolve_overrides", "payload": { "base_id": "", "object_type": "", "object_name": "", "method_name": "", "source_state": "working", }, }, { "method": "metadata.objects.list", "payload": { "base_id": "", "kind": "", "limit": 50, "offset": 0, }, }, { "method": "metadata.object.full", "payload": { "base_id": "", "ref": ".", "sections": ["modules", "templates", "forms"], }, }, { "method": "extension.objects.find", "payload": { "base_id": "", "extension": "", "object_type": "CommonForm", "source_state": "working", "limit": 50, }, }, { "method": "code.search", "payload": { "base_id": "", "query": "", "object_type": "", "object_name": "", "source_state": "working", "scan_limit": 400, }, }, { "method": "code.write", "payload": { "base_id": "", "object_type": "", "object_name": "", "routine_name": "", "routine_text": "", }, }, { "method": "code.write", "payload": { "base_id": "", "extension": "", "path": "..", "routine_text": "", "mode": "apply", }, }, { "method": "bulk.execute", "payload": { "base_id": "", "cache_profile": "normal", "requests": [ { "method": "code.search", "payload": { "base_id": "", "query": "", "source_state": "working", }, }, { "method": "metadata.resolve_overrides", "payload": { "base_id": "", "method_name": "", "source_state": "working", }, }, ], }, }, { "method": "code.read", "payload": { "base_id": "", "module_ref": "", "include_line_numbers": True, "max_chars": 20000, }, }, { "method": "templates.bindings", "payload": { "base_id": "", "object_type": "", "object_name": "", }, }, { "method": "diagnostics.call_chain", "payload": { "base_id": "", "entry_method": "", "object_type": "", "object_name": "", }, }, { "method": "extensions.list", "payload": { "base_id": "", "is_active": True, "limit": 100, "offset": 0, }, }, { "method": "modules.search", "payload": { "base_id": "", "query": "", "scan_limit": 500, "resolve_owners": True, "owner_scan_limit": 80, }, }, { "method": "modules.read", "payload": { "base_id": "", "module_ref": "", "include_line_numbers": True, "include_text": True, }, }, { "method": "metadata.write.preflight", "payload": { "base_id": "", "extension": "", "target": {"canonical_path": "ОбщаяФорма..", "kind": "module"}, "intent": { "operation": "upsert_routine", "routine_text": "", }, }, }, { "method": "metadata.write", "payload": { "base_id": "", "extension": "", "target": {"canonical_path": "ОбщаяФорма.."}, "mode": "apply", "routine_operation": "upsert", "routine_text": "", }, }, { "method": "metadata.form.command_button.write", "payload": { "base_id": "", "extension": "", "form": "", "command_name": "", "button_name": "", "title": "", "handler_name": "", "handler_routine_text": "", "include_handler": True, "mode": "apply", }, }, { "method": "metadata.form.command_button.verify", "payload": { "base_id": "", "extension": "", "form": "", "command_name": "", "button_name": "", "handler_name": "", }, }, { "method": "metadata.form.write_target.verify", "payload": { "base_id": "", "extension": "", "form": "", "command": "", "property": "title", }, }, { "method": "metadata.write.history", "payload": { "base_id": "", "limit": 10, }, }, { "method": "metadata.write.rollback", "payload": { "base_id": "", "operation_id": "", "allow_sql_saved_state_rollback": True, }, }, { "method": "metadata.saved_state.prepare", "payload": { "base_id": "", "target_table": "ConfigCASSave", "object_type": "", "object_name": "", "mode": "plan", }, }, { "method": "metadata.saved_state.diff", "payload": { "base_id": "", "table": "ConfigCASSave", "file_name": "", "max_text_diff_lines": 80, }, }, { "method": "metadata.saved_state.status", "payload": { "base_id": "", "table": "ConfigCASSave", "limit": 200, }, }, { "method": "metadata.saved_state.changes.list", "payload": { "base_id": "", "limit": 200, "include_context": True, "group_by_context": True, "context_limit": 50, }, }, ], "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.", }, "payload": { "type": "object", "description": ( "Method-specific JSON payload. For object-scoped methods, pass a selector as ref, " "kind/name/guid, or MCP-friendly object_type/object_name/object_guid." ), "additionalProperties": True, }, }, "oneOf": [ { "properties": { "method": {"const": "metadata.resolve_overrides"}, "payload": { "type": "object", "required": ["base_id", "method_name"], "properties": { "base_id": {"type": "string"}, **OBJECT_SELECTOR_SCHEMA_PROPERTIES, "method_name": {"type": "string"}, "include_inactive": {"type": "boolean"}, "max_items": {"type": "integer", "minimum": 1, "maximum": 2000}, }, }, }, "required": ["method", "payload"], }, { "properties": { "method": {"const": "metadata.objects.list"}, "payload": { "type": "object", "required": ["base_id"], "properties": { "base_id": {"type": "string"}, **OBJECT_SELECTOR_SCHEMA_PROPERTIES, "name_pattern": {"type": "string"}, "limit": {"type": "integer", "minimum": 1}, "offset": {"type": "integer", "minimum": 0}, }, }, }, "required": ["method", "payload"], }, { "properties": { "method": {"const": "metadata.object.full"}, "payload": { "type": "object", "required": ["base_id"], "properties": { "base_id": {"type": "string"}, **OBJECT_SELECTOR_SCHEMA_PROPERTIES, "sections": {"type": "array", "items": {"type": "string"}}, "include_extensions": {"type": "boolean"}, "resolve_owner_chain": {"type": "boolean"}, "max_lines": {"type": "integer", "minimum": 1, "maximum": 10000}, "limit": {"type": "integer", "minimum": 1, "maximum": 500}, "offset": {"type": "integer", "minimum": 0}, }, }, }, "required": ["method", "payload"], }, { "properties": { "method": {"const": "code.search"}, "payload": { "type": "object", "required": ["base_id", "query"], "properties": { "base_id": {"type": "string"}, "query": {"type": "string"}, **OBJECT_SELECTOR_SCHEMA_PROPERTIES, "extension": {"type": "string"}, "routine_name": {"type": "string"}, "source_state": {"type": "string", "enum": ["working", "applied", "all"]}, "scope": {"type": "string", "enum": ["all", "object", "modules"]}, "regex": {"type": "boolean"}, "scan_limit": {"type": "integer", "minimum": 1, "maximum": 5000}, "limit": {"type": "integer", "minimum": 1, "maximum": 500}, "include_context": {"type": "boolean"}, "include_line_numbers": {"type": "boolean"}, "since_version": {"type": "string"}, "cache_profile": {"type": "string", "enum": ["short", "normal", "long"]}, }, }, }, "required": ["method", "payload"], }, { "properties": { "method": {"const": "code.read"}, "payload": { "type": "object", "required": ["base_id"], "properties": { "base_id": {"type": "string"}, **OBJECT_SELECTOR_SCHEMA_PROPERTIES, "id": {"type": "string"}, "routine_name": {"type": "string"}, "module_ref": {"type": "string"}, "source_state": {"type": "string", "enum": ["working", "applied", "all"]}, "include_line_numbers": {"type": "boolean"}, "include_text": {"type": "boolean"}, "max_chars": {"type": "integer", "minimum": 1, "maximum": 120000}, "include_overrides": {"type": "boolean"}, "cache_profile": {"type": "string", "enum": ["short", "normal", "long"]}, }, }, }, "required": ["method", "payload"], }, { "properties": { "method": {"const": "code.write"}, "payload": { "type": "object", "required": ["base_id"], "properties": { "base_id": {"type": "string"}, **OBJECT_SELECTOR_SCHEMA_PROPERTIES, "path": {"type": "string"}, "canonical_path": {"type": "string"}, "extension": {"type": "string"}, "routine_name": {"type": "string"}, "routine_text": {"type": "string"}, "module_text": {"type": "string"}, "full_text": {"type": "string"}, "code": {"type": "string"}, "old": {"type": "string"}, "new": {"type": "string"}, "mode": {"type": "string", "enum": ["plan", "apply"]}, "include_storage": {"type": "boolean"}, }, }, }, "required": ["method", "payload"], }, { "properties": { "method": {"const": "templates.bindings"}, "payload": { "type": "object", "required": ["base_id"], "properties": { "base_id": {"type": "string"}, **OBJECT_SELECTOR_SCHEMA_PROPERTIES, "template_name": {"type": "string"}, "include_storage": {"type": "boolean"}, "cache_profile": {"type": "string", "enum": ["short", "normal", "long"]}, }, }, }, "required": ["method", "payload"], }, { "properties": { "method": {"const": "diagnostics.call_chain"}, "payload": { "type": "object", "required": ["base_id", "entry_method"], "properties": { "base_id": {"type": "string"}, "entry_method": {"type": "string"}, "method_name": {"type": "string"}, **OBJECT_SELECTOR_SCHEMA_PROPERTIES, "include_text": {"type": "boolean"}, "max_depth": {"type": "integer", "minimum": 1, "maximum": 50}, "max_nodes": {"type": "integer", "minimum": 1, "maximum": 2000}, "stop_on": {"type": "string"}, "resolve_owners": {"type": "boolean"}, "resolve_templates": {"type": "boolean"}, "cache_profile": {"type": "string", "enum": ["short", "normal", "long"]}, }, }, }, "required": ["method", "payload"], }, { "properties": { "method": {"const": "metadata.definition.find"}, "payload": { "type": "object", "required": ["base_id", "query"], "properties": { "base_id": {"type": "string"}, "query": {"type": "string"}, **OBJECT_SELECTOR_SCHEMA_PROPERTIES, "extension": {"type": "string"}, "areas": {"type": "array", "items": {"type": "string"}}, "limit": {"type": "integer", "minimum": 1, "maximum": 10000}, }, }, }, "required": ["method", "payload"], }, { "properties": { "method": {"const": "modules.search"}, "description": "Search BSL text in decoded modules with optional owner resolution.", "payload": { "type": "object", "required": ["base_id", "query"], "properties": { "base_id": {"type": "string"}, "query": {"type": "string"}, **OBJECT_SELECTOR_SCHEMA_PROPERTIES, "extension": {"type": "string"}, "scan_limit": {"type": "integer", "minimum": 1, "maximum": 5000}, "owner_scan_limit": {"type": "integer", "minimum": 1, "maximum": 200}, "limit": {"type": "integer", "minimum": 1, "maximum": 100}, "resolve_owners": {"type": "boolean"}, "include_storage": {"type": "boolean"}, }, }, }, "required": ["method", "payload"], }, { "properties": { "method": {"const": "extensions.list"}, "description": "Enumerate extensions with active state and load order.", "payload": { "type": "object", "required": ["base_id"], "properties": { "base_id": {"type": "string"}, "is_active": {"type": "boolean"}, "is_forbid_conflict": {"type": "boolean"}, "include_storage": {"type": "boolean"}, "limit": {"type": "integer", "minimum": 1}, "offset": {"type": "integer", "minimum": 0}, }, }, }, "required": ["method", "payload"], }, { "properties": { "method": {"const": "bulk.execute"}, "payload": { "type": "object", "required": ["base_id", "requests"], "properties": { "base_id": {"type": "string"}, "cache_profile": {"type": "string", "enum": ["short", "normal", "long"]}, "requests": { "type": "array", "maxItems": 30, "items": { "type": "object", "required": ["method", "payload"], "properties": { "method": {"type": "string"}, "payload": {"type": "object"}, }, }, }, "limit": {"type": "integer", "minimum": 1, "maximum": 50}, "offset": {"type": "integer", "minimum": 0}, }, }, }, "required": ["method", "payload"], }, { "properties": { "method": {"const": "modules.read"}, "description": "Read module source by module_ref or owner selector.", "payload": { "type": "object", "required": ["base_id"], "properties": { "base_id": {"type": "string"}, "module_ref": {"type": "string"}, **OBJECT_SELECTOR_SCHEMA_PROPERTIES, "module_type": {"type": "string"}, "include_line_numbers": {"type": "boolean"}, "include_text": {"type": "boolean"}, "include_storage": {"type": "boolean"}, "preview": {"type": "boolean"}, "max_chars": {"type": "integer", "minimum": 1, "maximum": 100000}, }, }, }, "required": ["method", "payload"], }, ], "additionalProperties": False, }, }, { "name": "onec_job_get", "description": "Return the status/result of a long 1C adapter job started by onec_request.", "inputSchema": { "type": "object", "required": ["job_id"], "properties": { "job_id": { "type": "string", "description": "Job id returned by onec_request.", }, "consume": { "type": "boolean", "description": "Remove a completed job after reading it.", }, }, "additionalProperties": False, }, }, { "name": "onec_job_cancel", "description": "Request cancellation of a long 1C adapter job started by onec_request.", "inputSchema": { "type": "object", "required": ["job_id"], "properties": { "job_id": { "type": "string", "description": "Job id returned by onec_request.", }, }, "additionalProperties": False, }, }, { "name": "access_role_users", "description": "Find users that receive a 1C/BSP role through access profiles and groups. Supports fuzzy role phrases such as 'запись изменение номенклатура поставщиков'.", "inputSchema": { "type": "object", "required": ["base_id", "role"], "properties": { "base_id": {"type": "string", "description": "Concrete 1C base id."}, "role": {"type": "string", "description": "Role id, exact role name, substring, or fuzzy natural-language role phrase."}, "limit": {"type": "integer", "minimum": 1, "maximum": 20000, "default": 20000}, "timeout_seconds": {"type": "integer", "minimum": 1, "maximum": 120, "default": 120}, }, "additionalProperties": False, }, }, { "name": "access_role_profiles", "description": "Find 1C/BSP access profiles that include a role, plus access groups using those profiles.", "inputSchema": { "type": "object", "required": ["base_id", "role"], "properties": { "base_id": {"type": "string", "description": "Concrete 1C base id."}, "role": {"type": "string", "description": "Role id, exact role name, substring, or fuzzy natural-language role phrase."}, "limit": {"type": "integer", "minimum": 1, "maximum": 20000, "default": 1000}, "timeout_seconds": {"type": "integer", "minimum": 1, "maximum": 120, "default": 120}, }, "additionalProperties": False, }, }, { "name": "access_role_audit_export", "description": "Export flat 1C/BSP role audit rows: role -> profile -> access group -> user. Can include CSV text.", "inputSchema": { "type": "object", "required": ["base_id", "role"], "properties": { "base_id": {"type": "string", "description": "Concrete 1C base id."}, "role": {"type": "string", "description": "Role id, exact role name, substring, or fuzzy natural-language role phrase."}, "format": {"type": "string", "enum": ["json", "csv"], "default": "json"}, "limit": {"type": "integer", "minimum": 1, "maximum": 20000, "default": 20000}, "timeout_seconds": {"type": "integer", "minimum": 1, "maximum": 120, "default": 120}, }, "additionalProperties": False, }, }, { "name": "access_role_audit_analyze", "description": "Analyze a 1C/BSP role audit chain and return risk findings for broad groups, many users, external users, fuzzy matches, and multiple paths.", "inputSchema": { "type": "object", "required": ["base_id", "role"], "properties": { "base_id": {"type": "string", "description": "Concrete 1C base id."}, "role": {"type": "string", "description": "Role id, exact role name, substring, or fuzzy natural-language role phrase."}, "user_threshold": {"type": "integer", "minimum": 1, "maximum": 100000, "default": 50}, "timeout_seconds": {"type": "integer", "minimum": 1, "maximum": 120, "default": 120}, }, "additionalProperties": False, }, }, { "name": "access_role_audit_compare_latest", "description": "Compare the latest two local access audit reports for a base/role and return added, removed, and changed access-path users.", "inputSchema": { "type": "object", "required": ["base_id", "role"], "properties": { "base_id": {"type": "string", "description": "Concrete 1C base id."}, "role": {"type": "string", "description": "Role query used in saved access audit reports."}, "report_root": {"type": "string", "description": "Optional local report root. Defaults to reports/1c-access."}, "write_artifacts": {"type": "boolean", "default": True}, }, "additionalProperties": False, }, }, { "name": "infobase_users_search", "description": "Default user search for 1C infobase users visible in Configurator. Authoritative for platform identity, authentication flags, platform administrator, and RolesID. Do not substitute BSP catalog users or profiles for Configurator role assignments.", "inputSchema": { "type": "object", "required": ["base_id"], "properties": { "base_id": {"type": "string", "description": "Concrete 1C base id."}, "query": {"type": "string", "description": "Optional Configurator user name, full name, or platform user id. Empty returns the first page."}, "limit": {"type": "integer", "minimum": 1, "maximum": 500, "default": 20}, "scan_limit": {"type": "integer", "minimum": 1, "maximum": 50000, "default": 5000}, "timeout_seconds": {"type": "integer", "minimum": 1, "maximum": 120, "default": 30}, }, "additionalProperties": False, }, }, { "name": "infobase_user_get", "description": "Get one 1C infobase/Configurator user by exact name or platform id. Exact configuration role names are reported as runtime-required when SQL exposes only RolesID.", "inputSchema": { "type": "object", "required": ["base_id", "user"], "properties": { "base_id": {"type": "string", "description": "Concrete 1C base id."}, "user": {"type": "string", "description": "Exact Configurator user name or platform user id."}, "scan_limit": {"type": "integer", "minimum": 1, "maximum": 50000, "default": 5000}, "timeout_seconds": {"type": "integer", "minimum": 1, "maximum": 120, "default": 30}, }, "additionalProperties": False, }, }, { "name": "infobase_user_password_capabilities", "description": "Report whether protected password set/clear operations are ready for Configurator users in a concrete infobase. No password is accepted by this diagnostic tool.", "inputSchema": { "type": "object", "required": ["base_id"], "properties": { "base_id": {"type": "string", "description": "Concrete 1C base id."} }, "additionalProperties": False, }, }, { "name": "infobase_user_password_status", "description": "Read whether one exact infobase/Configurator user has an empty or non-empty password without exposing hashes or protected Data. Also reports whether standard authentication is enabled.", "inputSchema": { "type": "object", "required": ["base_id", "user"], "properties": { "base_id": {"type": "string", "description": "Concrete 1C base id."}, "user": {"type": "string", "description": "Exact Configurator user name or platform user id."}, "scan_limit": {"type": "integer", "minimum": 1, "maximum": 50000, "default": 5000}, "timeout_seconds": {"type": "integer", "minimum": 1, "maximum": 120, "default": 30} }, "additionalProperties": False, }, }, { "name": "infobase_user_password_set", "description": "Set a new password for one exact infobase/Configurator user through a guarded SQL transaction on dbo.v8users.Data. The adapter writes the normal and uppercase SHA-1/Base64 pair, verifies readback, and never echoes or persists the clear-text password.", "inputSchema": { "type": "object", "required": ["base_id", "user", "confirm_user_id", "new_password", "allow_password_change"], "properties": { "base_id": {"type": "string", "description": "Concrete 1C base id."}, "user": {"type": "string", "description": "Exact Configurator user name or platform user id."}, "confirm_user_id": {"type": "string", "description": "Exact 32-hex id returned by infobase_user_get."}, "new_password": {"type": "string", "minLength": 1, "maxLength": 1024, "writeOnly": True, "description": "Secret new password. It is sent only to the configured 1C runtime bridge and is never returned."}, "allow_password_change": {"type": "boolean", "description": "Must be true after reviewing the exact target."}, "allow_administrator_password_change": {"type": "boolean", "default": False, "description": "Additional confirmation required when the selected user is a platform administrator."}, "request_id": {"type": "string", "description": "Optional idempotency/audit request id."}, "timeout_seconds": {"type": "integer", "minimum": 1, "maximum": 120, "default": 30} }, "additionalProperties": False, }, }, { "name": "infobase_user_password_clear", "description": "Remove (clear) the password of one exact infobase/Configurator user through a guarded SQL transaction on dbo.v8users.Data. The adapter changes only the current password hash pair and verifies readback. No password argument is accepted.", "inputSchema": { "type": "object", "required": ["base_id", "user", "confirm_user_id", "allow_password_clear"], "properties": { "base_id": {"type": "string", "description": "Concrete 1C base id."}, "user": {"type": "string", "description": "Exact Configurator user name or platform user id."}, "confirm_user_id": {"type": "string", "description": "Exact 32-hex id returned by infobase_user_get."}, "allow_password_clear": {"type": "boolean", "description": "Must be true after reviewing the exact target."}, "allow_administrator_password_change": {"type": "boolean", "default": False, "description": "Additional confirmation required when the selected user is a platform administrator."}, "request_id": {"type": "string", "description": "Optional idempotency/audit request id."}, "timeout_seconds": {"type": "integer", "minimum": 1, "maximum": 120, "default": 30} }, "additionalProperties": False, }, }, { "name": "access_user_explain", "description": "Explain BSP catalog access for one explicitly BSP-scoped user: groups, profiles, technical roles, permissions, and access keys. This does not prove Configurator authentication or direct platform role assignments; ordinary 'user' requests must start with infobase_users_search.", "inputSchema": { "type": "object", "required": ["base_id", "user"], "properties": { "base_id": {"type": "string", "description": "Concrete 1C base id."}, "user": {"type": "string", "description": "User name, user id/ref, or ref tail."}, "preset": {"type": "string", "default": "bsp"}, "object": {"type": "string"}, "action": {"type": "string"}, "resolve_records": {"type": "boolean"}, "max_effective_permissions_per_user": {"type": "integer", "minimum": 1, "maximum": 20000}, "timeout_seconds": {"type": "integer", "minimum": 1, "maximum": 120, "default": 120}, }, "additionalProperties": False, }, }, { "name": "access_users_search", "description": "Search BSP catalog users by name, login, id/ref tail, or fuzzy fragment. Use only for explicit BSP group/profile/RLS questions; ordinary 'users' means infobase/Configurator users and must use infobase_users_search.", "inputSchema": { "type": "object", "required": ["base_id", "query"], "properties": { "base_id": {"type": "string", "description": "Concrete 1C base id."}, "query": {"type": "string", "description": "User name, login fragment, id/ref tail, or fuzzy text."}, "limit": {"type": "integer", "minimum": 1, "maximum": 200, "default": 20}, "scan_limit": {"type": "integer", "minimum": 1, "maximum": 50000, "default": 20000}, "timeout_seconds": {"type": "integer", "minimum": 1, "maximum": 120, "default": 120}, }, "additionalProperties": False, }, }, { "name": "access_object_explain", "description": "Explain who can see a BSP-protected object/record by resolving object access keys to groups, user sets, and users.", "inputSchema": { "type": "object", "required": ["base_id"], "properties": { "base_id": {"type": "string", "description": "Concrete 1C base id."}, "object": {"type": "string", "description": "Normalized object ref from access_object_keys.object."}, "object_id": {"type": "string", "description": "Data record id from access_object_keys.object_id."}, "object_sql_number": {"type": "integer"}, "access_key": {"type": "string"}, "limit": {"type": "integer", "minimum": 1, "maximum": 20000, "default": 1000}, "subject_limit": {"type": "integer", "minimum": 1, "maximum": 20000, "default": 1000}, "max_resolved_records": {"type": "integer", "minimum": 0, "maximum": 5000, "default": 200}, "timeout_seconds": {"type": "integer", "minimum": 1, "maximum": 120, "default": 120}, }, "additionalProperties": False, }, }, { "name": "access_keys_query", "description": "Page through BSP access key registers by group, user set, object, access set, or all. Use for data restriction diagnostics, not for metadata-object role rights.", "inputSchema": { "type": "object", "required": ["base_id"], "properties": { "base_id": {"type": "string", "description": "Concrete 1C base id."}, "kind": {"type": "string", "description": "group, user, object, access_set, or all."}, "group": {"type": "string"}, "user": {"type": "string"}, "user_set": {"type": "string"}, "object": {"type": "string"}, "object_id": {"type": "string"}, "object_sql_number": {"type": "integer"}, "access_key": {"type": "string"}, "limit": {"type": "integer", "minimum": 1, "maximum": 20000, "default": 1000}, "offset": {"type": "integer", "minimum": 0, "default": 0}, "timeout_seconds": {"type": "integer", "minimum": 1, "maximum": 120, "default": 120}, }, "additionalProperties": False, }, }, { "name": "access_object_keys_resolve", "description": "Return BSP object access-key rows and resolve object records when possible. Accepts object names/public refs and internal ids.", "inputSchema": { "type": "object", "required": ["base_id"], "properties": { "base_id": {"type": "string", "description": "Concrete 1C base id."}, "object": {"type": "string"}, "object_id": {"type": "string"}, "object_sql_number": {"type": "integer"}, "access_key": {"type": "string"}, "limit": {"type": "integer", "minimum": 1, "maximum": 20000, "default": 1000}, "offset": {"type": "integer", "minimum": 0, "default": 0}, "max_resolved_records": {"type": "integer", "minimum": 0, "maximum": 5000, "default": 200}, "timeout_seconds": {"type": "integer", "minimum": 1, "maximum": 120, "default": 120}, }, "additionalProperties": False, }, }, { "name": "access_object_roles", "description": "Find BSP roles that grant permissions for one metadata object and summarize read/insert/update/delete rights. Accepts object selectors by ref, kind/name, or GUID aliases.", "inputSchema": { "type": "object", "required": ["base_id"], "properties": { "base_id": {"type": "string", "description": "Concrete 1C base id."}, "ref": {"type": "string", "description": "Public object ref such as InformationRegister.УОП_АктуальныеСпецификации or РегистрСведений.УОП_АктуальныеСпецификации."}, "kind": {"type": "string"}, "name": {"type": "string"}, "guid": {"type": "string"}, "object_type": {"type": "string"}, "object_name": {"type": "string"}, "object_guid": {"type": "string"}, "action": {"type": "string", "description": "Optional right filter such as read, insert, update, delete, Просмотр, Добавление, Изменение, or Удаление."}, "limit": {"type": "integer", "minimum": 1, "maximum": 20000, "default": 200}, "max_effective_permissions_per_user": {"type": "integer", "minimum": 0, "maximum": 200000, "default": 0}, "timeout_seconds": {"type": "integer", "minimum": 1, "maximum": 120, "default": 120}, }, "additionalProperties": False, }, }, { "name": "access_object_subjects", "description": "Find roles, profiles, access groups, and users that receive permissions for one metadata object. Accepts names/public refs and resolves internal identifiers automatically.", "inputSchema": { "type": "object", "required": ["base_id"], "properties": { "base_id": {"type": "string", "description": "Concrete 1C base id."}, "ref": {"type": "string", "description": "Public object ref such as РегистрСведений.УОП_АктуальныеСпецификации or InformationRegister.Name."}, "kind": {"type": "string"}, "name": {"type": "string"}, "guid": {"type": "string"}, "object_type": {"type": "string"}, "object_name": {"type": "string"}, "object_guid": {"type": "string"}, "action": {"type": "string", "description": "Optional right filter such as read, write, insert, update, delete, Просмотр, Добавление, or Изменение."}, "limit": {"type": "integer", "minimum": 1, "maximum": 20000, "default": 1000}, "max_effective_permissions_per_user": {"type": "integer", "minimum": 0, "maximum": 200000, "default": 0}, "include_access_key_scope": {"type": "boolean", "default": False, "description": "Also return BSP subject access key scope. This is useful for data restriction diagnostics and can be slower."}, "access_key_scope_limit": {"type": "integer", "minimum": 1, "maximum": 200000, "default": 20000, "description": "Maximum rows to read from each BSP access-key extractor when include_access_key_scope is true."}, "access_key_scope_subject_limit": {"type": "integer", "minimum": 1, "maximum": 200000, "default": 20000, "description": "Maximum matched groups/users to include in access-key scope diagnostics. This is independent from the response limit."}, "timeout_seconds": {"type": "integer", "minimum": 1, "maximum": 120, "default": 120}, }, "additionalProperties": False, }, }, { "name": "access_rls_discover", "description": "Discover metadata candidates for BSP/RLS/data restriction extraction by names such as Огранич, Доступ, RLS, and Ключ. Returns storage routes and fields for building a verified extractor.", "inputSchema": { "type": "object", "required": ["base_id"], "properties": { "base_id": {"type": "string", "description": "Concrete 1C base id."}, "terms": { "type": "array", "items": {"type": "string"}, "description": "Optional search terms. Defaults to Огранич, Доступ, RLS, Ключ.", }, "limit": {"type": "integer", "minimum": 1, "maximum": 500, "default": 50}, "timeout_seconds": {"type": "integer", "minimum": 1, "maximum": 120, "default": 120}, }, "additionalProperties": False, }, }, ] def keep_onec_request_schema_generic() -> None: for tool in TOOLS: if tool.get("name") != "onec_request": continue schema = tool.get("inputSchema") if not isinstance(schema, dict): return # onec_request is the adapter pass-through. Do not enumerate adapter # methods here; the REST adapter owns method-specific contracts. schema.pop("oneOf", None) schema["additionalProperties"] = False return keep_onec_request_schema_generic() class AdapterError(RuntimeError): def __init__(self, message: str, *, status: int | None = None, body: str = "") -> None: super().__init__(message) self.status = status self.body = body def adapter_url() -> str: return os.environ.get("ONEC_ADAPTER_URL", DEFAULT_ADAPTER_URL).rstrip("/") def adapter_token() -> str: return os.environ.get("ONEC_ADAPTER_TOKEN", "") def adapter_timeout() -> float: try: return max(0.5, float(os.environ.get("ONEC_ADAPTER_TIMEOUT_SECONDS", "120"))) except ValueError: return 120.0 def job_fast_wait_seconds() -> float: try: return max(0.0, min(float(os.environ.get("ONEC_MCP_JOB_FAST_WAIT_SECONDS", "1.5")), 3.0)) except ValueError: return 1.5 def job_heartbeat_seconds() -> float: try: return max(0.2, float(os.environ.get("ONEC_MCP_JOB_HEARTBEAT_SECONDS", "1.0"))) except ValueError: return 1.0 def job_timeout_seconds(payload: dict[str, Any]) -> float: raw_timeout = payload.get("timeout_seconds") if raw_timeout is None: raw_timeout = os.environ.get("ONEC_MCP_JOB_TIMEOUT_SECONDS", "600") try: return max(1.0, float(raw_timeout)) except (TypeError, ValueError): return 600.0 JOB_TTL_SECONDS = 3600.0 def truthy(value: Any) -> bool: if isinstance(value, bool): return value if value is None: return False return str(value).strip().lower() in {"1", "true", "yes", "y", "on"} def normalize_source_mode(value: Any) -> str: value_text = str(value or "").strip().lower() if value_text not in SOURCE_MODES: return DEFAULT_SOURCE_MODE return value_text def normalize_source_state(value: Any, source_mode: str) -> str: value_text = str(value or "").strip().lower() if value_text == "runtime": return "applied" if value_text == "designer": return "working" if value_text in SOURCE_STATES: return value_text return "working" if source_mode == "designer" else "applied" def normalize_cache_policy(value: Any, source_mode: str) -> str: value_text = str(value or "").strip().lower() if value_text not in CACHE_POLICIES: return DEFAULT_CACHE_POLICY_BY_SOURCE_MODE.get(source_mode, "ttl") if source_mode == "designer" and value_text == "ttl": return "none" return value_text def is_guid_text(value: Any) -> bool: if not isinstance(value, str): return False return GUID_RE.match(value.strip().lower()) is not None def metadata_cache_lookup_identity(base_id: str, guid: str) -> dict[str, Any] | None: if not base_id or not is_guid_text(guid): return None try: result = call_adapter_method("metadata.cache.lookup", {"base_id": base_id, "guid": guid}) except AdapterError: return None if not isinstance(result, dict) or result.get("status") != "ok": return None identity = result.get("object") if not isinstance(identity, dict): return None return identity def metadata_cache_lookup_identity_cached( base_id: str, guid: str, owner_cache: dict[str, dict[str, Any] | None], lookup_budget: dict[str, int], ) -> dict[str, Any] | None: normalized_guid = str(guid).strip().lower() if not is_guid_text(normalized_guid): return None if normalized_guid in owner_cache: return owner_cache.get(normalized_guid) if lookup_budget.get("remaining", 0) <= 0: return None lookup_budget["remaining"] = max(0, int(lookup_budget.get("remaining", 0)) - 1) identity = metadata_cache_lookup_identity(base_id, normalized_guid) owner_cache[normalized_guid] = identity return identity def resolve_owner_metadata(base_id: str, owner_payload: Any, context_payload: Any, owner_cache: dict[str, dict[str, Any] | None], lookup_budget: dict[str, int]) -> dict[str, Any] | None: if not isinstance(owner_payload, dict): return None if owner_payload.get("status") == "resolved" and owner_payload.get("name") and owner_payload.get("kind") and owner_payload.get("guid"): return owner_payload owner_guid = owner_payload.get("guid") or owner_payload.get("id") if not is_guid_text(owner_guid): if isinstance(context_payload, dict): owner_guid = context_payload.get("guid") or context_payload.get("owner_guid") if not is_guid_text(owner_guid): return owner_payload identity = metadata_cache_lookup_identity_cached(base_id, str(owner_guid), owner_cache, lookup_budget) if not identity: return owner_payload return { "status": "resolved", "kind": identity.get("kind"), "name": identity.get("name"), "synonym": identity.get("synonym"), "guid": str(owner_guid).lower(), } def enrich_owner_metadata_in_data(payload: dict[str, Any], data: Any) -> Any: base_id = str(payload.get("base_id") or "").strip() if not is_guid_text(base_id): return data owner_cache: dict[str, dict[str, Any] | None] = {} lookup_budget = {"remaining": OWNER_IDENTITY_LOOKUP_LIMIT} requested_object_type = str(payload.get("object_type") or "") requested_object_name = str(payload.get("object_name") or "") requested_object_guid = str(payload.get("object_guid") or "") def walk(obj: Any, context: dict[str, Any] | None = None) -> Any: if not isinstance(obj, dict): return [walk(item, context) for item in obj] if isinstance(obj, list) else obj enriched = dict(obj) current_context = context if isinstance(context, dict) else None if current_context is None: current_context = enriched.get("read_selector") if isinstance(enriched.get("read_selector"), dict) else None owner_fields = [ "owner", "resolved_owner", ] for key in owner_fields: if key not in enriched: continue context_payload = current_context if isinstance(current_context, dict) else {} owner_payload = enriched.get(key) resolved_owner = resolve_owner_metadata(base_id, owner_payload, context_payload, owner_cache, lookup_budget) normalized_owner = _normalize_owner_output( base_id, resolved_owner, fallback_kind=str(context_payload.get("kind") or context_payload.get("object_type") or requested_object_type), fallback_name=str(context_payload.get("name") or context_payload.get("object_name") or requested_object_name), fallback_guid=str(context_payload.get("guid") or context_payload.get("object_guid") or requested_object_guid), ) if normalized_owner is None and owner_payload is not None: normalized_owner = owner_payload if isinstance(owner_payload, dict) else {"status": "unknown"} enriched[key] = normalized_owner if isinstance(normalized_owner, dict): enriched[f"{key}_display"] = { "kind": normalized_owner.get("kind"), "name": normalized_owner.get("name"), "guid": normalized_owner.get("guid"), } for key, value in list(enriched.items()): if key in owner_fields: continue nested = walk(value, current_context) if isinstance(nested, dict): current = dict(nested) else: current = nested enriched[key] = current return enriched return walk(data) def _build_object_display_payload(raw: dict[str, Any] | Any) -> dict[str, Any] | None: if not isinstance(raw, dict): return None kind = raw.get("kind") or raw.get("object_type") or raw.get("type") name = raw.get("name") or raw.get("synonym") or raw.get("title") guid = raw.get("guid") or raw.get("id") if not (kind or name or guid): return None return {"kind": kind, "name": name, "guid": guid} def _ensure_display_fields_for_object_item(item: dict[str, Any], *, fallback_kind: str | None = None) -> None: if not isinstance(item, dict): return if item.get("object_display") is None: built = _build_object_display_payload(item) if built is not None: item["object_display"] = built else: fallback_name = item.get("name") or item.get("synonym") or item.get("title") or item.get("caption") if fallback_name or item.get("guid") or item.get("id"): item["object_display"] = { "kind": item.get("kind") or fallback_kind or item.get("object_type") or item.get("type"), "name": fallback_name, "guid": item.get("guid") or item.get("id") or item.get("ref"), } owner_payload = item.get("owner") if isinstance(item.get("owner"), dict) else None if owner_payload is not None and item.get("owner_display") is None: owner_display = _build_object_display_payload(owner_payload) if owner_display is not None: item["owner_display"] = owner_display elif owner_payload.get("kind") or owner_payload.get("name") or owner_payload.get("guid"): item["owner_display"] = { "kind": owner_payload.get("kind"), "name": owner_payload.get("name"), "guid": owner_payload.get("guid"), } def _enrich_nested_object_display(section: dict[str, Any], fallback_kind: str | None = None) -> None: if not isinstance(section, dict): return for key in ("items", "attributes", "tabular_sections", "properties", "fields", "nodes"): nested_items = section.get(key) if not isinstance(nested_items, list): continue for nested in nested_items: if not isinstance(nested, dict): continue _ensure_display_fields_for_object_item(nested, fallback_kind=fallback_kind) def _enrich_metadata_objects_list_display(payload: dict[str, Any], result: Any) -> Any: _ = payload # kept for future heuristics and compatibility if not isinstance(result, dict) and not isinstance(result, list): return result enriched = result candidates: list[Any] = [] if isinstance(result, list): candidates = result elif isinstance(result, dict): for container_key in ("items", "objects", "result", "data"): container = result.get(container_key) if isinstance(container, list): candidates = container break for item in candidates: if not isinstance(item, dict): continue if "object_display" in item: continue item_display = _build_object_display_payload(item) if item_display is not None: item["object_display"] = item_display return enriched def _enrich_metadata_object_full_display(payload: dict[str, Any], result: Any) -> Any: if not isinstance(result, dict): return result enriched = result object_kind = None if isinstance(enriched.get("query"), dict): object_kind = enriched["query"].get("kind") or enriched["query"].get("object_type") query = enriched.get("query") if isinstance(query, dict) and not enriched.get("object_display"): owner_display = { "kind": query.get("kind"), "name": query.get("name"), "guid": query.get("guid"), } if any(owner_display.get(key) for key in ("kind", "name", "guid")): enriched["object_display"] = owner_display if "object" in enriched and isinstance(enriched.get("object"), dict) and "object_display" not in enriched["object"]: object_data = _build_object_display_payload(enriched.get("object") or {}) if object_data is not None: enriched["object"]["object_display"] = object_data for section_key in ("forms", "templates", "commands", "modules"): section_items = enriched.get(section_key) if not isinstance(section_items, list): continue for item in section_items: if not isinstance(item, dict): continue if "object_display" not in item: candidate = _build_object_display_payload(item) if candidate is not None: item["object_display"] = candidate elif object_kind: candidate_name = item.get("name") or item.get("synonym") candidate_guid = item.get("guid") or item.get("id") if candidate_name or candidate_guid: item["object_display"] = {"kind": object_kind, "name": candidate_name, "guid": candidate_guid} semantic = enriched.get("semantic") if isinstance(semantic, dict): sections = semantic.get("sections") if isinstance(sections, list): for section in sections: if not isinstance(section, dict): continue if section.get("name") and not section.get("object_display"): section["object_display"] = { "kind": section.get("kind") or object_kind, "name": section.get("name"), "guid": section.get("guid") or section.get("id"), } if section.get("owner") and isinstance(section.get("owner"), dict) and section.get("owner_display") is None: owner_payload = section.get("owner") owner_display = _build_object_display_payload(owner_payload) if owner_display is not None: section["owner_display"] = owner_display else: section["owner_display"] = { "kind": owner_payload.get("kind"), "name": owner_payload.get("name"), "guid": owner_payload.get("guid"), } _enrich_nested_object_display(section, fallback_kind=str(object_kind or section.get("kind") or "")) return enriched def _enrich_modules_search_display(payload: dict[str, Any], result: Any) -> Any: _ = payload # preserved for compatibility if not isinstance(result, dict): return result candidates: list[Any] = [] container_keys = ("items", "result", "modules", "matches", "data") for key in container_keys: container = result.get(key) if isinstance(container, list): candidates = container break if not candidates and isinstance(result.get("result"), dict): nested = result.get("result") for key in container_keys: nested_container = nested.get(key) if isinstance(nested_container, list): candidates = nested_container break for item in candidates: if not isinstance(item, dict): continue read_selector = item.get("read_selector") if isinstance(item.get("read_selector"), dict) else {} owner_payload = item.get("owner") if isinstance(owner_payload, dict): owner_display = _build_object_display_payload(owner_payload) if owner_display is not None: item["owner_display"] = owner_display elif ( owner_payload.get("kind") or owner_payload.get("name") or owner_payload.get("guid") or owner_payload.get("id") ): item["owner_display"] = { "kind": owner_payload.get("kind"), "name": owner_payload.get("name"), "guid": owner_payload.get("guid") or owner_payload.get("id"), } module_ref = item.get("module_ref") or read_selector.get("module_ref") if "source_ref_display" not in item and (module_ref or item.get("module_name")): selector_display = _build_object_display_payload(read_selector) item["source_ref_display"] = { "kind": selector_display.get("kind") if selector_display else "module", "name": selector_display.get("name") if selector_display else item.get("module_name") or item.get("name"), "guid": module_ref, "method": read_selector.get("method"), } if "object_display" not in item: object_display = _build_object_display_payload(item) if object_display is None: object_display = { "kind": item.get("object_type") or item.get("kind") or item.get("owner_kind"), "name": item.get("name") or item.get("module_name") or item.get("routine_name"), "guid": item.get("guid") or item.get("id") or module_ref, } item["object_display"] = object_display return result def maybe_enrich_owner_fields(method: str, payload: dict[str, Any] | None, result: Any) -> Any: if method not in { "modules.search", "modules.read", "code.search", "code.read", "metadata.object.full", "metadata.objects.list", "metadata.resolve_overrides", "templates.bindings", "templates.read", "templates.analyze", "templates.map", "extension.objects.find", "metadata.route.resolve", "diagnostics.call_chain", }: return result if not isinstance(payload, dict): return result result = enrich_owner_metadata_in_data(payload, result) if method == "metadata.object.full": result = _enrich_metadata_object_full_display(payload, result) elif method == "metadata.objects.list": result = _enrich_metadata_objects_list_display(payload, result) elif method in {"modules.search", "code.search"}: result = _enrich_modules_search_display(payload, result) elif method in {"templates.bindings", "templates.read", "templates.analyze", "templates.map", "extension.objects.find", "metadata.route.resolve", "diagnostics.call_chain", "code.read", "modules.read"}: if method == "modules.read" and isinstance(result, dict): owner_payload = result.get("owner") if isinstance(owner_payload, dict): owner_display = _build_object_display_payload(owner_payload) if owner_display is not None: result["owner_display"] = owner_display elif owner_payload.get("kind") or owner_payload.get("name") or owner_payload.get("guid"): result["owner_display"] = { "kind": owner_payload.get("kind"), "name": owner_payload.get("name"), "guid": owner_payload.get("guid"), } module_payload = result.get("module") if isinstance(result.get("module"), dict) else None if isinstance(module_payload, dict): module_display = _build_object_display_payload(module_payload) if module_display is not None: result["module_display"] = module_display elif module_payload.get("name") or module_payload.get("module_ref") or module_payload.get("guid"): result["module_display"] = { "kind": module_payload.get("kind") or module_payload.get("module_type") or "module", "name": module_payload.get("name"), "guid": module_payload.get("module_ref") or module_payload.get("guid") or module_payload.get("id"), } if isinstance(result, dict) and isinstance(result.get("items"), list): for item in result.get("items") or []: if not isinstance(item, dict): continue owner_payload = item.get("owner") if isinstance(owner_payload, dict): owner_display = _build_object_display_payload(owner_payload) if owner_display is not None: item["owner_display"] = owner_display elif owner_payload.get("kind") or owner_payload.get("name") or owner_payload.get("guid"): item["owner_display"] = { "kind": owner_payload.get("kind"), "name": owner_payload.get("name"), "guid": owner_payload.get("guid"), } module_payload = item.get("module") if isinstance(item.get("module"), dict) else None if isinstance(module_payload, dict): module_display = _build_object_display_payload(module_payload) if module_display is not None: item["module_display"] = module_display elif module_payload.get("name") or module_payload.get("guid"): item["module_display"] = { "kind": module_payload.get("kind") or module_payload.get("module_type") or "module", "name": module_payload.get("name"), "guid": module_payload.get("module_ref") or module_payload.get("guid") or module_payload.get("id"), } if method == "diagnostics.call_chain": if isinstance(result, dict): diagnostics = result.get("diagnostics") if isinstance(diagnostics, dict): edges = diagnostics.get("edges") if isinstance(edges, list): for edge in edges: if not isinstance(edge, dict): continue edge_owner = edge.get("owner") if isinstance(edge.get("owner"), dict) else None if isinstance(edge_owner, dict): edge["owner_display"] = _build_object_display_payload(edge_owner) or edge_owner return result def coerce_int(value: Any, default: int | None = None, *, minimum: int | None = None, maximum: int | None = None) -> int | None: try: parsed = int(value) except (TypeError, ValueError): return default if minimum is not None: parsed = max(minimum, parsed) if maximum is not None: parsed = min(maximum, parsed) return parsed def normalize_cache_profile(payload: dict[str, Any]) -> str: profile = str(payload.get("cache_profile") or "normal").strip().lower() if profile not in NEW_METHOD_CACHE_TTL_SECONDS: return "normal" return profile def cache_ttl_for_profile(profile: str) -> int: return int(NEW_METHOD_CACHE_TTL_SECONDS.get(profile, NEW_METHOD_CACHE_TTL_SECONDS["normal"])) def make_cache_key(method: str, payload: dict[str, Any], version: int = NEW_API_VERSION) -> str: payload_snapshot = dict(payload) payload_snapshot.pop("_mcp_request_id", None) payload_snapshot.pop("consume", None) payload_snapshot.pop("mcp_async", None) payload_snapshot.pop("_mcp_async", None) payload_snapshot.pop("mcp_sync", None) payload_snapshot.pop("_mcp_sync", None) payload_text = json.dumps(payload_snapshot, ensure_ascii=False, sort_keys=True, default=str) key_material = f"{method}@v{version}:{payload_text}" return hashlib.md5(key_material.encode("utf-8")).hexdigest() def read_cached_response(cache_key: str) -> dict[str, Any] | None: entry = NEW_METHOD_CACHE.get(cache_key) if not entry: return None expires_at = float(entry.get("expires_at") or 0) if expires_at <= now_ts(): NEW_METHOD_CACHE.pop(cache_key, None) return None cached = entry.get("value") if not isinstance(cached, dict): return None result = dict(cached) result["cache_hit"] = True result["cache_expires_at"] = expires_at return result def write_cache_response(cache_key: str, payload: dict[str, Any], profile: str, response: Any) -> None: ttl = cache_ttl_for_profile(profile) NEW_METHOD_CACHE[cache_key] = { "expires_at": now_ts() + float(ttl), "value": response, "cache_key": cache_key, "cache_profile": profile, } def _normalize_unified_response( method: str, request_id: str, payload: dict[str, Any], request_start: float, *, items: list[Any] | Any, status: str = "ok", warnings: list[str] | None = None, diagnostics: dict[str, Any] | None = None, cache_key: str | None = None, cache_profile: str = "normal", cache_hit: bool = False, total: int | None = None, ) -> dict[str, Any]: request_items = items if isinstance(items, list) else [] if not isinstance(items, list): request_items = [items] if items is not None else [] limit = coerce_int(payload.get("limit"), 50, minimum=1, maximum=500) or 50 offset = coerce_int(payload.get("offset"), 0, minimum=0) or 0 total_count = len(request_items) if total is None else int(total) paginated = request_items[offset : offset + limit] response = { "schema": f"adapter_1c_unified.{method}.v{NEW_API_VERSION}", "status": status, "request_id": request_id, "method": method, "latency_ms": int((now_ts() - request_start) * 1000), "items": paginated, "pagination": { "limit": limit, "offset": offset, "total": total_count, "has_more": offset + len(paginated) < total_count, "next_offset": offset + len(paginated) if offset + len(paginated) < total_count else None, }, "warnings": warnings or [], } if diagnostics is not None: response["diagnostics"] = diagnostics expires_at = now_ts() + cache_ttl_for_profile(cache_profile) response["cache"] = { "cache_key": cache_key or make_cache_key(method, payload), "cache_hit": cache_hit, "cache_expires_at": int(expires_at), "cache_profile": cache_profile, } response["x_api_version"] = NEW_API_VERSION return response def _as_list(value: Any) -> list[Any]: if value is None: return [] return value if isinstance(value, list) else [value] def _extract_json_items(raw: Any, *keys: str) -> list[Any]: if isinstance(raw, list): return raw if isinstance(raw, dict): for key in keys: if key in raw: nested = raw.get(key) if isinstance(nested, list): return nested for key in ("result", "data", "rows", "values"): nested = raw.get(key) if isinstance(nested, list): return nested if isinstance(nested, dict): for item_key in ("items", "data", "values"): values = nested.get(item_key) if isinstance(values, list): return values return [] def _coerce_limit_offset(payload: dict[str, Any], default_limit: int) -> tuple[int, int]: limit = coerce_int(payload.get("limit"), default_limit, minimum=1, maximum=500) or default_limit offset = coerce_int(payload.get("offset"), 0, minimum=0) or 0 return limit, offset def _add_warning(warnings: list[dict[str, Any]] | list[str], warning_code: str, message: str) -> None: if isinstance(warnings, list) and warnings and isinstance(warnings[0], dict): warnings.append({"code": warning_code, "message": message}) elif isinstance(warnings, list): warnings.append(f"{warning_code}: {message}") def _normalize_owner_dict(base_id: str, owner_payload: Any) -> dict[str, Any] | None: if not isinstance(owner_payload, dict): return None if owner_payload.get("status") == "resolved" and owner_payload.get("guid"): return { "status": owner_payload.get("status"), "kind": owner_payload.get("kind"), "name": owner_payload.get("name") or owner_payload.get("caption"), "synonym": owner_payload.get("synonym"), "guid": owner_payload.get("guid"), "source": "metadata.cache", "mechanism": owner_payload.get("mechanism", "unknown"), } owner_guid = owner_payload.get("guid") if not is_guid_text(owner_guid): owner_guid = owner_payload.get("id") if not is_guid_text(owner_guid): return None resolved = metadata_cache_lookup_identity(base_id, owner_guid) if not isinstance(resolved, dict): return { "status": "unknown", "kind": owner_payload.get("kind"), "name": owner_payload.get("name") or owner_payload.get("caption"), "guid": owner_guid, "source": "metadata.cache", "mechanism": owner_payload.get("mechanism", "unknown"), } return { "status": "resolved", "kind": resolved.get("kind") or owner_payload.get("kind"), "name": resolved.get("name") or owner_payload.get("name"), "synonym": resolved.get("synonym"), "guid": owner_guid, "source": "metadata.cache", "mechanism": owner_payload.get("mechanism", "unknown"), } def _normalize_owner_output( base_id: str, owner_payload: Any, *, fallback_kind: str | None = None, fallback_name: str | None = None, fallback_guid: str | None = None, ) -> dict[str, Any] | None: fallback: dict[str, Any] = {} if fallback_kind: fallback["kind"] = fallback_kind if fallback_name: fallback["name"] = fallback_name if fallback_guid: fallback["guid"] = fallback_guid normalized = _normalize_owner_dict(base_id, owner_payload) if isinstance(normalized, dict): if not normalized.get("kind") and fallback.get("kind"): normalized["kind"] = fallback["kind"] if not normalized.get("name") and fallback.get("name"): normalized["name"] = fallback["name"] if not normalized.get("guid") and fallback.get("guid"): normalized["guid"] = fallback["guid"] if not normalized.get("source"): normalized["source"] = normalized.get("status") or "metadata.cache" return normalized if fallback: resolved_guid = fallback.get("guid") if resolved_guid and is_guid_text(resolved_guid): return { "status": "candidate", "kind": fallback.get("kind"), "name": fallback.get("name"), "guid": resolved_guid, "source": "selector", "mechanism": "fallback", } if fallback.get("name") or fallback.get("kind"): return { "status": "candidate", "kind": fallback.get("kind"), "name": fallback.get("name"), "source": "selector", "mechanism": "fallback", } return None def _run_bulk_execute(payload: dict[str, Any], request_start: float, request_id: str) -> dict[str, Any]: cache_profile = normalize_cache_profile(payload) cache_key = make_cache_key("bulk.execute", payload) cached = read_cached_response(cache_key) if cached is not None: return cached base_id = str(payload.get("base_id") or "").strip() requests = _as_list(payload.get("requests")) if not is_guid_text(base_id): return _normalize_unified_response( "bulk.execute", request_id, payload, request_start, status="invalid_argument", items=[], diagnostics={"code": "invalid_argument", "message": "base_id required"}, warnings=["base_id is required and must be GUID"], cache_key=cache_key, cache_profile=cache_profile, total=0, cache_hit=False, ) if not requests: return _normalize_unified_response( "bulk.execute", request_id, payload, request_start, status="invalid_argument", items=[], diagnostics={"code": "invalid_argument", "message": "requests must be a non-empty array"}, warnings=["no requests provided for bulk.execute"], cache_key=cache_key, cache_profile=cache_profile, total=0, cache_hit=False, ) requests = requests[:BULK_MAX_REQUESTS] results: list[dict[str, Any]] = [] for index, request in enumerate(requests): if not isinstance(request, dict): results.append({"index": index, "status": "invalid_argument", "error": "request must be an object"}) continue submethod = str(request.get("method") or "").strip() subpayload = request.get("payload") if not isinstance(subpayload, dict): subpayload = {} if "base_id" not in subpayload and base_id: subpayload = dict(subpayload) subpayload["base_id"] = base_id try: sub_result = call_adapter_method(submethod, subpayload) if submethod == "modules.read": sub_result = enrich_modules_read_result(subpayload, sub_result) sub_result = maybe_enrich_owner_fields(submethod, subpayload if isinstance(subpayload, dict) else None, sub_result) summary: dict[str, Any] = {} if submethod == "code.search" and isinstance(sub_result, dict): search_items = _as_list(sub_result.get("items")) search_diagnostics = sub_result.get("diagnostics") if isinstance(sub_result.get("diagnostics"), dict) else {} search_types: list[str] = [] for hit in search_items: if isinstance(hit, dict): owner = hit.get("owner") or hit.get("source_ref") if isinstance(owner, dict): kind = str(owner.get("kind") or "").strip() if kind and kind not in search_types: search_types.append(kind) summary = { "status": str(sub_result.get("status") or "ok"), "matches_count": len(search_items), "total": sub_result.get("total", len(search_items)), "query": search_diagnostics.get("query") if isinstance(search_diagnostics, dict) else None, "regex": bool(search_diagnostics.get("regex")) if isinstance(search_diagnostics, dict) else False, "scope": search_diagnostics.get("scope") if isinstance(search_diagnostics, dict) else None, "object_type_filter": str(subpayload.get("object_type") or ""), "owner_kinds": search_types[:12], "warnings_count": len(_as_list(sub_result.get("warnings"))), } elif submethod == "metadata.resolve_overrides" and isinstance(sub_result, dict): override_items = _as_list(sub_result.get("items")) layer_counts: dict[str, int] = {} extension_count = 0 for item in override_items: if not isinstance(item, dict): continue layer = str(item.get("layer") or "").strip() or "unknown" layer_counts[layer] = layer_counts.get(layer, 0) + 1 if item.get("extension") is not None: extension_count += 1 summary = { "status": str(sub_result.get("status") or "ok"), "override_count": len(override_items), "extension_count": extension_count, "layer_distribution": layer_counts, "method_name": str(subpayload.get("method_name") or ""), "base_object_name": str(subpayload.get("object_name") or subpayload.get("object_guid") or ""), "warnings_count": len(_as_list(sub_result.get("warnings"))), } elif submethod == "templates.bindings" and isinstance(sub_result, dict): binding_items = _as_list(sub_result.get("items")) binding_count = 0 template_names: list[str] = [] for item in binding_items: if not isinstance(item, dict): continue template = item.get("template", {}) if isinstance(template, dict): name = str(template.get("name") or "").strip() if name and name not in template_names: template_names.append(name) bindings = item.get("bindings") if isinstance(item.get("bindings"), list) else [] binding_count += len(_as_list(bindings)) summary = { "status": str(sub_result.get("status") or "ok"), "templates_count": len(binding_items), "bindings_count": binding_count, "templates": template_names[:8], "object_type": str(subpayload.get("object_type") or ""), "object_name": str(subpayload.get("object_name") or ""), "warnings_count": len(_as_list(sub_result.get("warnings"))), } elif submethod == "extensions.list" and isinstance(sub_result, dict): extension_items = _as_list(sub_result.get("items")) active_count = 0 inactive_count = 0 for item in extension_items: if not isinstance(item, dict): continue is_inactive = ( truthy(item.get("is_disabled")) or item.get("status") == "inactive" or item.get("state") == "inactive" ) if is_inactive: inactive_count += 1 else: active_count += 1 summary = { "status": str(sub_result.get("status") or "ok"), "extensions_count": len(extension_items), "active_count": active_count, "inactive_count": inactive_count, "with_load_order": sum( 1 for item in extension_items if isinstance(item, dict) and item.get("load_order") is not None ), "is_active_filter": subpayload.get("is_active"), "limit": subpayload.get("limit"), "offset": subpayload.get("offset"), "warnings_count": len(_as_list(sub_result.get("warnings"))), } elif submethod == "code.read" and isinstance(sub_result, dict): read_items = _as_list(sub_result.get("items")) read_item = read_items[0] if read_items else {} if not isinstance(read_item, dict): read_item = {} summary = { "status": str(sub_result.get("status") or "ok"), "source_length": len(str(read_item.get("text") or "")), "has_source": bool(str(read_item.get("text") or "").strip()), "module_ref": read_item.get("module_ref"), "has_owner": isinstance(read_item.get("owner"), dict), "owner_kind": read_item.get("owner", {}).get("kind") if isinstance(read_item.get("owner"), dict) else None, "owner_name": read_item.get("owner", {}).get("name") if isinstance(read_item.get("owner"), dict) else None, "owner_guid": read_item.get("owner", {}).get("guid") if isinstance(read_item.get("owner"), dict) else None, "warnings_count": len(_as_list(sub_result.get("warnings"))), } elif submethod == "diagnostics.call_chain" and isinstance(sub_result, dict): call_chain_items = _as_list(sub_result.get("items")) call_chain_diagnostics = sub_result.get("diagnostics") if isinstance(sub_result.get("diagnostics"), dict) else {} call_chain_edges = call_chain_diagnostics.get("edges") if isinstance(call_chain_diagnostics, dict) else [] summary = { "status": str(sub_result.get("status") or "ok"), "nodes_count": len(call_chain_items), "edges_count": len(_as_list(call_chain_edges)), "max_depth": call_chain_diagnostics.get("max_depth"), "max_nodes": call_chain_diagnostics.get("max_nodes"), "truncated": bool(call_chain_diagnostics.get("truncated", False)), "entry_routine": call_chain_items[0].get("routine") if call_chain_items else None, "last_routine": call_chain_items[-1].get("routine") if call_chain_items else None, "warnings_count": len(_as_list(sub_result.get("warnings"))), } elif not summary and isinstance(sub_result, dict): result_items = _as_list(sub_result.get("items")) result_diagnostics = sub_result.get("diagnostics") if isinstance(result_diagnostics, dict): diagnostics_hint = { k: result_diagnostics[k] for k in ("count", "total", "query", "max_depth", "max_nodes", "edges", "base_id") if k in result_diagnostics } else: diagnostics_hint = {} summary = { "status": str(sub_result.get("status") or "ok"), "total": sub_result.get("total", len(result_items)), "items_count": len(result_items), "result_keys": list(sub_result.keys())[:24], "diagnostics": diagnostics_hint, "warnings_count": len(_as_list(sub_result.get("warnings"))), } results.append( { "index": index, "method": submethod, "status": sub_result.get("status") if isinstance(sub_result, dict) else "ok", "result": sub_result, **({"summary": summary} if summary else {}), } ) 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)}) requested_count = len(_as_list(payload.get("requests"))) failed_count = len([item for item in results if (item.get("status") in {"error", "invalid_argument"})]) ok_count = len([item for item in results if item.get("status") == "ok"]) partial_status = "ok" if failed_count == 0 else ("error" if ok_count == 0 else "partial") result = _normalize_unified_response( "bulk.execute", request_id, payload, request_start, items=results, status=partial_status, warnings=[], diagnostics={ "count": len(results), "requested": requested_count, "truncated": requested_count > len(requests), "ok_count": ok_count, "failed_count": failed_count, }, cache_key=cache_key, cache_profile=cache_profile, total=len(results), cache_hit=False, ) write_cache_response(cache_key, payload, cache_profile, result) return result def _run_unified_method(method: str, payload: dict[str, Any], request_start: float, request_id: str) -> dict[str, Any]: if method == "bulk.execute": return _run_bulk_execute(payload, request_start, request_id) return _normalize_unified_response( method, request_id, payload, request_start, items=[], status="not_supported", diagnostics={"code": "method_not_supported", "message": f"method {method} is not registered as unified"}, cache_profile=normalize_cache_profile(payload), cache_key=make_cache_key(method, payload), total=0, cache_hit=False, ) def enrich_modules_read_result(payload: dict[str, Any], result: Any) -> Any: if not isinstance(result, dict) or result.get("status") != "ok": return result if "owner" in result and isinstance(result.get("owner"), dict): enriched = dict(result) if not enriched.get("owner_display"): enriched["owner_display"] = _build_object_display_payload(result["owner"]) or { "kind": result["owner"].get("kind"), "name": result["owner"].get("name"), "guid": result["owner"].get("guid"), } return enriched return result def build_freshness_context(payload: dict[str, Any]) -> dict[str, Any]: source_mode = normalize_source_mode(payload.get("source_mode")) source_state = normalize_source_state(payload.get("source_state"), source_mode) cache_policy = normalize_cache_policy(payload.get("cache_policy"), source_mode) force_refresh = truthy(payload.get("force_refresh")) snapshot_hint = payload.get("snapshot_hint") snapshot_token = None if isinstance(snapshot_hint, dict): snapshot_token = snapshot_hint.get("revision_token") if isinstance(snapshot_hint.get("revision_token"), str) else None if snapshot_token is None: snapshot_token = snapshot_hint.get("snapshot") if isinstance(snapshot_hint.get("snapshot"), str) else None elif isinstance(snapshot_hint, str): snapshot_token = snapshot_hint return { "source_mode": source_mode, "source_state": source_state, "cache_policy": cache_policy, "force_refresh": force_refresh, "snapshot_token": snapshot_token, } def enrich_result_with_freshness(payload: dict[str, Any], method: str, result: Any, request_start: float) -> Any: if not isinstance(result, dict): return result context = build_freshness_context(payload) context["request_id"] = str(payload.get("_mcp_request_id") or uuid.uuid4().hex) context["method"] = method context["base_id"] = payload.get("base_id") latency_ms = int((now_ts() - request_start) * 1000) context["latency_ms"] = latency_ms context["status"] = "fresh" if bool(context["force_refresh"] or context["cache_policy"] == "none") else "possibly_stale" warnings: list[str] = [] if context["source_mode"] == "designer": if context["cache_policy"] == "ttl": warnings.append("designer mode with cache_policy=ttl can return stale data in live edit sessions.") if context["source_state"] != "working" and context["source_state"] != "all": warnings.append("designer mode should normally use source_state=working to include non-applied metadata.") if context["snapshot_token"] is None: warnings.append("snapshot_hint is not set; freshness is best-effort for designer mode.") if context["source_mode"] == "runtime" and context["cache_policy"] == "none": warnings.append("runtime mode requested cache_policy=none; additional metadata checks may be slower.") if warnings: context["warnings"] = warnings enriched = dict(result) enriched.setdefault("_freshness", {}).update(context) return enriched def apply_freshness_request_policy(payload: dict[str, Any], method: str) -> dict[str, Any]: source_mode = normalize_source_mode(payload.get("source_mode")) if method == "code.write": source_mode = "designer" cache_policy = normalize_cache_policy(payload.get("cache_policy"), source_mode) source_state = normalize_source_state(payload.get("source_state"), source_mode) if method == "code.write": source_state = "working" cache_policy = "none" force_refresh = truthy(payload.get("force_refresh")) transformed = dict(payload) transformed["source_mode"] = source_mode transformed["source_state"] = source_state transformed["cache_policy"] = cache_policy if method in REST_STATE_METHODS and "state" not in transformed: transformed["state"] = REST_STATE_BY_SOURCE_STATE.get(source_state, "working") if force_refresh: transformed["force_refresh"] = True if source_mode == "designer" and method in { "metadata.objects.list", "metadata.object.get", "metadata.object.full", "metadata.definition.find", "metadata.resolve_overrides", "metadata.route.resolve", "code.search", "code.read", "modules.search", "templates.bindings", "templates.read", "templates.analyze", "templates.map", "extension.objects.find", "diagnostics.call_chain", }: if "refresh_cache" not in transformed: transformed["refresh_cache"] = bool(force_refresh) if cache_policy == "none": transformed["refresh_cache"] = True if force_refresh else transformed.get("refresh_cache", True) return transformed def http_json(method: str, path: str, payload: dict[str, Any] | None = None, timeout: float | 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"} if data is not None: headers["Content-Type"] = "application/json; charset=utf-8" if adapter_token(): headers["Authorization"] = f"Bearer {adapter_token()}" request = urllib.request.Request(url, data=data, headers=headers, method=method) effective_timeout = adapter_timeout() if timeout is None else timeout try: with urllib.request.urlopen(request, timeout=effective_timeout) as response: raw = response.read().decode("utf-8-sig") return json.loads(raw) if raw.strip() else {"status": response.status} except TimeoutError as exc: raise AdapterError(f"REST adapter request timed out after {effective_timeout} seconds") from exc 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 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) def public_error(method: str, error: str, diagnostics: Any | None = None, *, schema: str = "adapter_1c_mcp_error.v1") -> dict[str, Any]: return { "schema": schema, "status": "error", "method": method, "error": error, "diagnostics": diagnostics if diagnostics is not None else {"message": error}, } def invalid_argument(method: str, argument: str, message: str, *, allowed_values: list[str] | None = None) -> dict[str, Any]: return { "schema": "onec_adapter_request_error.v1", "status": "invalid_argument", "method": method, "error": "invalid_argument", "argument": argument, "diagnostics": {"message": message}, **({"allowed_values": allowed_values} if allowed_values else {}), } def validate_metadata_object_full_sections(payload: dict[str, Any]) -> dict[str, Any] | None: if "sections" not in payload: return None sections = payload.get("sections") if not isinstance(sections, list): return invalid_argument( "metadata.object.full", "sections", "sections must be a JSON array of section names.", allowed_values=sorted(FULL_METHOD_SECTIONS | {FULL_METHOD_ALL_KEY}), ) if not sections: return invalid_argument( "metadata.object.full", "sections", "sections must contain at least one section name.", allowed_values=sorted(FULL_METHOD_SECTIONS | {FULL_METHOD_ALL_KEY}), ) requested_sections: list[str] = [] for section in sections: if not isinstance(section, str): return invalid_argument( "metadata.object.full", "sections", "sections must be a JSON array of section names.", allowed_values=sorted(FULL_METHOD_SECTIONS | {FULL_METHOD_ALL_KEY}), ) section_name = section.strip().lower() if section_name == FULL_METHOD_ALL_KEY: for candidate in FULL_METHOD_SECTION_ORDER: if candidate not in requested_sections: requested_sections.append(candidate) continue if section_name not in FULL_METHOD_SECTIONS: return invalid_argument( "metadata.object.full", "sections", f"Unsupported section `{section}`.", allowed_values=sorted(FULL_METHOD_SECTIONS | {FULL_METHOD_ALL_KEY}), ) if section_name not in requested_sections: requested_sections.append(section_name) payload["sections"] = requested_sections return None def adapter_error_result(method: str, exc: AdapterError) -> dict[str, Any]: body_json = None if exc.body: try: body_json = json.loads(exc.body) except Exception: body_json = exc.body[:4000] return public_error( method, "adapter_unavailable" if exc.status is None else "adapter_http_error", { "message": str(exc), "http_status": exc.status, "body": body_json, }, ) def now_ts() -> float: return time.time() def job_set(job_id: str, **updates: Any) -> None: with JOB_LOCK: job = JOBS.get(job_id) if job: job.update(updates) job["updated_at"] = now_ts() def job_snapshot(job_id: str) -> dict[str, Any]: with JOB_LOCK: return dict(JOBS.get(job_id) or {}) def job_cancel_requested(job_id: str) -> bool: with JOB_LOCK: job = JOBS.get(job_id) or {} return truthy(job.get("cancel_requested")) or job.get("status") == "cancelled" def job_finish(job_id: str, status: str, **updates: Any) -> None: with JOB_LOCK: job = JOBS.get(job_id) if not job: return if job.get("status") == "cancelled" and status != "cancelled": return job.update(updates) job["status"] = status job["finished_at"] = now_ts() job["updated_at"] = job["finished_at"] def job_heartbeat(job_id: str, stop_event: threading.Event) -> None: while not stop_event.wait(job_heartbeat_seconds()): with JOB_LOCK: job = JOBS.get(job_id) if not job or job.get("status") not in {"queued", "running"}: return job["updated_at"] = now_ts() progress = dict(job.get("progress") or {}) progress["heartbeat_at"] = job["updated_at"] job["progress"] = progress def cancel_adapter_job(job_id: str) -> dict[str, Any]: cleanup_jobs() with JOB_LOCK: job = JOBS.get(job_id) if not job: return { "schema": "adapter_1c_mcp_job.v1", "status": "not_found", "job_id": job_id, "diagnostics": {"message": "Job was not found. It may have expired or the MCP proxy was restarted."}, } if job.get("status") in {"done", "error", "timeout", "cancelled"}: return dict(job) job["cancel_requested"] = True job["status"] = "cancelled" job["updated_at"] = now_ts() job["finished_at"] = job["updated_at"] job["diagnostics"] = {"message": "Cancellation requested. A running REST request may finish in the background, but this job will remain cancelled."} return dict(job) def full_partial_result(base_id: str | None = None, payload: dict[str, Any] | None = None) -> dict[str, Any]: query = payload or {} return { "schema": "onec_metadata_object_full.v1", "status": "partial", "base_id": base_id or query.get("base_id"), "source": {"kind": "live_metadata"}, "query": { "guid": query.get("guid"), "kind": query.get("kind"), "name": query.get("name"), **({"ordinal": query.get("ordinal")} if query.get("ordinal") not in {None, ""} else {}), "include_storage": truthy(query.get("include_storage")), }, "sections": { "card": "pending", "semantic": "pending", "forms": "pending", "templates": "pending", "commands": "pending", "modules": "pending", "parts_summary": "not_requested", }, "failed_sections": [], "diagnostics": [], "counts": {}, } def merge_section_counts(partial: dict[str, Any]) -> None: forms = partial.get("forms") or [] templates = partial.get("templates") or [] commands = partial.get("commands") or [] modules = partial.get("modules") or [] semantic_sections = ((partial.get("semantic") or {}).get("sections") or []) if isinstance(partial.get("semantic"), dict) else [] partial["counts"] = { "forms": len(forms), "templates": len(templates), "commands": len(commands), "modules": len(modules), "attributes": sum(int((section or {}).get("declared_record_count") or 0) for section in semantic_sections if (section or {}).get("category") == "Attribute"), "tabular_sections": sum(int((section or {}).get("declared_record_count") or 0) for section in semantic_sections if (section or {}).get("category") == "TabularSection"), } def update_full_partial(job_id: str, partial: dict[str, Any], current_step: str, completed: int, total: int) -> None: merge_section_counts(partial) job_set( job_id, partial_result=partial, current_step=current_step, progress={ "current_step": current_step, "completed_steps": completed, "total_steps": total, "percent": int((completed / total) * 100) if total else 0, }, ) def section_failed(partial: dict[str, Any], section: str, method: str, result: Any) -> None: status = result.get("status") if isinstance(result, dict) else "error" diagnostics = result.get("diagnostics") if isinstance(result, dict) else {"message": str(result)} partial["sections"][section] = "failed" if any(item.get("section") == section for item in partial.get("failed_sections") or []): return partial.setdefault("failed_sections", []).append( {"section": section, "method": method, "status": status, "diagnostics": diagnostics} ) partial.setdefault("diagnostics", []).append( {"section": section, "method": method, "status": status, "diagnostics": diagnostics} ) def section_ok_or_empty(partial: dict[str, Any], section: str, value: Any) -> None: partial["sections"][section] = "ok" if value else "empty" def run_metadata_object_full_job(job_id: str, method: str, payload: dict[str, Any], timeout_seconds: float) -> None: base_id = str(payload.get("base_id") or "") partial = full_partial_result(base_id, payload) started_at = now_ts() requested_sections_raw: list[str] = [str(section) for section in (payload.get("_sections") or payload.get("sections") or []) if isinstance(section, str)] requested_sections = [] for section_name in requested_sections_raw: normalized = section_name.strip().lower() if normalized == FULL_METHOD_ALL_KEY: for candidate in FULL_METHOD_SECTION_ORDER: if candidate not in requested_sections: requested_sections.append(candidate) continue if normalized in FULL_METHOD_SECTIONS and normalized not in requested_sections: requested_sections.append(normalized) if not requested_sections: requested_sections = ["card", "semantic", "modules", "templates", "forms", "commands"] if truthy(payload.get("include_parts_summary")) or truthy(payload.get("include_storage")): requested_sections.append("parts_summary") elif "parts_summary" not in requested_sections and (truthy(payload.get("include_parts_summary")) or truthy(payload.get("include_storage"))): requested_sections.append("parts_summary") for section in requested_sections: if section not in partial.get("sections", {}): partial.setdefault("sections", {})[section] = "not_requested" steps: list[tuple[str, str, dict[str, Any]]] = [] for section in requested_sections: if section == "card": steps.append(("card", "metadata.object.get", {**payload, "include_semantic": False})) elif section == "semantic": steps.append(("semantic", "metadata.object.get", payload)) elif section == "forms": steps.append( ( "forms", "metadata.object.form.details", { **payload, "max_items": int(payload.get("max_form_items") or payload.get("max_items") or 1000), "max_forms": int(payload.get("max_forms") or 20), "include_module_text": truthy(payload.get("include_form_module_text")), }, ) ) elif section == "templates": steps.append( ( "templates", "metadata.object.template.details" if truthy(payload.get("include_template_details")) else "metadata.object.templates", payload, ) ) elif section == "commands": steps.append(("commands", "metadata.object.commands", payload)) elif section == "modules": steps.append(("modules", "metadata.object.modules", payload)) elif section == "parts_summary": steps.append(("parts_summary", "metadata.object.parts", {**payload, "include_text": False, "include_tree": False})) partial["sections"]["parts_summary"] = "pending" total = len(steps) completed = 0 running_steps = [section for section, _, _ in steps] update_full_partial(job_id, partial, "starting", completed, total) def run_section(section: str, section_method: str, section_payload: dict[str, Any]) -> tuple[str, str, Any]: try: elapsed = now_ts() - started_at remaining = max(1.0, timeout_seconds - elapsed) section_timeout = min(float(section_payload.get("timeout_seconds") or remaining), remaining) result = call_adapter_method(section_method, {**section_payload, "timeout_seconds": section_timeout}, timeout=section_timeout) except AdapterError as exc: result = adapter_error_result(section_method, exc) except Exception as exc: result = public_error(section_method, "mcp_section_exception", {"message": str(exc), "traceback": traceback.format_exc(limit=6)}) return section, section_method, result executor = concurrent.futures.ThreadPoolExecutor(max_workers=max(1, total), thread_name_prefix=f"onec-full-{job_id[:8]}") futures = {executor.submit(run_section, section, section_method, section_payload): (section, section_method) for section, section_method, section_payload in steps} try: while futures: if job_cancel_requested(job_id): partial["status"] = "cancelled" executor.shutdown(wait=False, cancel_futures=True) job_finish(job_id, "cancelled", partial_result=partial, result=partial) return elapsed = now_ts() - started_at if elapsed >= timeout_seconds: for _, (section, section_method) in list(futures.items()): section_failed(partial, section, section_method, {"status": "timeout", "diagnostics": {"message": f"MCP job timeout after {timeout_seconds:.0f} seconds"}}) partial["status"] = "partial" executor.shutdown(wait=False, cancel_futures=True) job_finish( job_id, "error", error="job_timeout", partial_result=partial, result=partial, progress={"current_step": "timeout", "running_steps": running_steps, "completed_steps": completed, "total_steps": total, "percent": int((completed / total) * 100) if total else 0}, diagnostics={"message": f"MCP job timeout after {timeout_seconds:.0f} seconds", "running_steps": running_steps}, ) return done, _ = concurrent.futures.wait(futures, timeout=0.5, return_when=concurrent.futures.FIRST_COMPLETED) if not done: job_set( job_id, partial_result=partial, current_step=",".join(running_steps) if running_steps else "waiting", progress={ "current_step": "running_sections", "running_steps": running_steps, "completed_steps": completed, "total_steps": total, "percent": int((completed / total) * 100) if total else 0, }, ) continue for future in done: section, section_method = futures.pop(future) if section in running_steps: running_steps.remove(section) completed += 1 try: section, section_method, result = future.result() except Exception as exc: result = public_error(section_method, "mcp_section_exception", {"message": str(exc), "traceback": traceback.format_exc(limit=6)}) if not isinstance(result, dict) or result.get("status") not in {"ok", "partial"}: section_failed(partial, section, section_method, result) elif section == "semantic": partial["object"] = result.get("object") partial["semantic"] = result.get("semantic") section_ok_or_empty(partial, section, partial.get("semantic")) elif section == "forms": partial["forms"] = result.get("forms") or [] section_ok_or_empty(partial, section, partial["forms"]) elif section == "templates": partial["templates"] = result.get("templates") or [] section_ok_or_empty(partial, section, partial["templates"]) elif section == "commands": partial["commands"] = result.get("commands") or [] section_ok_or_empty(partial, section, partial["commands"]) elif section == "modules": partial["modules"] = result.get("modules") or [] section_ok_or_empty(partial, section, partial["modules"]) elif section == "parts_summary": partial["parts_summary"] = {"counts": result.get("counts")} partial["sections"][section] = "ok" update_full_partial(job_id, partial, section, completed, total) finally: executor.shutdown(wait=False, cancel_futures=True) partial["status"] = "partial" if partial.get("failed_sections") else "ok" merge_section_counts(partial) job_finish(job_id, "done", result=partial, partial_result=partial, progress={"current_step": "done", "completed_steps": total, "total_steps": total, "percent": 100}) def cleanup_jobs(now: float | None = None) -> None: effective_now = time.time() if now is None else now with JOB_LOCK: expired = [ job_id for job_id, job in JOBS.items() if effective_now - float(job.get("updated_at") or job.get("created_at") or effective_now) > JOB_TTL_SECONDS ] for job_id in expired: JOBS.pop(job_id, None) def start_adapter_job(method: str, payload: dict[str, Any]) -> str: cleanup_jobs() job_id = uuid.uuid4().hex now = time.time() timeout_seconds = job_timeout_seconds(payload) with JOB_LOCK: JOBS[job_id] = { "schema": "adapter_1c_mcp_job.v1", "job_id": job_id, "status": "queued", "method": method, "created_at": now, "updated_at": now, "timeout_seconds": timeout_seconds, "progress": {"current_step": "queued", "completed_steps": 0, "total_steps": None, "percent": 0}, } def worker() -> None: stop_heartbeat = threading.Event() heartbeat_thread = threading.Thread(target=job_heartbeat, args=(job_id, stop_heartbeat), name=f"onec-heartbeat-{job_id[:8]}", daemon=True) heartbeat_thread.start() with JOB_LOCK: job = JOBS.get(job_id) if job: job["status"] = "running" job["started_at"] = time.time() job["updated_at"] = job["started_at"] job["progress"] = {"current_step": "running", "completed_steps": 0, "total_steps": None, "percent": 0} try: if method == "metadata.object.full": run_metadata_object_full_job(job_id, method, payload, timeout_seconds) return result = call_adapter_method(method, payload, timeout=timeout_seconds) if job_cancel_requested(job_id): job_finish(job_id, "cancelled", result={"status": "cancelled", "method": method}) return job_finish(job_id, "done", result=result, progress={"current_step": "done", "completed_steps": 1, "total_steps": 1, "percent": 100}) except AdapterError as exc: job_finish(job_id, "error", **adapter_error_result(method, exc)) except Exception as exc: job_finish( job_id, "error", **public_error( method, "mcp_job_exception", {"message": str(exc), "traceback": traceback.format_exc(limit=8)}, schema="adapter_1c_mcp_job.v1", ), ) finally: stop_heartbeat.set() heartbeat_thread.join(timeout=0.2) def timeout_watchdog() -> None: time.sleep(timeout_seconds) with JOB_LOCK: job = JOBS.get(job_id) if not job or job.get("status") not in {"queued", "running"}: return partial = job.get("partial_result") current_step = ((job.get("progress") or {}).get("current_step") or job.get("current_step") or "running") running_steps = (job.get("progress") or {}).get("running_steps") or [current_step] job.update( public_error( method, "job_timeout", {"message": f"MCP job timeout after {timeout_seconds:.0f} seconds", "current_step": current_step}, schema="adapter_1c_mcp_job.v1", ) ) if partial: if isinstance(partial, dict): partial["status"] = "partial" for section in running_steps: section_name = str(section or "").strip() or current_step if section_name == "running_sections": continue partial.setdefault("sections", {})[section_name] = "failed" if not any(item.get("section") == section_name for item in partial.get("failed_sections") or []): partial.setdefault("failed_sections", []).append( { "section": section_name, "method": method, "status": "timeout", "diagnostics": {"message": f"MCP job timeout after {timeout_seconds:.0f} seconds"}, } ) job["partial_result"] = partial job["result"] = partial job["status"] = "error" job["finished_at"] = now_ts() job["updated_at"] = job["finished_at"] thread = threading.Thread(target=worker, name=f"onec-job-{job_id[:8]}", daemon=True) thread.start() watchdog = threading.Thread(target=timeout_watchdog, name=f"onec-timeout-{job_id[:8]}", daemon=True) watchdog.start() return job_id def get_adapter_job(job_id: str, *, consume: bool = False) -> dict[str, Any]: cleanup_jobs() with JOB_LOCK: job = dict(JOBS.get(job_id) or {}) if consume and job.get("status") in {"done", "error", "timeout", "cancelled", "not_found"}: JOBS.pop(job_id, None) if not job: return { "schema": "adapter_1c_mcp_job.v1", "status": "not_found", "job_id": job_id, "diagnostics": {"message": "Job was not found. It may have expired or the MCP proxy was restarted."}, } return job def method_requires_base_id(method: str) -> bool: if method in BASE_ID_OPTIONAL_METHODS: return False return method.startswith(BASE_ID_REQUIRED_METHOD_PREFIXES) def missing_base_id_policy(method: str) -> dict[str, Any]: return { "schema": "adapter_1c_mcp_policy.v1", "status": "blocked", "method": method, "reason": "base_id_required", "diagnostics": { "message": ( "This adapter method reads a concrete 1C database and requires payload.base_id. " "Do not guess a base id from examples. Use the project/user context or ask for the target base id. " "You may call onec_health with a known concrete base_id to check it before metadata/modules requests." ), "agent_guidance": [ "If the task context already contains module_ref/read_selector, retry the direct read with the same base_id.", "If base_id is unknown, stop and ask for it instead of running metadata/modules searches.", "Treat not_found from scoped searches as method-scope evidence, not proof that code is absent.", ], }, } def metadata_write_code_guardrail(method: str, payload: dict[str, Any]) -> dict[str, Any] | None: if method not in {"metadata.write", "metadata.module.write_apply"} or truthy(payload.get("_allow_low_level_code_write")): return None target = payload.get("target") if isinstance(payload.get("target"), dict) else {} target_kind = str(target.get("kind") or payload.get("target_kind") or "").strip().lower() code_fields = ("routine_text", "module_text", "full_text", "code", "old", "new") 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 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"), "routine_name": payload.get("routine_name") or target.get("routine_name"), "routine_text": payload.get("routine_text"), "module_text": payload.get("module_text"), "full_text": payload.get("full_text"), "code": payload.get("code"), "old": payload.get("old"), "new": payload.get("new"), "mode": payload.get("mode") or "apply", }.items() if value is not None }, } return { "schema": "adapter_1c_mcp_policy.v1", "status": "blocked", "method": method, "reason": "use_code_write_for_bsl", "diagnostics": { "message": "BSL edits through MCP must use code.write. code.write accepts 1C selectors and saves to the saved-state working layer without SQL/save-gate questions.", "suggested_request": {"method": "code.write", "payload": suggested_payload}, "bypass": "Pass _allow_low_level_code_write=true only for explicit low-level adapter diagnostics.", }, } def run_or_enqueue_adapter_method(method: str, payload: dict[str, Any]) -> Any: request_start = now_ts() request_id = uuid.uuid4().hex request_payload = apply_freshness_request_policy(payload, method) request_payload["_mcp_request_id"] = request_id payload = request_payload 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")) ): return { "schema": "adapter_1c_mcp_policy.v1", "status": "blocked", "method": method, "reason": "diagnostic_method", "diagnostics": { "message": ( "This is a low-level diagnostic method and must not be used as a fallback for user-facing metadata answers. " "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." ) }, } force_async = truthy(payload.get("_mcp_async")) or truthy(payload.get("mcp_async")) force_sync = truthy(payload.get("_mcp_sync")) or truthy(payload.get("mcp_sync")) special_details_long = method in {"metadata.object.properties", "metadata.object.special.details"} and str(payload.get("kind") or "").strip().lower() in { "documentjournal", "журналдокументов", "журнал документов", } if method == "modules.search": payload = dict(payload) if "resolve_owners" not in payload: payload["resolve_owners"] = True if method == "metadata.object.full": sections_error = validate_metadata_object_full_sections(payload) if sections_error is not None: return sections_error if method in UNIFIED_METHODS: try: result = _run_unified_method(method, payload, request_start, request_id) except AdapterError as exc: return enrich_result_with_freshness(payload, method, adapter_error_result(method, exc), request_start) except Exception as exc: return enrich_result_with_freshness( payload, method, public_error(method, "mcp_request_exception", {"message": str(exc), "traceback": traceback.format_exc(limit=8)}), request_start, ) return enrich_result_with_freshness(payload, method, result, request_start) # non-unified legacy methods keep previous behavior: scan_limit = 0 raw_scan_limit = payload.get("scan_limit") if raw_scan_limit is not None: try: scan_limit = int(raw_scan_limit) except (TypeError, ValueError): scan_limit = 0 if scan_limit < 0: scan_limit = 0 should_enqueue = force_async or ((method in LONG_METHODS or special_details_long) and not force_sync) if method in {"modules.search", "code.search"} and scan_limit and scan_limit > 1500 and not force_sync: should_enqueue = True if method == "code.read": raw_text_limit = payload.get("max_chars") if raw_text_limit in {None, ""}: raw_text_limit = payload.get("read_max_chars") read_limit = None try: read_limit = int(raw_text_limit) if raw_text_limit is not None else None except (TypeError, ValueError): read_limit = None if read_limit is None: read_limit = 100000 if read_limit >= HEAVY_TEXT_THRESHOLD_FOR_CODE_READ and not force_sync: should_enqueue = True if not should_enqueue: try: result = call_adapter_method(method, payload) except AdapterError as exc: return enrich_result_with_freshness(payload, method, adapter_error_result(method, exc), request_start) except Exception as exc: return enrich_result_with_freshness( payload, method, public_error(method, "mcp_request_exception", {"message": str(exc), "traceback": traceback.format_exc(limit=8)}), request_start, ) if method == "modules.read": result = enrich_modules_read_result(payload, result) if method in {"modules.search", "code.search", "metadata.object.full", "metadata.objects.list"}: result = enrich_owner_metadata_in_data(payload, result) if method == "metadata.object.full": result = _enrich_metadata_object_full_display(payload, result) elif method == "metadata.objects.list": result = _enrich_metadata_objects_list_display(payload, result) elif method == "modules.search": result = _enrich_modules_search_display(payload, result) return enrich_result_with_freshness(payload, method, result, request_start) clean_payload = {key: value for key, value in payload.items() if key not in {"_mcp_async", "mcp_async", "_mcp_sync", "mcp_sync"}} try: accepted = call_adapter_method("adapter.job.start", {"method": method, "payload": clean_payload}, timeout=adapter_timeout()) except AdapterError as exc: return enrich_result_with_freshness(payload, "adapter.job.start", adapter_error_result("adapter.job.start", exc), request_start) except Exception as exc: return enrich_result_with_freshness( payload, "adapter.job.start", public_error("adapter.job.start", "mcp_request_exception", {"message": str(exc), "traceback": traceback.format_exc(limit=8)}), request_start, ) if not isinstance(accepted, dict) or accepted.get("status") not in {"accepted", "done", "error"}: return enrich_result_with_freshness(payload, method, accepted, request_start) job_id = str(accepted.get("job_id") or "") if not job_id: return enrich_result_with_freshness(payload, method, accepted, request_start) deadline = time.time() + job_fast_wait_seconds() while time.time() < deadline: try: job = call_adapter_method("adapter.job.get", {"job_id": job_id}, timeout=adapter_timeout()) except AdapterError as exc: return enrich_result_with_freshness(payload, "adapter.job.get", adapter_error_result("adapter.job.get", exc), request_start) if job.get("status") in {"done", "error", "timeout", "cancelled"}: if job.get("status") == "done": return enrich_result_with_freshness(payload, method, maybe_enrich_owner_fields(method, payload, job.get("result")), request_start) return enrich_result_with_freshness(payload, method, job, request_start) time.sleep(0.05) try: job = call_adapter_method("adapter.job.get", {"job_id": job_id}, timeout=adapter_timeout()) except AdapterError: job = accepted return enrich_result_with_freshness( payload, method, { "schema": "adapter_1c_mcp_job.v1", "status": "accepted", "job_id": job_id, "method": method, "source": "adapter", "timeout_seconds": job.get("timeout_seconds"), "progress": job.get("progress"), "current_step": job.get("current_step"), "poll": {"tool": "onec_request", "method": "mcp.job.get", "payload": {"job_id": job_id}}, "cancel": {"tool": "onec_request", "method": "mcp.job.cancel", "payload": {"job_id": job_id}}, "diagnostics": { "message": "Long adapter request is running in the MCP proxy. Poll mcp.job.get with this job_id instead of falling back to SQL diagnostics.", }, }, request_start, ) def tool_text(data: Any) -> dict[str, Any]: return { "content": [ { "type": "text", "text": json.dumps(data, ensure_ascii=False, indent=2), } ] } def access_audit_slugify(value: str, *, max_length: int = 80) -> str: slug = re.sub(r"[^0-9A-Za-zА-Яа-яЁё._-]+", "-", value.strip()) slug = re.sub(r"-+", "-", slug).strip("-._") return (slug or "role-audit")[:max_length] def access_audit_read_json(path: Path) -> dict[str, Any]: data = json.loads(path.read_text(encoding="utf-8-sig")) if not isinstance(data, dict): raise ValueError(f"JSON root is not an object: {path}") return data def access_audit_summary_role(summary: dict[str, Any]) -> str | None: query = summary.get("query") if isinstance(summary.get("query"), dict) else {} role = query.get("role") if isinstance(query, dict) else None return str(role) if role is not None else None def access_audit_find_latest_summaries(report_root: Path, base_id: str, *, role: str, count: int = 2) -> list[Path]: folder = report_root / access_audit_slugify(base_id, max_length=60) summaries: list[tuple[str, Path]] = [] role_filter = role.casefold() for path in folder.glob("*.summary.json"): try: summary = access_audit_read_json(path) except (OSError, json.JSONDecodeError, ValueError): continue if (access_audit_summary_role(summary) or "").casefold() != role_filter: continue summaries.append((str(summary.get("generated_at") or ""), path)) summaries.sort(key=lambda item: item[0], reverse=True) return [path for _, path in summaries[:count]] def access_audit_load_export(path: Path) -> dict[str, Any]: data = access_audit_read_json(path) artifacts = data.get("artifacts") if isinstance(data.get("artifacts"), dict) else {} export_path = artifacts.get("json") if export_path: return access_audit_read_json(Path(str(export_path))) return data def access_audit_user_key(row: dict[str, Any]) -> str: return str(row.get("user_id") or row.get("user_name") or "").strip() def access_audit_group_rows_by_user(export: dict[str, Any]) -> dict[str, dict[str, Any]]: users: dict[str, dict[str, Any]] = {} rows = export.get("rows") if isinstance(export.get("rows"), list) else [] for row in rows: if not isinstance(row, dict): continue key = access_audit_user_key(row) if not key: continue item = users.setdefault( key, { "user": { "user_id": row.get("user_id"), "user_name": row.get("user_name"), "user_type": row.get("user_type"), "user_active": row.get("user_active"), "user_marked": row.get("user_marked"), }, "access_paths": set(), }, ) if row.get("access_path"): item["access_paths"].add(str(row.get("access_path"))) for item in users.values(): item["access_paths"] = sorted(item["access_paths"]) return users def access_audit_compare_exports(old_export: dict[str, Any], new_export: dict[str, Any]) -> dict[str, Any]: old_users = access_audit_group_rows_by_user(old_export) new_users = access_audit_group_rows_by_user(new_export) old_keys = set(old_users) new_keys = set(new_users) added_keys = sorted(new_keys - old_keys) removed_keys = sorted(old_keys - new_keys) common_keys = sorted(old_keys & new_keys) changed_paths = [ { "user": new_users[key]["user"], "old_access_paths": old_users[key]["access_paths"], "new_access_paths": new_users[key]["access_paths"], } for key in common_keys if old_users[key]["access_paths"] != new_users[key]["access_paths"] ] return { "schema": "onec_access_role_audit_compare.v1", "status": "ok", "counts": { "old_users": len(old_keys), "new_users": len(new_keys), "added_users": len(added_keys), "removed_users": len(removed_keys), "unchanged_users": len(common_keys), "changed_access_paths": len(changed_paths), }, "added_users": [new_users[key]["user"] for key in added_keys], "removed_users": [old_users[key]["user"] for key in removed_keys], "changed_access_paths": changed_paths, } def access_audit_compare_files(old_path: Path, new_path: Path, *, output: Path | None = None, html_output: Path | None = None) -> dict[str, Any]: result = access_audit_compare_exports(access_audit_load_export(old_path), access_audit_load_export(new_path)) result["sources"] = {"old": str(old_path), "new": str(new_path)} result["artifacts"] = { **({"json": str(output)} if output is not None else {}), **({"html": str(html_output)} if html_output is not None else {}), } if output is not None: output.parent.mkdir(parents=True, exist_ok=True) output.write_text(json.dumps(result, ensure_ascii=False, indent=2), encoding="utf-8") if html_output is not None: html_output.parent.mkdir(parents=True, exist_ok=True) html_output.write_text("
" + json.dumps(result, ensure_ascii=False, indent=2) + "
", encoding="utf-8") return result def access_role_audit_compare_latest(args: dict[str, Any]) -> dict[str, Any]: base_id = str(args.get("base_id") or "").strip() role = str(args.get("role") or "").strip() if not base_id: return missing_base_id_policy("access.role.audit_compare_latest") if not role: return public_error("access.role.audit_compare_latest", "role_required", {"message": "role is required"}) report_root = Path(str(args.get("report_root") or os.environ.get("ONEC_ACCESS_REPORT_ROOT") or DEFAULT_ACCESS_REPORT_ROOT)) latest = access_audit_find_latest_summaries(report_root, base_id, role=role, count=2) if len(latest) < 2: return { "schema": "onec_access_role_audit_compare_latest.v1", "status": "not_enough_reports", "base_id": base_id, "role": role, "reports_found": len(latest), "message": "Need at least two saved audit summaries for this base and role.", } new_path, old_path = latest[0], latest[1] output = None html_output = None if truthy(args.get("write_artifacts", True)): folder = report_root / access_audit_slugify(base_id, max_length=60) stem = f"compare-{old_path.stem.replace('.summary', '')}-{new_path.stem.replace('.summary', '')}" output = folder / f"{stem}.json" html_output = folder / f"{stem}.html" result = access_audit_compare_files(old_path, new_path, output=output, html_output=html_output) return {"schema": "onec_access_role_audit_compare_latest.v1", **result, "base_id": base_id, "role": role} def handle_tool_call(name: str, arguments: dict[str, Any] | None) -> dict[str, Any]: args = arguments or {} if name == "onec_health": result: dict[str, Any] = { "schema": "adapter_1c_mcp_health.v1", "mcp": {"status": "ok", "adapter_url": adapter_url()}, "adapter": None, } try: result["adapter"] = call_adapter_method("health", {"base_id": args.get("base_id")} if args.get("base_id") else {}) except AdapterError as exc: result["adapter"] = adapter_error_result("health", exc) return tool_text(result) if name == "onec_help": payload = {"method": args.get("method")} if args.get("method") else {} try: return tool_text(call_adapter_method("help.methods", payload)) except AdapterError as exc: return tool_text( { "schema": "adapter_1c_mcp_methods.v1", "source": "adapter_unavailable", "adapter_error": str(exc), "methods": [], } ) if name == "onec_request": method = str(args.get("method") or "").strip() if not method: return tool_text(public_error("onec_request", "method_required", {"message": "method is required"})) 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"})) 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: return tool_text(public_error(method, "job_id_required", {"message": "payload.job_id is required"})) try: job = call_adapter_method("adapter.job.get", {"job_id": job_id, "consume": truthy(payload.get("consume"))}) if ( isinstance(job, dict) and job.get("status") == "done" and isinstance(job.get("result"), (dict, list)) ): 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) 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"}: job_id = str(payload.get("job_id") or "").strip() if not job_id: return tool_text(public_error(method, "job_id_required", {"message": "payload.job_id is required"})) try: 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)) if name == "onec_job_get": job_id = str(args.get("job_id") or "").strip() if not job_id: return tool_text(public_error("onec_job_get", "job_id_required", {"message": "job_id is required"})) try: job = call_adapter_method("adapter.job.get", {"job_id": job_id, "consume": truthy(args.get("consume"))}) if ( isinstance(job, dict) and job.get("status") == "done" and isinstance(job.get("result"), (dict, list)) ): 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) except AdapterError as exc: return tool_text(adapter_error_result("adapter.job.get", exc)) if name == "onec_job_cancel": job_id = str(args.get("job_id") or "").strip() if not job_id: return tool_text(public_error("onec_job_cancel", "job_id_required", {"message": "job_id is required"})) try: 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)) if name == "access_role_audit_compare_latest": return tool_text(access_role_audit_compare_latest(args)) access_tool_methods = { "infobase_users_search": "infobase.users.search", "infobase_user_get": "infobase.user.get", "infobase_user_password_capabilities": "infobase.user.password.capabilities", "infobase_user_password_status": "infobase.user.password.status", "infobase_user_password_set": "infobase.user.password.set", "infobase_user_password_clear": "infobase.user.password.clear", "access_role_users": "access.role.users", "access_role_profiles": "access.role.profiles", "access_role_audit_export": "access.role.audit_export", "access_role_audit_analyze": "access.role.audit_analyze", "access_user_explain": "access.user.explain", "access_users_search": "access.users.search", "access_object_explain": "access.object.explain", "access_keys_query": "access.keys.query", "access_object_keys_resolve": "access.object_keys.resolve", "access_object_roles": "access.object.roles", "access_object_subjects": "access.object.subjects", "access_rls_discover": "access.rls.discover", } if name in access_tool_methods: method = access_tool_methods[name] if not str(args.get("base_id") or "").strip(): return tool_text(missing_base_id_policy(method)) try: return tool_text(call_adapter_method(method, dict(args), timeout=adapter_timeout())) except AdapterError as exc: return tool_text(adapter_error_result(method, exc)) return tool_text(public_error(name, "unknown_tool", {"message": f"Unknown tool `{name}`"})) def jsonrpc_error(request_id: Any, code: int, message: str, data: Any | None = None) -> dict[str, Any]: error: dict[str, Any] = {"code": code, "message": message} if data is not None: error["data"] = data return {"jsonrpc": "2.0", "id": request_id, "error": error} def jsonrpc_result(request_id: Any, result: Any) -> dict[str, Any]: return {"jsonrpc": "2.0", "id": request_id, "result": result} def handle_jsonrpc(payload: dict[str, Any]) -> dict[str, Any] | None: request_id = payload.get("id") method = payload.get("method") params = payload.get("params") or {} try: if method == "initialize": return jsonrpc_result( request_id, { "protocolVersion": PROTOCOL_VERSION, "capabilities": {"tools": {"listChanged": False}}, "serverInfo": {"name": "adapter-1c-mcp", "version": "0.1.0", "contract_version": MCP_CONTRACT_VERSION}, }, ) if method == "notifications/initialized": return None if method == "ping": return jsonrpc_result(request_id, {}) if method == "tools/list": return jsonrpc_result(request_id, {"contract_version": MCP_CONTRACT_VERSION, "tools": TOOLS}) if method == "tools/call": 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()) def payload_has_method(payload: Any, method: str) -> bool: if isinstance(payload, dict): return payload.get("method") == method if isinstance(payload, list): return any(isinstance(item, dict) and item.get("method") == method for item in payload) return False def handle_jsonrpc_payload(payload: Any) -> dict[str, Any] | list[dict[str, Any]] | None: if isinstance(payload, list): responses = [response for item in payload if isinstance(item, dict) for response in [handle_jsonrpc(item)] if response is not None] return responses or None if isinstance(payload, dict): return handle_jsonrpc(payload) return jsonrpc_error(None, -32600, "Invalid JSON-RPC payload") def sse_event(event: str, data: str) -> bytes: lines = [f"event: {event}", *(f"data: {line}" for line in data.splitlines() or [""]), "", ""] return ("\n".join(lines)).encode("utf-8") class McpHandler(BaseHTTPRequestHandler): server_version = "adapter-1c-mcp/0.1" def log_message(self, fmt: str, *args: Any) -> None: print(f"{self.address_string()} - {fmt % args}", file=sys.stderr, flush=True) def write_json(self, status: int, data: Any, extra_headers: dict[str, str] | None = None) -> None: encoded = json.dumps(data, ensure_ascii=False, indent=2).encode("utf-8") self.send_response(status) self.send_header("Content-Type", "application/json; charset=utf-8") self.send_header("Content-Length", str(len(encoded))) self.send_header("Access-Control-Allow-Origin", "*") for key, value in (extra_headers or {}).items(): self.send_header(key, value) self.end_headers() self.wfile.write(encoded) def write_no_content(self, status: int = 202, extra_headers: dict[str, str] | None = None) -> None: self.send_response(status) self.send_header("Access-Control-Allow-Origin", "*") for key, value in (extra_headers or {}).items(): self.send_header(key, value) self.end_headers() def read_json(self) -> Any: length = int(self.headers.get("Content-Length") or 0) raw = self.rfile.read(length).decode("utf-8") if length else "{}" return json.loads(raw) if raw.strip() else {} def do_OPTIONS(self) -> None: self.send_response(204) self.send_header("Access-Control-Allow-Origin", "*") self.send_header("Access-Control-Allow-Methods", "GET, POST, DELETE, OPTIONS") self.send_header("Access-Control-Allow-Headers", "Accept, Content-Type, Authorization, Mcp-Session-Id") self.end_headers() def do_GET(self) -> None: parsed = urllib.parse.urlparse(self.path) if parsed.path == "/health": self.write_json(200, {"status": "ok", "name": "adapter-1c-mcp", "contract_version": MCP_CONTRACT_VERSION, "adapter_url": adapter_url()}) return if parsed.path == "/tools": self.write_json(200, {"contract_version": MCP_CONTRACT_VERSION, "tools": TOOLS}) return if parsed.path not in {"/sse", "/mcp"}: self.write_json(404, {"error": "not found"}) return session_id = uuid.uuid4().hex events: "queue.Queue[dict[str, Any] | None]" = queue.Queue() with SESSION_LOCK: SESSIONS[session_id] = events self.send_response(200) self.send_header("Content-Type", "text/event-stream; charset=utf-8") self.send_header("Cache-Control", "no-cache") self.send_header("Connection", "keep-alive") self.send_header("Access-Control-Allow-Origin", "*") self.end_headers() endpoint = f"/messages?session_id={session_id}" self.wfile.write(sse_event("endpoint", endpoint)) self.wfile.flush() try: while True: try: item = events.get(timeout=15) except queue.Empty: self.wfile.write(b": keepalive\n\n") self.wfile.flush() continue if item is None: break self.wfile.write(sse_event("message", json.dumps(item, ensure_ascii=False))) self.wfile.flush() except (BrokenPipeError, ConnectionResetError): pass finally: with SESSION_LOCK: SESSIONS.pop(session_id, None) def do_POST(self) -> None: parsed = urllib.parse.urlparse(self.path) if parsed.path in {"/mcp", "/"}: payload = self.read_json() response = handle_jsonrpc_payload(payload) headers: dict[str, str] = {} incoming_session_id = self.headers.get("Mcp-Session-Id", "").strip() if incoming_session_id: headers["Mcp-Session-Id"] = incoming_session_id if payload_has_method(payload, "initialize"): headers["Mcp-Session-Id"] = uuid.uuid4().hex if response is None: self.write_no_content(202, headers) return self.write_json(200, response or {}, headers) return if parsed.path != "/messages": self.write_json(404, {"error": "not found"}) return query_params = urllib.parse.parse_qs(parsed.query) session_id = (query_params.get("session_id") or [""])[0] with SESSION_LOCK: events = SESSIONS.get(session_id) if events is None: self.write_json(404, {"error": "unknown session"}) return payload = self.read_json() response = handle_jsonrpc_payload(payload) if response is not None: events.put(response) self.write_json(202, {"status": "accepted"}) def do_DELETE(self) -> None: parsed = urllib.parse.urlparse(self.path) if parsed.path not in {"/mcp", "/sse", "/messages"}: self.write_json(404, {"error": "not found"}) return session_id = self.headers.get("Mcp-Session-Id", "").strip() if not session_id: query_params = urllib.parse.parse_qs(parsed.query) session_id = (query_params.get("session_id") or [""])[0] if session_id: with SESSION_LOCK: events = SESSIONS.pop(session_id, None) if events is not None: events.put(None) self.write_no_content(202) def main() -> int: parser = argparse.ArgumentParser(description="Run adapter-1c MCP proxy.") parser.add_argument("--host", default=os.environ.get("HOST", "0.0.0.0")) parser.add_argument("--port", type=int, default=int(os.environ.get("PORT", "8021"))) args = parser.parse_args() server = ThreadingHTTPServer((args.host, args.port), McpHandler) print(f"adapter-1c-mcp listening on http://{args.host}:{args.port}", flush=True) print(f"ONEC_ADAPTER_URL={adapter_url()}", flush=True) try: server.serve_forever() except KeyboardInterrupt: pass finally: with SESSION_LOCK: for events in SESSIONS.values(): events.put(None) server.server_close() time.sleep(0.1) return 0 if __name__ == "__main__": raise SystemExit(main())