Files
llm/plugins/1c/mcp/adapter_1c_mcp.py
T
2026-08-14 09:40:51 +03:00

3954 lines
185 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
from __future__ import annotations
import argparse
import concurrent.futures
import datetime
import http.client
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.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()
MCP_AUDIT_LOCK = threading.Lock()
SELECTOR_TOKEN_LOCK = threading.Lock()
JOBS: dict[str, dict[str, Any]] = {}
NEW_METHOD_CACHE: dict[str, dict[str, Any]] = {}
SELECTOR_TOKENS: dict[str, dict[str, Any]] = {}
SELECTOR_TOKEN_TTL_SECONDS = 600
LONG_METHODS = {
"metadata.object.attributes",
"metadata.object.full",
"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"}
CONFIGURATION_VIEW_TO_SOURCE_STATE = {
"effective": "working",
"effective_working": "working",
"designer": "working",
"working": "working",
"runtime": "applied",
"runtime_applied": "applied",
"applied": "applied",
"compare": "all",
"comparison": "all",
"both": "all",
}
REST_STATE_BY_SOURCE_STATE = {
"applied": "active",
"working": "working",
"all": "both",
}
REST_STATE_METHODS = {
"metadata.object.forms",
"metadata.object.form.details",
"metadata.form.decode",
"metadata.object.full",
"metadata.resolve_overrides",
"modules.search",
"code.search",
"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"
TECHNICAL_AGENT_FIELDS = {
"table", "file_name", "file_names", "module_ref", "module_id",
"stream_index", "bsl_offset", "cas_key", "storage_key",
"include_storage", "guid", "object_guid", "form_guid", "extension_guid",
}
TOOLS = [
{
"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. "
"Use complete public 1C names first: extension + object ref + child name where applicable. "
"Search results declare read_selector.method and include read_selector.selector_token; reuse that token with its declared method for the next read call. "
"For a name search across metadata objects, forms, attributes, commands, templates, routines, and extension definitions, use metadata.definition.find; "
"use code.search only when the query is BSL text. Scope extension objects with extension.objects.find or metadata.definition.find areas=extensions, never by SQL table names. "
"For unresolved module owners, inspect diagnostics.owner_resolution and adjust the public object selector "
"(ref, kind/name, or object_type/object_name). Global code/vector searches resolve "
"base module owners lazily from current metadata; use metadata.module_owner_cache.backfill for bounded "
"background warming instead of increasing owner_scan_limit on interactive searches. "
"The default agent view is configuration_view=effective_working with source_state=working: the logical Designer snapshot, with working changes and extension layers preferred; "
"it becomes executable after configuration update, not necessarily now. Use configuration_view=runtime_applied for code executable now, or compare to inspect both. "
"Do not select Config/ConfigSave tables in ordinary programming calls. "
"For saved-state methods, select base_saved_state or extension_saved_state with layer and identify objects by ref or kind/name. "
"Do not send GUIDs, table/file_name/module_ref, stream indexes, CAS keys, or include_storage in ordinary agent requests. "
"Before writes, call metadata.write.preflight when you need a read-only route/freshness check; it reports "
"ready, needs_prepare, needs_resolution, or blocked and never applies SQL writes. "
"Repository manual-capture protocol: when repository.lock.request or repository.lock.request.status returns "
"status=pending_user_lock, do not invent confirmation fields. After the user confirms the exact object is "
"captured in Configurator, call the returned next_call.method using next_call.params as this tool's payload. "
"The successful confirmation returns write_context; forward it unchanged as payload.repository_lock (or copy its fields to the payload top level) for write preflight and write calls. "
"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 a scheduled-job schedule, use metadata.write with target.kind=schedule and target.ref such as РегламентныеЗадания.ОбменДанными; pass named schedule fields instead of GUIDs or tree paths. "
"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": "<base_id-from-project-context>",
"object_type": "<metadata-kind>",
"object_name": "<metadata-object-name>",
"method_name": "<routine-name>",
"source_state": "working",
},
},
{
"method": "metadata.objects.list",
"payload": {
"base_id": "<base_id-from-project-context>",
"kind": "<metadata-kind>",
"limit": 50,
"offset": 0,
},
},
{
"method": "metadata.object.full",
"payload": {
"base_id": "<base_id-from-project-context>",
"ref": "<metadata-kind>.<metadata-object-name>",
"sections": ["modules", "templates", "forms"],
},
},
{
"method": "extension.objects.find",
"payload": {
"base_id": "<base_id-from-project-context>",
"extension": "<extension-name>",
"object_type": "CommonForm",
"source_state": "working",
"limit": 50,
},
},
{
"method": "code.search",
"payload": {
"base_id": "<base_id-from-project-context>",
"query": "<text-or-routine-fragment>",
"object_type": "<optional-metadata-kind>",
"object_name": "<optional-metadata-object-name>",
"source_state": "working",
"scan_limit": 400,
},
},
{
"method": "code.write",
"payload": {
"base_id": "<base_id-from-project-context>",
"object_type": "<metadata-kind>",
"object_name": "<metadata-object-name>",
"routine_name": "<routine-name>",
"routine_text": "<full-procedure-or-function-text>",
},
},
{
"method": "code.write",
"payload": {
"base_id": "<base_id-from-project-context>",
"extension": "<extension-name>",
"path": "<extension>.<form-or-object-name>.<routine-name>",
"routine_text": "<full-procedure-or-function-text>",
"mode": "apply",
},
},
{
"method": "bulk.execute",
"payload": {
"base_id": "<base_id-from-project-context>",
"cache_profile": "normal",
"requests": [
{
"method": "code.search",
"payload": {
"base_id": "<base_id-from-project-context>",
"query": "<text-or-routine-fragment>",
"source_state": "working",
},
},
{
"method": "metadata.resolve_overrides",
"payload": {
"base_id": "<base_id-from-project-context>",
"method_name": "<routine-name>",
"source_state": "working",
},
},
],
},
},
{
"method": "code.read",
"payload": {
"selector_token": "<selector-token-from-code-search>",
"include_line_numbers": True,
"max_chars": 20000,
},
},
{
"method": "templates.bindings",
"payload": {
"base_id": "<base_id-from-project-context>",
"object_type": "<metadata-kind>",
"object_name": "<metadata-object-name>",
},
},
{
"method": "diagnostics.call_chain",
"payload": {
"base_id": "<base_id-from-project-context>",
"entry_method": "<routine-name>",
"object_type": "<metadata-kind>",
"object_name": "<metadata-object-name>",
},
},
{
"method": "extensions.list",
"payload": {
"base_id": "<base_id-from-project-context>",
"is_active": True,
"limit": 100,
"offset": 0,
},
},
{
"method": "modules.search",
"payload": {
"base_id": "<base_id-from-project-context>",
"query": "<text-or-routine-fragment>",
"scan_limit": 500,
"resolve_owners": True,
"owner_scan_limit": 80,
},
},
{
"method": "modules.read",
"payload": {
"selector_token": "<selector-token-from-modules-search>",
"include_line_numbers": True,
"include_text": True,
},
},
{
"method": "metadata.write.preflight",
"payload": {
"base_id": "<base_id-from-project-context>",
"extension": "<extension-name>",
"target": {"canonical_path": "ОбщаяФорма.<form-name>.<routine-name>", "kind": "module"},
"intent": {
"operation": "upsert_routine",
"routine_text": "<full-procedure-or-function-text>",
},
},
},
{
"method": "metadata.write",
"payload": {
"base_id": "<base_id-from-project-context>",
"extension": "<extension-name>",
"target": {"canonical_path": "ОбщаяФорма.<form-name>.<routine-name>"},
"mode": "apply",
"routine_operation": "upsert",
"routine_text": "<full-procedure-or-function-text>",
},
},
{
"method": "metadata.write",
"payload": {
"base_id": "<base_id-from-project-context>",
"target": {"kind": "schedule", "ref": "РегламентныеЗадания.<scheduled-job-name>"},
"schedule": {"begin_time": "09:00:00", "week_days": [1, 2, 3, 4, 5]},
"allow_saved_state_write": True,
"mode": "plan",
},
},
{
"method": "metadata.write",
"payload": {
"base_id": "<base_id-from-project-context>",
"target": {
"area": "object",
"ref": "Справочник.<object-name>",
"property": "synonym",
},
"value": "<new-synonym>",
"mode": "plan",
},
},
{
"method": "metadata.write",
"payload": {
"base_id": "<base_id-from-project-context>",
"target": {
"area": "object",
"operation": "add_attribute",
"template_member_ref": "Справочник.<object-name>.Реквизит.<template-attribute>",
"new_member_name": "<new-attribute-name>",
},
"mode": "plan",
},
},
{
"method": "metadata.form.command_button.write",
"payload": {
"base_id": "<base_id-from-project-context>",
"extension": "<extension-name>",
"form": "<form-name>",
"command_name": "<command-name>",
"button_name": "<button-name>",
"title": "<command-title>",
"handler_name": "<routine-name>",
"handler_routine_text": "<full-procedure-text>",
"include_handler": True,
"mode": "apply",
},
},
{
"method": "metadata.form.command_button.verify",
"payload": {
"base_id": "<base_id-from-project-context>",
"extension": "<extension-name>",
"form": "<form-name>",
"command_name": "<command-name>",
"button_name": "<button-name>",
"handler_name": "<routine-name>",
},
},
{
"method": "metadata.form.write_target.verify",
"payload": {
"base_id": "<base_id-from-project-context>",
"extension": "<extension-name>",
"form": "<form-name>",
"command": "<command-or-element-name>",
"property": "title",
},
},
{
"method": "metadata.write.history",
"payload": {
"base_id": "<base_id-from-project-context>",
"limit": 10,
},
},
{
"method": "metadata.write.rollback",
"payload": {
"base_id": "<base_id-from-project-context>",
"operation_id": "<operation-id-from-metadata.write.history>",
"allow_sql_saved_state_rollback": True,
},
},
{
"method": "metadata.saved_state.prepare",
"payload": {
"base_id": "<base_id-from-project-context>",
"layer": "extension_saved_state",
"ref": "<metadata-kind>.<metadata-object-name>",
"extension": "<extension-name>",
"mode": "plan",
},
},
{
"method": "metadata.saved_state.diff",
"payload": {
"base_id": "<base_id-from-project-context>",
"ref": "<metadata-kind>.<metadata-object-name>",
"module_ordinal": 1,
"extension": "<extension-name-if-needed>",
"max_text_diff_lines": 80,
},
},
{
"method": "metadata.saved_state.status",
"payload": {
"base_id": "<base_id-from-project-context>",
"layer": "extension_saved_state",
"limit": 200,
},
},
{
"method": "metadata.saved_state.changes.list",
"payload": {
"base_id": "<base_id-from-project-context>",
"layer": "extension_saved_state",
"limit": 200,
"include_context": True,
"group_by_context": True,
"context_limit": 50,
},
},
{
"method": "configuration.activation.status",
"payload": {
"base_id": "<base_id-from-project-context>",
"layer": "all",
},
},
{
"method": "configuration.activation.plan",
"payload": {
"base_id": "<base_id-from-project-context>",
"layer": "extension_saved_state",
},
},
{
"method": "configuration.activation.request",
"payload": {
"base_id": "<base_id-from-project-context>",
"layer": "extension_saved_state",
},
},
{
"method": "configuration.activation.execute",
"payload": {
"base_id": "<base_id-from-project-context>",
"request_id": "<request-id-from-configuration.activation.request>",
"mode": "debug",
"confirm_activation": True,
"bridge_debug": True,
},
},
{
"method": "configuration.activation.request.cancel",
"payload": {
"base_id": "<base_id-from-project-context>",
"request_id": "<activation-request-id>",
"confirm_cancel": True,
},
},
{
"method": "configuration.activation.capabilities",
"payload": {
"base_id": "<base_id-from-project-context>",
"layer": "all",
},
},
{
"method": "configuration.activation.bridge.probe",
"payload": {
"base_id": "<base_id-from-project-context>",
"layer": "extension_saved_state",
"timeout_seconds": 10,
},
},
{
"method": "configuration.activation.verify",
"payload": {
"base_id": "<base_id-from-project-context>",
"request_id": "<activation-request-id>",
},
},
],
"properties": {
"method": {
"type": "string",
"description": "Adapter method, for example metadata.objects.list, metadata.object.full, metadata.definition.find, metadata.route.resolve, extension.objects.find, templates.read, templates.analyze, templates.map, metadata.module_owner_cache.backfill, metadata.saved_state.ensure, metadata.saved_state.ensure.rollback, metadata.saved_state.prepare, metadata.saved_state.status, metadata.saved_state.diff, metadata.saved_state.changes.list, configuration.activation.status, configuration.activation.plan, configuration.activation.request, configuration.activation.request.status, configuration.activation.request.cancel, configuration.activation.audit, configuration.activation.capabilities, configuration.activation.bridge.probe, configuration.activation.execute, configuration.activation.verify, metadata.saved_state.forms.search, metadata.saved_state.modules.search, metadata.form.write_target.resolve, metadata.form.write_target.verify, metadata.module.write_apply, metadata.write.plan, metadata.write.preflight, metadata.write.capabilities, metadata.write, metadata.write.history, metadata.write.rollback, metadata.form.command_button.write, metadata.form.command_button.verify, code.write, metadata.form.element.write_apply, metadata.write_learning.capture_before, metadata.write_learning.capture_after, metadata.write_learning.diff, metadata.write_learning.infer_rule, modules.search, modules.read, code.search, code.read, code.symbol.resolve, templates.bindings, diagnostics.call_chain, bulk.execute, changes.propose, storage.saved_state.apply_proposal, storage.saved_state.rollback, or mcp.job.get. Live database methods require payload.base_id; placeholders in examples must be replaced from project/user context.",
},
"payload": {
"type": "object",
"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"},
"module_ref": {"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"},
"expected_sha1": {"type": "string"},
"expected_text_sha1": {"type": "string"},
"repository_lock": {"type": "object", "additionalProperties": True},
"write_context": {"type": "object", "additionalProperties": True},
"mode": {"type": "string", "enum": ["plan", "apply"]},
"include_storage": {"type": "boolean"},
},
},
},
"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 data record. Prefer public object_ref plus record_ref; legacy raw BSP fields remain available.",
"inputSchema": {
"type": "object",
"required": ["base_id"],
"properties": {
"base_id": {"type": "string", "description": "Concrete 1C base id."},
"object_ref": {"type": "string", "description": "Public metadata ref, for example Справочники.Номенклатура."},
"record_ref": {"type": "string", "description": "Concrete 1C application-data record reference."},
"object": {"type": "string", "description": "Legacy raw BSP object value."},
"object_id": {"type": "string", "description": "Legacy raw BSP object_id; prefer record_ref."},
"object_sql_number": {"type": "integer", "description": "Legacy physical selector; prefer object_ref."},
"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. kind selects the query area, not a metadata kind; in object mode prefer object_ref plus optional record_ref.",
"inputSchema": {
"type": "object",
"required": ["base_id"],
"properties": {
"base_id": {"type": "string", "description": "Concrete 1C base id."},
"kind": {"type": "string", "description": "Query area: group, user_set, object, set, or all. This is not a 1C metadata kind."},
"area": {"type": "string", "description": "Alias of kind."},
"group": {"type": "string"},
"user": {"type": "string"},
"user_set": {"type": "string"},
"object_ref": {"type": "string", "description": "Public metadata ref, for example Справочники.Номенклатура."},
"record_ref": {"type": "string", "description": "Concrete 1C application-data record reference."},
"object": {"type": "string", "description": "Legacy raw BSP object value."},
"object_id": {"type": "string", "description": "Legacy raw BSP object_id; prefer record_ref."},
"object_sql_number": {"type": "integer", "description": "Legacy physical selector; prefer object_ref."},
"access_key": {"type": "string"},
"resolve_records": {"type": "boolean", "default": False},
"max_resolved_records": {"type": "integer", "minimum": 0, "maximum": 5000, "default": 200},
"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 record presentations. Prefer public object_ref plus optional record_ref.",
"inputSchema": {
"type": "object",
"required": ["base_id"],
"properties": {
"base_id": {"type": "string", "description": "Concrete 1C base id."},
"object_ref": {"type": "string", "description": "Public metadata ref, for example Справочники.Номенклатура."},
"record_ref": {"type": "string", "description": "Concrete 1C application-data record reference."},
"object": {"type": "string", "description": "Legacy raw BSP object value."},
"object_id": {"type": "string", "description": "Legacy raw BSP object_id; prefer record_ref."},
"object_sql_number": {"type": "integer", "description": "Legacy physical selector; prefer object_ref."},
"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
# Agents program against the logical Designer configuration by default.
# Runtime remains explicit through source_mode=runtime or source_state=applied.
return "applied" if source_mode == "runtime" else "working"
def normalize_configuration_view(value: Any, source_state: str) -> tuple[str, str]:
view = str(value or "").strip().casefold()
if not view:
return ("effective_working" if source_state == "working" else "runtime_applied" if source_state == "applied" else "compare", source_state)
mapped = CONFIGURATION_VIEW_TO_SOURCE_STATE.get(view)
if not mapped:
return "", ""
if source_state and source_state != mapped:
return "", ""
canonical = "effective_working" if mapped == "working" else "runtime_applied" if mapped == "applied" else "compare"
return canonical, mapped
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:
item = {"index": index, "method": submethod, "status": "error", "error": str(exc)}
if truthy(os.environ.get("ONEC_MCP_DEBUG_DIAGNOSTICS")):
item["traceback"] = traceback.format_exc(limit=5)
results.append(item)
requested_count = len(_as_list(payload.get("requests")))
failed_count = len([item for item in results if (item.get("status") in {"error", "invalid_argument"})])
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)
if method.startswith("storage."):
context["cache_policy"] = "none"
context["force_refresh"] = True
context["request_id"] = str(payload.get("_mcp_request_id") or uuid.uuid4().hex)
context["method"] = method
context["base_id"] = payload.get("base_id")
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)
explicit_source_state = normalize_source_state(payload.get("source_state"), source_mode) if payload.get("source_state") is not None else ""
configuration_view, source_state = normalize_configuration_view(payload.get("configuration_view"), explicit_source_state)
if not configuration_view:
transformed = dict(payload)
transformed["_configuration_view_error"] = "configuration_view conflicts with source_state or is unsupported."
return transformed
if not source_state:
source_state = normalize_source_state(None, source_mode)
configuration_view, source_state = normalize_configuration_view(payload.get("configuration_view"), source_state)
if method == "code.write":
source_state = "working"
cache_policy = "none"
force_refresh = truthy(payload.get("force_refresh"))
if method.startswith("storage."):
# Storage methods always call the live SQL layer or adapter-local
# backup store directly; their result is never served from the
# metadata/vector cache.
cache_policy = "none"
force_refresh = True
transformed = dict(payload)
transformed["source_mode"] = source_mode
transformed["source_state"] = source_state
transformed["configuration_view"] = configuration_view
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 mcp_audit_event(event: dict[str, Any]) -> None:
"""Persist proxy telemetry without BSL text, payload bytes, or credentials."""
try:
path = Path(os.environ.get("ONEC_MCP_AUDIT_LOG_PATH") or "/data/mcp-audit.jsonl")
path.parent.mkdir(parents=True, exist_ok=True)
with MCP_AUDIT_LOCK:
max_bytes = max(1_048_576, int(os.environ.get("ONEC_MCP_AUDIT_MAX_BYTES") or 52_428_800))
keep_files = max(1, min(20, int(os.environ.get("ONEC_MCP_AUDIT_KEEP_FILES") or 10)))
if path.exists() and path.stat().st_size >= max_bytes:
for index in range(keep_files - 1, 0, -1):
source = path.with_name(f"{path.name}.{index}")
target = path.with_name(f"{path.name}.{index + 1}")
if source.exists():
source.replace(target)
path.replace(path.with_name(f"{path.name}.1"))
with path.open("a", encoding="utf-8") as handle:
handle.write(json.dumps(event, ensure_ascii=False, sort_keys=True, default=str) + "\n")
except (OSError, ValueError):
return
def mcp_audit_request_summary(payload: dict[str, Any]) -> dict[str, Any]:
keys = ("base_id", "extension", "extension_guid", "ref", "kind", "name", "object_type", "object_name", "module_ordinal", "mode")
return {key: payload.get(key) for key in keys if payload.get(key) not in {None, ""}}
def http_json(
method: str,
path: str,
payload: dict[str, Any] | None = None,
timeout: float | None = None,
request_id: str | None = None,
) -> Any:
url = f"{adapter_url()}{path}"
data = None if payload is None else json.dumps(payload, ensure_ascii=False).encode("utf-8")
headers = {"Accept": "application/json"}
if data is not None:
headers["Content-Type"] = "application/json; charset=utf-8"
if adapter_token():
headers["Authorization"] = f"Bearer {adapter_token()}"
if request_id and re.fullmatch(r"[A-Za-z0-9_.-]{8,128}", request_id):
headers["X-Request-ID"] = request_id
request = urllib.request.Request(url, data=data, headers=headers, method=method)
effective_timeout = adapter_timeout() if timeout is None else timeout
try:
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, http.client.HTTPException, OSError) as exc:
reason = getattr(exc, "reason", None) or str(exc) or type(exc).__name__
raise AdapterError(f"REST adapter is unavailable: {reason}") from exc
def call_adapter_method(method: str, payload: dict[str, Any], *, timeout: float | None = None) -> Any:
request_id = str(payload.get("_mcp_request_id") or "").strip()
started = now_ts()
try:
if method == "health":
query = ""
if payload.get("base_id"):
query = "?" + urllib.parse.urlencode({"base_id": str(payload.get("base_id"))})
result = http_json("GET", f"/health{query}", timeout=timeout, request_id=request_id)
elif method == "help.methods":
try:
result = http_json("POST", "/rpc", {"method": method, "payload": payload}, timeout=timeout, request_id=request_id)
except AdapterError as exc:
if exc.status not in {404, 405}:
raise
result = http_json("GET", "/methods", timeout=timeout, request_id=request_id)
else:
result = http_json("POST", "/rpc", {"method": method, "payload": payload}, timeout=timeout, request_id=request_id)
except Exception as exc:
mcp_audit_event({
"event": "mcp_adapter_call", "time": datetime.datetime.now(datetime.timezone.utc).isoformat(),
"request_id": request_id or None, "method": method, "request": mcp_audit_request_summary(payload),
"status": "exception", "error": "adapter_unavailable" if isinstance(exc, AdapterError) else "mcp_request_exception",
"exception_type": type(exc).__name__, "duration_ms": int((now_ts() - started) * 1000),
})
raise
mcp_audit_event({
"event": "mcp_adapter_call", "time": datetime.datetime.now(datetime.timezone.utc).isoformat(),
"request_id": request_id or None, "method": method, "request": mcp_audit_request_summary(payload),
"status": result.get("status") if isinstance(result, dict) else None,
"error": result.get("error") if isinstance(result, dict) else None,
"duration_ms": int((now_ts() - started) * 1000),
})
return result
def public_error(method: str, error: str, diagnostics: Any | None = None, *, schema: str = "adapter_1c_mcp_error.v1") -> dict[str, Any]:
safe_diagnostics = diagnostics if diagnostics is not None else {"message": error}
if not truthy(os.environ.get("ONEC_MCP_DEBUG_DIAGNOSTICS")):
safe_diagnostics = strip_private_error_diagnostics(safe_diagnostics)
return {
"schema": schema,
"status": "error",
"method": method,
"error": error,
"diagnostics": safe_diagnostics,
}
def strip_private_error_diagnostics(value: Any) -> Any:
if isinstance(value, list):
return [strip_private_error_diagnostics(item) for item in value]
if not isinstance(value, dict):
return value
return {
key: strip_private_error_diagnostics(item)
for key, item in value.items()
if key not in {"traceback", "stack", "stacktrace", "exception_repr"}
}
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
owner_object_type = (
payload.get("object_type")
or target.get("object_type")
or payload.get("owner_kind")
or target.get("owner_kind")
)
if str(owner_object_type or "").strip().casefold() in {"module", "bsl_module", "bsl", "модуль"}:
owner_object_type = None
owner_object_name = (
payload.get("object_name")
or target.get("object_name")
or payload.get("owner_name")
or target.get("owner_name")
)
owner_object_guid = (
payload.get("object_guid")
or target.get("object_guid")
or payload.get("owner_guid")
or target.get("owner_guid")
)
suggested_payload = {
"base_id": payload.get("base_id"),
**{
key: value
for key, value in {
"ref": payload.get("ref") or target.get("ref"),
"module_ref": payload.get("module_ref") or target.get("module_ref"),
"object_type": owner_object_type,
"object_name": owner_object_name,
"object_guid": owner_object_guid,
"routine_name": payload.get("routine_name") or target.get("routine_name"),
"routine_text": payload.get("routine_text"),
"module_text": payload.get("module_text"),
"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 purge_expired_selector_tokens() -> None:
cutoff = now_ts() - SELECTOR_TOKEN_TTL_SECONDS
with SELECTOR_TOKEN_LOCK:
expired = [token for token, entry in SELECTOR_TOKENS.items() if float(entry.get("created_at") or 0) < cutoff]
for token in expired:
SELECTOR_TOKENS.pop(token, None)
def issue_selector_token(selector: dict[str, Any]) -> str:
purge_expired_selector_tokens()
token = f"onecsel_{uuid.uuid4().hex}"
with SELECTOR_TOKEN_LOCK:
SELECTOR_TOKENS[token] = {"created_at": now_ts(), "selector": dict(selector)}
return token
def diagnostic_mode_authorized(payload: dict[str, Any]) -> bool:
"""Developer diagnostics are opt-in at deployment level, not an agent choice."""
return (
(truthy(payload.get("diagnostic")) or truthy(payload.get("_allow_diagnostic")))
and truthy(os.environ.get("ONEC_MCP_ALLOW_DIAGNOSTIC"))
)
def publicize_read_selectors(value: Any) -> Any:
"""Replace adapter-issued technical continuations with short-lived opaque tokens."""
if isinstance(value, list):
return [publicize_read_selectors(item) for item in value]
if not isinstance(value, dict):
return value
public: dict[str, Any] = {}
for key, item in value.items():
if key in TECHNICAL_AGENT_FIELDS:
continue
if key == "read_selector" and isinstance(item, dict) and str(item.get("method") or "").strip():
public[key] = {"method": str(item["method"]), "selector_token": issue_selector_token(item)}
elif key == "read_selectors" and isinstance(item, dict):
public[key] = {
name: (
{"method": str(selector["method"]), "selector_token": issue_selector_token(selector)}
if isinstance(selector, dict) and str(selector.get("method") or "").strip()
else publicize_read_selectors(selector)
)
for name, selector in item.items()
}
else:
public[key] = publicize_read_selectors(item)
return public
def resolve_selector_token(method: str, payload: dict[str, Any]) -> tuple[dict[str, Any] | None, dict[str, Any] | None]:
token = str(payload.get("selector_token") or "").strip()
if not token:
return payload, None
purge_expired_selector_tokens()
with SELECTOR_TOKEN_LOCK:
entry = SELECTOR_TOKENS.get(token)
selector = entry.get("selector") if isinstance(entry, dict) and isinstance(entry.get("selector"), dict) else None
if not selector:
return None, public_error(method, "selector_token_invalid", {"message": "selector_token is unknown or expired; repeat the public discovery call."})
selector_method = str(selector.get("method") or "").strip()
if selector_method != method:
return None, public_error(method, "selector_token_method_mismatch", {"message": f"selector_token is valid only for `{selector_method}`."})
explicit = {key: value for key, value in payload.items() if key != "selector_token"}
resolved = {**selector, **explicit, "_selector_token_resolved": True}
return resolved, None
def technical_selector_fields(payload: Any) -> list[str]:
"""Find technical selector keys at every JSON level supplied by an agent."""
found: set[str] = set()
if isinstance(payload, dict):
for key, value in payload.items():
if key in TECHNICAL_AGENT_FIELDS:
found.add(key)
found.update(technical_selector_fields(value))
elif isinstance(payload, list):
for value in payload:
found.update(technical_selector_fields(value))
return sorted(found)
def normal_agent_technical_field_guardrail(method: str, payload: dict[str, Any]) -> dict[str, Any] | None:
if diagnostic_mode_authorized(payload) or truthy(payload.get("_selector_token_resolved")):
return None
prohibited = technical_selector_fields(payload)
if not prohibited:
return None
return {
"schema": "adapter_1c_mcp_policy.v1",
"status": "blocked",
"method": method,
"reason": "technical_selector_forbidden",
"diagnostics": {
"fields": prohibited,
"message": "Use complete public 1C names (extension + ref + child name) or an adapter-issued selector_token. SQL/storage coordinates are developer diagnostics only.",
"suggested_request": {
"method": "metadata.object.full",
"payload": {"base_id": payload.get("base_id"), "ref": payload.get("ref"), "configuration_view": "effective_working"},
},
},
}
def runtime_form_inspection_unsupported(method: str, payload: dict[str, Any]) -> dict[str, Any] | None:
if method not in {"runtime.form.elements.inspect", "runtime.form.inspect"}:
return None
return {
"schema": "onec_runtime_form_inspection.v1",
"status": "unsupported",
"method": method,
"error": "runtime_inspection_unsupported",
"base_id": payload.get("base_id"),
"diagnostics": {
"message": "The SQL-only adapter does not open 1C forms, execute form handlers, or inspect runtime-generated controls. Read static metadata with metadata.form.decode; obtain runtime evidence through a separately authorised human-operated channel.",
},
}
def run_or_enqueue_adapter_method(method: str, payload: dict[str, Any]) -> Any:
request_start = now_ts()
request_id = uuid.uuid4().hex
if method == "code.search" and payload.get("extension_guid") in {None, ""}:
payload = dict(payload)
payload.pop("extension_guid", None)
request_payload = apply_freshness_request_policy(payload, method)
configuration_view_error = request_payload.pop("_configuration_view_error", None)
if configuration_view_error:
return {
"schema": "adapter_1c_mcp_policy.v1",
"status": "invalid_argument",
"method": method,
"error": "invalid_configuration_view",
"diagnostics": {"message": str(configuration_view_error)},
}
request_payload["_mcp_request_id"] = request_id
payload = request_payload
runtime_guardrail = runtime_form_inspection_unsupported(method, payload)
if runtime_guardrail is not None:
return enrich_result_with_freshness(payload, method, runtime_guardrail, request_start)
technical_field_guardrail = normal_agent_technical_field_guardrail(method, payload)
if technical_field_guardrail is not None:
return enrich_result_with_freshness(payload, method, technical_field_guardrail, request_start)
code_guardrail = metadata_write_code_guardrail(method, payload)
if code_guardrail is not None:
return enrich_result_with_freshness(payload, method, code_guardrail, request_start)
if method_requires_base_id(method) and not str(payload.get("base_id") or "").strip():
return missing_base_id_policy(method)
if (method.startswith(DIAGNOSTIC_METHOD_PREFIXES) or method in DIAGNOSTIC_METHODS) and not diagnostic_mode_authorized(payload):
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. "
"Developer diagnostics require diagnostic=true and ONEC_MCP_ALLOW_DIAGNOSTIC=true in the MCP deployment."
)
},
}
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("<!doctype html><html><body><pre>" + json.dumps(result, ensure_ascii=False, indent=2) + "</pre></body></html>", 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"}))
payload, selector_error = resolve_selector_token(method, payload)
if selector_error is not None:
return tool_text(selector_error)
assert payload is not None
if method in {"mcp.job.get", "adapter.job.get", "onec.job.get"}:
job_id = str(payload.get("job_id") or "").strip()
if not job_id:
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(publicize_read_selectors(job))
except AdapterError as exc:
return tool_text(adapter_error_result("adapter.job.get", exc))
if method in {"mcp.job.cancel", "adapter.job.cancel", "onec.job.cancel"}:
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(publicize_read_selectors(run_or_enqueue_adapter_method(method, payload)))
if name == "onec_job_get":
job_id = str(args.get("job_id") or "").strip()
if not job_id:
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(publicize_read_selectors(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:
data: dict[str, Any] = {"message": str(exc)}
if truthy(os.environ.get("ONEC_MCP_DEBUG_DIAGNOSTICS")):
data["traceback"] = traceback.format_exc()
return jsonrpc_error(request_id, -32000, "MCP request failed", data)
def payload_has_method(payload: Any, method: str) -> bool:
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())