answer42 0.2.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- answer42-0.2.0.dist-info/METADATA +388 -0
- answer42-0.2.0.dist-info/RECORD +28 -0
- answer42-0.2.0.dist-info/WHEEL +4 -0
- answer42-0.2.0.dist-info/entry_points.txt +2 -0
- answer42-0.2.0.dist-info/licenses/LICENSE +21 -0
- mcp_1c/__init__.py +4 -0
- mcp_1c/assets/MCPTestClient.cf +0 -0
- mcp_1c/assets/MCPTestManager.cf +0 -0
- mcp_1c/assets/__init__.py +1 -0
- mcp_1c/assets/skills/answer42/SKILL.md +170 -0
- mcp_1c/assets/skills/answer42-rag/SKILL.md +58 -0
- mcp_1c/bridge.py +136 -0
- mcp_1c/credentials.py +147 -0
- mcp_1c/os_support.py +224 -0
- mcp_1c/platform.py +187 -0
- mcp_1c/protocol.py +35 -0
- mcp_1c/rag/__init__.py +5 -0
- mcp_1c/rag/detect.py +23 -0
- mcp_1c/rag/model.py +114 -0
- mcp_1c/rag/parsers.py +387 -0
- mcp_1c/rag/service.py +375 -0
- mcp_1c/rag/store.py +228 -0
- mcp_1c/recorder.py +239 -0
- mcp_1c/release_helper.py +83 -0
- mcp_1c/runtime.py +636 -0
- mcp_1c/server.py +3285 -0
- mcp_1c/skill_installer.py +127 -0
- mcp_1c/window_control.py +276 -0
mcp_1c/server.py
ADDED
|
@@ -0,0 +1,3285 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
import asyncio
|
|
5
|
+
import base64
|
|
6
|
+
import json
|
|
7
|
+
import logging
|
|
8
|
+
import os
|
|
9
|
+
import shutil
|
|
10
|
+
import sys
|
|
11
|
+
import time
|
|
12
|
+
import uuid
|
|
13
|
+
from datetime import datetime
|
|
14
|
+
import shlex
|
|
15
|
+
import subprocess
|
|
16
|
+
import re
|
|
17
|
+
import hashlib
|
|
18
|
+
import importlib.resources
|
|
19
|
+
import urllib.request
|
|
20
|
+
from urllib.parse import urlsplit, urlunsplit
|
|
21
|
+
from dataclasses import dataclass, field
|
|
22
|
+
from contextlib import contextmanager
|
|
23
|
+
from pathlib import Path
|
|
24
|
+
from typing import Annotated, Any
|
|
25
|
+
|
|
26
|
+
from mcp.server.fastmcp import FastMCP
|
|
27
|
+
|
|
28
|
+
from . import os_support
|
|
29
|
+
from . import platform as _platform
|
|
30
|
+
from . import runtime
|
|
31
|
+
from . import skill_installer
|
|
32
|
+
from . import release_helper
|
|
33
|
+
from .bridge import OneCBridgeServer
|
|
34
|
+
from .credentials import get_store as _credentials_store
|
|
35
|
+
from .rag import RagService
|
|
36
|
+
from .window_control import active_1c_window_geometry as _active_windows_1c_window_geometry
|
|
37
|
+
from .window_control import maximize_test_client_window as _maximize_external_window
|
|
38
|
+
from .recorder import RecordingSession
|
|
39
|
+
|
|
40
|
+
mcp = FastMCP("Answer42")
|
|
41
|
+
_RAG = RagService()
|
|
42
|
+
DEFAULT_SESSION = "default"
|
|
43
|
+
STATE_FILE = os_support.runtime_path("ONEC_MCP_SESSION_STATE", "sessions.json")
|
|
44
|
+
AUTORELOAD_PID_FILE = os_support.runtime_path("ONEC_MCP_AUTORELOAD_PID_FILE", "answer42-autoreload.pid")
|
|
45
|
+
AUTORELOAD_LOG_FILE = os_support.runtime_path("ONEC_MCP_AUTORELOAD_LOG_FILE", "answer42-autoreload.log")
|
|
46
|
+
RAG_BINDINGS_FILE = os_support.runtime_path("ONEC_MCP_RAG_BINDINGS", "rag-bindings.json")
|
|
47
|
+
|
|
48
|
+
_PORT_ALLOC_LOCK = os_support.runtime_path("ONEC_MCP_PORT_ALLOC_LOCK", "port-allocation.lock")
|
|
49
|
+
_SHARED_FILE_SERVERS_FILE = os_support.runtime_path("ONEC_MCP_SHARED_FILE_SERVERS", "shared-file-servers.json")
|
|
50
|
+
_SHARED_FILE_SERVERS_LOCK = os_support.runtime_path("ONEC_MCP_SHARED_FILE_SERVERS_LOCK", "shared-file-servers.lock")
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
@contextmanager
|
|
54
|
+
def _port_allocation_lock():
|
|
55
|
+
with os_support.file_lock(_PORT_ALLOC_LOCK):
|
|
56
|
+
yield
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
@contextmanager
|
|
60
|
+
def _shared_file_servers_lock():
|
|
61
|
+
with os_support.file_lock(_SHARED_FILE_SERVERS_LOCK):
|
|
62
|
+
yield
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
_PORT_BAND_SIZE = 20
|
|
66
|
+
_PORT_SCAN_WIDTH = 10
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
@dataclass(slots=True)
|
|
70
|
+
class SharedFileServer:
|
|
71
|
+
"""Shared ibsrv instance for one file infobase.
|
|
72
|
+
|
|
73
|
+
Several /TESTCLIENT windows can connect to the same autonomous server.
|
|
74
|
+
The server is stopped when the last owning session releases it.
|
|
75
|
+
"""
|
|
76
|
+
|
|
77
|
+
key: str
|
|
78
|
+
db_path: Path
|
|
79
|
+
data_dir: Path
|
|
80
|
+
log_dir: Path
|
|
81
|
+
address: str
|
|
82
|
+
port: int
|
|
83
|
+
direct_regport: int
|
|
84
|
+
name: str
|
|
85
|
+
platform_dir: Path
|
|
86
|
+
pid: int
|
|
87
|
+
process: subprocess.Popen | None = None
|
|
88
|
+
owners: set[str] = field(default_factory=set)
|
|
89
|
+
|
|
90
|
+
@property
|
|
91
|
+
def base_url(self) -> str:
|
|
92
|
+
return f"http://{self.address}:{self.port}"
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
_SHARED_FILE_SERVERS: dict[str, SharedFileServer] = {}
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def _load_shared_file_registry() -> dict[str, Any]:
|
|
99
|
+
if not _SHARED_FILE_SERVERS_FILE.exists():
|
|
100
|
+
return {}
|
|
101
|
+
try:
|
|
102
|
+
return json.loads(_SHARED_FILE_SERVERS_FILE.read_text(encoding="utf-8"))
|
|
103
|
+
except Exception:
|
|
104
|
+
return {}
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def _save_shared_file_registry(registry: dict[str, Any]) -> None:
|
|
108
|
+
_SHARED_FILE_SERVERS_FILE.parent.mkdir(parents=True, exist_ok=True)
|
|
109
|
+
_SHARED_FILE_SERVERS_FILE.write_text(json.dumps(registry, ensure_ascii=False, indent=2), encoding="utf-8")
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def _shared_file_key(kind: str, platform_dir: Path, db_path: Path) -> str:
|
|
113
|
+
return f"{kind}:{platform_dir.resolve()}:{db_path.resolve()}"
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def _shared_file_server_alive(shared: SharedFileServer) -> bool:
|
|
117
|
+
if shared.process is not None and shared.process.poll() is not None:
|
|
118
|
+
return False
|
|
119
|
+
if not _pid_is_running(shared.pid):
|
|
120
|
+
return False
|
|
121
|
+
return runtime.is_port_listening(shared.address, shared.port)
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def _shared_file_entry_alive(entry: dict[str, Any]) -> bool:
|
|
125
|
+
return _pid_is_running(int(entry.get("pid") or 0)) and runtime.is_port_listening(
|
|
126
|
+
str(entry.get("address") or "127.0.0.1"),
|
|
127
|
+
int(entry.get("port") or 0),
|
|
128
|
+
)
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def _shared_file_from_entry(key: str, entry: dict[str, Any]) -> SharedFileServer:
|
|
132
|
+
return SharedFileServer(
|
|
133
|
+
key=key,
|
|
134
|
+
db_path=Path(str(entry["db_path"])),
|
|
135
|
+
data_dir=Path(str(entry["data_dir"])),
|
|
136
|
+
log_dir=Path(str(entry["log_dir"])),
|
|
137
|
+
address=str(entry.get("address") or "127.0.0.1"),
|
|
138
|
+
port=int(entry["port"]),
|
|
139
|
+
direct_regport=int(entry.get("direct_regport") or 0),
|
|
140
|
+
name=str(entry.get("name") or "mcpfileclient"),
|
|
141
|
+
platform_dir=Path(str(entry["platform_dir"])),
|
|
142
|
+
pid=int(entry["pid"]),
|
|
143
|
+
process=None,
|
|
144
|
+
owners=set(map(str, entry.get("owners") or [])),
|
|
145
|
+
)
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def _shared_file_to_entry(shared: SharedFileServer) -> dict[str, Any]:
|
|
149
|
+
return {
|
|
150
|
+
"db_path": str(shared.db_path),
|
|
151
|
+
"data_dir": str(shared.data_dir),
|
|
152
|
+
"log_dir": str(shared.log_dir),
|
|
153
|
+
"address": shared.address,
|
|
154
|
+
"port": shared.port,
|
|
155
|
+
"direct_regport": shared.direct_regport,
|
|
156
|
+
"name": shared.name,
|
|
157
|
+
"platform_dir": str(shared.platform_dir),
|
|
158
|
+
"pid": shared.pid,
|
|
159
|
+
"owners": sorted(shared.owners),
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def _kill_pid_group(pid: int, label: str, timeout: float = 5.0) -> None:
|
|
164
|
+
"""Terminate a process tree by pid when we do not own a Popen handle."""
|
|
165
|
+
if pid <= 0 or not _pid_is_running(pid):
|
|
166
|
+
return
|
|
167
|
+
if os_support.IS_WINDOWS:
|
|
168
|
+
try:
|
|
169
|
+
subprocess.run(
|
|
170
|
+
["taskkill", "/PID", str(pid), "/T", "/F"],
|
|
171
|
+
stdout=subprocess.DEVNULL,
|
|
172
|
+
stderr=subprocess.DEVNULL,
|
|
173
|
+
check=False,
|
|
174
|
+
timeout=max(1, int(timeout) + 5),
|
|
175
|
+
)
|
|
176
|
+
logging.info("Stopped %s (pid=%s) via taskkill", label, pid)
|
|
177
|
+
except Exception as exc:
|
|
178
|
+
logging.warning("Failed to stop %s (pid=%s) via taskkill: %s", label, pid, exc)
|
|
179
|
+
return
|
|
180
|
+
|
|
181
|
+
import signal
|
|
182
|
+
|
|
183
|
+
try:
|
|
184
|
+
os.killpg(os.getpgid(pid), signal.SIGTERM)
|
|
185
|
+
except Exception:
|
|
186
|
+
try:
|
|
187
|
+
os.kill(pid, signal.SIGTERM)
|
|
188
|
+
except Exception:
|
|
189
|
+
return
|
|
190
|
+
deadline = time.monotonic() + timeout
|
|
191
|
+
while time.monotonic() < deadline:
|
|
192
|
+
if not _pid_is_running(pid):
|
|
193
|
+
logging.info("Stopped %s (pid=%s) via SIGTERM", label, pid)
|
|
194
|
+
return
|
|
195
|
+
time.sleep(0.1)
|
|
196
|
+
try:
|
|
197
|
+
os.killpg(os.getpgid(pid), signal.SIGKILL)
|
|
198
|
+
except Exception:
|
|
199
|
+
try:
|
|
200
|
+
os.kill(pid, signal.SIGKILL)
|
|
201
|
+
except Exception:
|
|
202
|
+
return
|
|
203
|
+
logging.warning("Killed %s (pid=%s) via SIGKILL", label, pid)
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
def _shared_file_paths(kind: str, digest: str) -> tuple[Path, Path, Path]:
|
|
207
|
+
root = Path(os.getenv("ONEC_MCP_SHARED_FILE_SERVERS_DIR", str(os_support.default_data_dir() / "shared-file-servers")))
|
|
208
|
+
base = root / kind / digest
|
|
209
|
+
return base / "ib", base / "ibsrv-data", base / "logs"
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
def _acquire_shared_file_server(
|
|
213
|
+
*,
|
|
214
|
+
owner_session_id: str,
|
|
215
|
+
kind: str,
|
|
216
|
+
platform_dir: Path,
|
|
217
|
+
ibcmd: str,
|
|
218
|
+
ibsrv: str,
|
|
219
|
+
db_path: Path,
|
|
220
|
+
cf_path: str = "",
|
|
221
|
+
create_if_missing: bool = False,
|
|
222
|
+
port_seed: str = "",
|
|
223
|
+
server_name: str = "mcpfileclient",
|
|
224
|
+
) -> SharedFileServer:
|
|
225
|
+
"""Start or reuse one ibsrv for a file infobase and add an owner ref.
|
|
226
|
+
|
|
227
|
+
The registry is persisted under /tmp so parallel MCP server processes can
|
|
228
|
+
share a single autonomous server for the same file infobase.
|
|
229
|
+
"""
|
|
230
|
+
resolved_db_path = db_path.resolve()
|
|
231
|
+
key = _shared_file_key(kind, platform_dir, resolved_db_path)
|
|
232
|
+
with _shared_file_servers_lock():
|
|
233
|
+
registry = _load_shared_file_registry()
|
|
234
|
+
entry = registry.get(key)
|
|
235
|
+
if isinstance(entry, dict) and _shared_file_entry_alive(entry):
|
|
236
|
+
owners = set(map(str, entry.get("owners") or []))
|
|
237
|
+
owners.add(owner_session_id)
|
|
238
|
+
entry["owners"] = sorted(owners)
|
|
239
|
+
registry[key] = entry
|
|
240
|
+
_save_shared_file_registry(registry)
|
|
241
|
+
shared = _shared_file_from_entry(key, entry)
|
|
242
|
+
_SHARED_FILE_SERVERS[key] = shared
|
|
243
|
+
return shared
|
|
244
|
+
if entry:
|
|
245
|
+
_kill_pid_group(int(entry.get("pid") or 0), f"shared-ibsrv-{kind}")
|
|
246
|
+
registry.pop(key, None)
|
|
247
|
+
_save_shared_file_registry(registry)
|
|
248
|
+
|
|
249
|
+
if create_if_missing:
|
|
250
|
+
cf = Path(cf_path) if cf_path else None
|
|
251
|
+
if cf and not cf.exists():
|
|
252
|
+
raise FileNotFoundError(f"Shared file infobase CF not found at {cf}.")
|
|
253
|
+
# Shared file infobases are intentionally persistent across several
|
|
254
|
+
# MCP server processes. If a previous process already created the
|
|
255
|
+
# database but died before writing the registry entry, do not wipe it
|
|
256
|
+
# here: just publish it through a fresh ibsrv.
|
|
257
|
+
if not resolved_db_path.exists() or not any(resolved_db_path.iterdir()):
|
|
258
|
+
runtime.create_file_infobase(ibcmd, resolved_db_path)
|
|
259
|
+
if cf:
|
|
260
|
+
runtime.load_configuration(ibcmd, resolved_db_path, str(cf))
|
|
261
|
+
elif not resolved_db_path.exists():
|
|
262
|
+
raise FileNotFoundError(f"File infobase directory not found: {resolved_db_path}")
|
|
263
|
+
|
|
264
|
+
digest = hashlib.sha1(key.encode("utf-8")).hexdigest()[:12]
|
|
265
|
+
_, data_dir, log_dir = _shared_file_paths(kind, digest)
|
|
266
|
+
port_offset = _stable_session_port_offset(port_seed or key)
|
|
267
|
+
port = _find_session_port(runtime.DEFAULT_IBSRV_HTTP_PORT, port_offset, sub_offset=10)
|
|
268
|
+
direct_regport = _find_session_port(runtime.DEFAULT_IBSRV_DIRECT_PORT, port_offset, sub_offset=10)
|
|
269
|
+
proc = runtime.start_standalone_server(
|
|
270
|
+
ibsrv=ibsrv,
|
|
271
|
+
address="127.0.0.1",
|
|
272
|
+
port=port,
|
|
273
|
+
name=server_name,
|
|
274
|
+
data_dir=data_dir,
|
|
275
|
+
log_dir=log_dir,
|
|
276
|
+
db_path=resolved_db_path,
|
|
277
|
+
direct_regport=direct_regport,
|
|
278
|
+
direct_range=_direct_range_from_regport(direct_regport),
|
|
279
|
+
)
|
|
280
|
+
shared = SharedFileServer(
|
|
281
|
+
key=key,
|
|
282
|
+
db_path=resolved_db_path,
|
|
283
|
+
data_dir=data_dir,
|
|
284
|
+
log_dir=log_dir,
|
|
285
|
+
address="127.0.0.1",
|
|
286
|
+
port=port,
|
|
287
|
+
direct_regport=direct_regport,
|
|
288
|
+
name=server_name,
|
|
289
|
+
platform_dir=platform_dir,
|
|
290
|
+
pid=proc.pid,
|
|
291
|
+
process=proc,
|
|
292
|
+
owners={owner_session_id},
|
|
293
|
+
)
|
|
294
|
+
registry[key] = _shared_file_to_entry(shared)
|
|
295
|
+
_save_shared_file_registry(registry)
|
|
296
|
+
_SHARED_FILE_SERVERS[key] = shared
|
|
297
|
+
return shared
|
|
298
|
+
|
|
299
|
+
|
|
300
|
+
def _release_shared_file_server(key: str, owner_session_id: str) -> bool:
|
|
301
|
+
"""Release one owner ref. Return True when the shared server was stopped."""
|
|
302
|
+
if not key:
|
|
303
|
+
return False
|
|
304
|
+
with _shared_file_servers_lock():
|
|
305
|
+
registry = _load_shared_file_registry()
|
|
306
|
+
entry = registry.get(key)
|
|
307
|
+
if not isinstance(entry, dict):
|
|
308
|
+
_SHARED_FILE_SERVERS.pop(key, None)
|
|
309
|
+
return False
|
|
310
|
+
owners = set(map(str, entry.get("owners") or []))
|
|
311
|
+
owners.discard(owner_session_id)
|
|
312
|
+
if owners and _shared_file_entry_alive(entry):
|
|
313
|
+
entry["owners"] = sorted(owners)
|
|
314
|
+
registry[key] = entry
|
|
315
|
+
_save_shared_file_registry(registry)
|
|
316
|
+
local = _SHARED_FILE_SERVERS.get(key)
|
|
317
|
+
if local:
|
|
318
|
+
local.owners = owners
|
|
319
|
+
return False
|
|
320
|
+
_kill_pid_group(int(entry.get("pid") or 0), "shared-ibsrv-file")
|
|
321
|
+
registry.pop(key, None)
|
|
322
|
+
_save_shared_file_registry(registry)
|
|
323
|
+
_SHARED_FILE_SERVERS.pop(key, None)
|
|
324
|
+
return True
|
|
325
|
+
|
|
326
|
+
|
|
327
|
+
def _stable_session_port_offset(session_id: str) -> int:
|
|
328
|
+
digest = hashlib.sha1(session_id.encode("utf-8")).digest()
|
|
329
|
+
return (int.from_bytes(digest[:2], "big") % 100) * _PORT_BAND_SIZE
|
|
330
|
+
|
|
331
|
+
|
|
332
|
+
def _find_session_port(base: int, offset: int, *, sub_offset: int = 0) -> int:
|
|
333
|
+
start = base + offset + sub_offset
|
|
334
|
+
return runtime.find_free_port(start, start + _PORT_SCAN_WIDTH)
|
|
335
|
+
|
|
336
|
+
|
|
337
|
+
def _direct_range_from_regport(direct_regport: int, width: int = 8) -> tuple[int, int]:
|
|
338
|
+
"""Return a small direct port range unique to this standalone server."""
|
|
339
|
+
start = direct_regport + 20
|
|
340
|
+
return start, start + width - 1
|
|
341
|
+
|
|
342
|
+
|
|
343
|
+
class SessionState:
|
|
344
|
+
"""Per-session container for all live 1C/MCP bridge state."""
|
|
345
|
+
|
|
346
|
+
def __init__(
|
|
347
|
+
self,
|
|
348
|
+
session_id: str,
|
|
349
|
+
display: str,
|
|
350
|
+
platform_dir: Path,
|
|
351
|
+
ws_port: int,
|
|
352
|
+
ibsrv_port: int,
|
|
353
|
+
testclient_port: int,
|
|
354
|
+
data_dir: Path,
|
|
355
|
+
base_url: str = "",
|
|
356
|
+
username: str = "",
|
|
357
|
+
password: str = "",
|
|
358
|
+
) -> None:
|
|
359
|
+
self.session_id = session_id
|
|
360
|
+
self.display = display
|
|
361
|
+
self.platform_dir = platform_dir
|
|
362
|
+
self.ws_port = ws_port
|
|
363
|
+
self.ibsrv_port = ibsrv_port
|
|
364
|
+
self.testclient_port = testclient_port
|
|
365
|
+
self.data_dir = data_dir
|
|
366
|
+
self.base_url = base_url
|
|
367
|
+
self.username = username
|
|
368
|
+
self.password = password
|
|
369
|
+
self.client_ibsrv_port = 0 # to be set in start_session for the built-in demo client
|
|
370
|
+
self.client_runtime: runtime.ManagerRuntime | None = None
|
|
371
|
+
self.shared_client_key: str = "" # shared ibsrv key for built-in demo client
|
|
372
|
+
self.file_client_ibsrv_port = 0 # to be set when a file infobase target is served through ibsrv
|
|
373
|
+
self.file_client_runtime: runtime.ManagerRuntime | None = None
|
|
374
|
+
self.shared_file_key: str = "" # shared ibsrv key for connection_mode=file
|
|
375
|
+
self.xvfb_process: subprocess.Popen | None = None
|
|
376
|
+
self.runtime: runtime.ManagerRuntime | None = None
|
|
377
|
+
self.test_client_process: subprocess.Popen | None = None
|
|
378
|
+
self.bridge = OneCBridgeServer(
|
|
379
|
+
host="127.0.0.1",
|
|
380
|
+
port=ws_port,
|
|
381
|
+
request_timeout=float(os.getenv("ONEC_MCP_REQUEST_TIMEOUT", "60")),
|
|
382
|
+
)
|
|
383
|
+
self.recorder = RecordingSession()
|
|
384
|
+
self.rag_snapshot: str = ""
|
|
385
|
+
self.idle_timeout_seconds: int = 60 * 60
|
|
386
|
+
self.last_activity_monotonic: float = time.monotonic()
|
|
387
|
+
|
|
388
|
+
def to_json(self) -> dict[str, Any]:
|
|
389
|
+
return {
|
|
390
|
+
"session_id": self.session_id,
|
|
391
|
+
"display": self.display,
|
|
392
|
+
"platform_dir": str(self.platform_dir),
|
|
393
|
+
"ws_port": self.ws_port,
|
|
394
|
+
"ibsrv_port": self.ibsrv_port,
|
|
395
|
+
"testclient_port": self.testclient_port,
|
|
396
|
+
"data_dir": str(self.data_dir),
|
|
397
|
+
"base_url": self.base_url,
|
|
398
|
+
"username": self.username,
|
|
399
|
+
# Password is intentionally not persisted.
|
|
400
|
+
"client_ibsrv_port": self.client_ibsrv_port,
|
|
401
|
+
"shared_client_key": self.shared_client_key,
|
|
402
|
+
"file_client_ibsrv_port": self.file_client_ibsrv_port,
|
|
403
|
+
"shared_file_key": self.shared_file_key,
|
|
404
|
+
"rag_snapshot": self.rag_snapshot,
|
|
405
|
+
"idle_timeout_seconds": self.idle_timeout_seconds,
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
@classmethod
|
|
409
|
+
def from_json(cls, data: dict[str, Any]) -> "SessionState":
|
|
410
|
+
sess = cls(
|
|
411
|
+
session_id=str(data["session_id"]),
|
|
412
|
+
display=str(data["display"]),
|
|
413
|
+
platform_dir=Path(str(data["platform_dir"])),
|
|
414
|
+
ws_port=int(data["ws_port"]),
|
|
415
|
+
ibsrv_port=int(data["ibsrv_port"]),
|
|
416
|
+
testclient_port=int(data["testclient_port"]),
|
|
417
|
+
data_dir=Path(str(data["data_dir"])),
|
|
418
|
+
base_url=str(data.get("base_url", "")),
|
|
419
|
+
username=str(data.get("username", "")),
|
|
420
|
+
password="",
|
|
421
|
+
)
|
|
422
|
+
sess.client_ibsrv_port = int(data.get("client_ibsrv_port") or 0)
|
|
423
|
+
sess.shared_client_key = str(data.get("shared_client_key") or "")
|
|
424
|
+
sess.file_client_ibsrv_port = int(data.get("file_client_ibsrv_port") or 0)
|
|
425
|
+
sess.shared_file_key = str(data.get("shared_file_key") or "")
|
|
426
|
+
sess.rag_snapshot = str(data.get("rag_snapshot", ""))
|
|
427
|
+
sess.idle_timeout_seconds = int(data.get("idle_timeout_seconds") or 60 * 60)
|
|
428
|
+
return sess
|
|
429
|
+
|
|
430
|
+
@property
|
|
431
|
+
def onec_thin(self) -> str:
|
|
432
|
+
return str(self.platform_dir / os_support.executable_name("1cv8c"))
|
|
433
|
+
|
|
434
|
+
@property
|
|
435
|
+
def ibcmd(self) -> str:
|
|
436
|
+
return str(self.platform_dir / os_support.executable_name("ibcmd"))
|
|
437
|
+
|
|
438
|
+
@property
|
|
439
|
+
def ibsrv(self) -> str:
|
|
440
|
+
return str(self.platform_dir / os_support.executable_name("ibsrv"))
|
|
441
|
+
|
|
442
|
+
@property
|
|
443
|
+
def db_path(self) -> Path:
|
|
444
|
+
return self.data_dir / "ib"
|
|
445
|
+
|
|
446
|
+
@property
|
|
447
|
+
def server_data_dir(self) -> Path:
|
|
448
|
+
return self.data_dir / "ibsrv-data"
|
|
449
|
+
|
|
450
|
+
@property
|
|
451
|
+
def log_dir(self) -> Path:
|
|
452
|
+
return self.data_dir / "logs"
|
|
453
|
+
|
|
454
|
+
@property
|
|
455
|
+
def client_db_path(self) -> Path:
|
|
456
|
+
return self.data_dir / "ib-client"
|
|
457
|
+
|
|
458
|
+
@property
|
|
459
|
+
def client_server_data_dir(self) -> Path:
|
|
460
|
+
return self.data_dir / "ibsrv-data-client"
|
|
461
|
+
|
|
462
|
+
@property
|
|
463
|
+
def client_log_dir(self) -> Path:
|
|
464
|
+
return self.data_dir / "logs-client"
|
|
465
|
+
|
|
466
|
+
@property
|
|
467
|
+
def file_client_server_data_dir(self) -> Path:
|
|
468
|
+
return self.data_dir / "ibsrv-data-file-client"
|
|
469
|
+
|
|
470
|
+
@property
|
|
471
|
+
def file_client_log_dir(self) -> Path:
|
|
472
|
+
return self.data_dir / "logs-file-client"
|
|
473
|
+
|
|
474
|
+
|
|
475
|
+
_SESSIONS: dict[str, SessionState] = {}
|
|
476
|
+
|
|
477
|
+
|
|
478
|
+
def _pid_is_running(pid: int) -> bool:
|
|
479
|
+
if pid <= 0:
|
|
480
|
+
return False
|
|
481
|
+
try:
|
|
482
|
+
os.kill(pid, 0)
|
|
483
|
+
except ProcessLookupError:
|
|
484
|
+
return False
|
|
485
|
+
except PermissionError:
|
|
486
|
+
return True
|
|
487
|
+
return True
|
|
488
|
+
|
|
489
|
+
|
|
490
|
+
def _start_autoreload_watcher(dev: str = "", dev_restart_timeout: float = 30.0) -> None:
|
|
491
|
+
"""Start a detached dev autoreload watcher once per host.
|
|
492
|
+
|
|
493
|
+
The watcher must outlive the current stdio MCP process: `openclaw mcp reload`
|
|
494
|
+
disposes that process, and the next tool call starts a fresh one. A pid file
|
|
495
|
+
prevents every MCP start from creating another watcher.
|
|
496
|
+
"""
|
|
497
|
+
|
|
498
|
+
if dev != "openclaw":
|
|
499
|
+
logging.info("Answer42 autoreload watcher disabled: pass --dev openclaw to enable it")
|
|
500
|
+
return
|
|
501
|
+
|
|
502
|
+
if os.getenv("ONEC_MCP_AUTORELOAD", "1").lower() in {"0", "false", "no", "off"}:
|
|
503
|
+
logging.info("Answer42 autoreload watcher disabled by ONEC_MCP_AUTORELOAD")
|
|
504
|
+
return
|
|
505
|
+
|
|
506
|
+
repo_root = Path(__file__).resolve().parents[2]
|
|
507
|
+
watcher = repo_root / "scripts" / "openclaw_mcp_autoreload.py"
|
|
508
|
+
if not watcher.exists():
|
|
509
|
+
logging.warning("Autoreload watcher script not found: %s", watcher)
|
|
510
|
+
return
|
|
511
|
+
|
|
512
|
+
try:
|
|
513
|
+
if AUTORELOAD_PID_FILE.exists():
|
|
514
|
+
try:
|
|
515
|
+
pid = int(AUTORELOAD_PID_FILE.read_text(encoding="utf-8").strip())
|
|
516
|
+
except ValueError:
|
|
517
|
+
pid = 0
|
|
518
|
+
if _pid_is_running(pid):
|
|
519
|
+
logging.info("Autoreload watcher already running, pid=%s", pid)
|
|
520
|
+
return
|
|
521
|
+
AUTORELOAD_PID_FILE.unlink(missing_ok=True)
|
|
522
|
+
|
|
523
|
+
AUTORELOAD_PID_FILE.parent.mkdir(parents=True, exist_ok=True)
|
|
524
|
+
AUTORELOAD_LOG_FILE.parent.mkdir(parents=True, exist_ok=True)
|
|
525
|
+
with AUTORELOAD_LOG_FILE.open("ab") as log:
|
|
526
|
+
proc = subprocess.Popen(
|
|
527
|
+
[
|
|
528
|
+
sys.executable,
|
|
529
|
+
str(watcher),
|
|
530
|
+
"--root",
|
|
531
|
+
str(repo_root),
|
|
532
|
+
"--pid-file",
|
|
533
|
+
str(AUTORELOAD_PID_FILE),
|
|
534
|
+
"--dev-restart-timeout",
|
|
535
|
+
str(dev_restart_timeout),
|
|
536
|
+
],
|
|
537
|
+
cwd=str(repo_root),
|
|
538
|
+
stdin=subprocess.DEVNULL,
|
|
539
|
+
stdout=log,
|
|
540
|
+
stderr=subprocess.STDOUT,
|
|
541
|
+
**os_support.subprocess_creation_kwargs(),
|
|
542
|
+
)
|
|
543
|
+
AUTORELOAD_PID_FILE.write_text(str(proc.pid), encoding="utf-8")
|
|
544
|
+
logging.info("Started autoreload watcher pid=%s log=%s", proc.pid, AUTORELOAD_LOG_FILE)
|
|
545
|
+
except Exception as exc:
|
|
546
|
+
logging.warning("Could not start autoreload watcher: %s", exc)
|
|
547
|
+
|
|
548
|
+
|
|
549
|
+
def _persist_sessions() -> None:
|
|
550
|
+
STATE_FILE.parent.mkdir(parents=True, exist_ok=True)
|
|
551
|
+
STATE_FILE.write_text(
|
|
552
|
+
json.dumps({sid: sess.to_json() for sid, sess in _SESSIONS.items()}, ensure_ascii=False, indent=2),
|
|
553
|
+
encoding="utf-8",
|
|
554
|
+
)
|
|
555
|
+
|
|
556
|
+
|
|
557
|
+
def _source_newer_than(target: Path, src_dir: Path) -> bool:
|
|
558
|
+
if not target.exists():
|
|
559
|
+
return True
|
|
560
|
+
if not src_dir.exists():
|
|
561
|
+
return False
|
|
562
|
+
target_mtime = target.stat().st_mtime
|
|
563
|
+
for path in src_dir.rglob("*"):
|
|
564
|
+
if path.is_file() and path.stat().st_mtime > target_mtime:
|
|
565
|
+
return True
|
|
566
|
+
return False
|
|
567
|
+
|
|
568
|
+
|
|
569
|
+
def _packaged_cf_name(src_dir: str) -> str:
|
|
570
|
+
normalized = Path(src_dir).as_posix().rstrip("/")
|
|
571
|
+
if normalized.endswith("src/client_cf") or normalized == "src/client_cf":
|
|
572
|
+
return "MCPTestClient.cf"
|
|
573
|
+
return "MCPTestManager.cf"
|
|
574
|
+
|
|
575
|
+
|
|
576
|
+
def _copy_packaged_cf(target: Path, src_dir: str) -> bool:
|
|
577
|
+
cf_name = _packaged_cf_name(src_dir)
|
|
578
|
+
try:
|
|
579
|
+
resource = importlib.resources.files("mcp_1c.assets").joinpath(cf_name)
|
|
580
|
+
if not resource.is_file():
|
|
581
|
+
return False
|
|
582
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
583
|
+
with importlib.resources.as_file(resource) as path:
|
|
584
|
+
shutil.copy2(path, target)
|
|
585
|
+
logging.info("Copied packaged CF artifact %s to %s", cf_name, target)
|
|
586
|
+
return True
|
|
587
|
+
except Exception as exc:
|
|
588
|
+
logging.debug("Packaged CF artifact %s is not available: %s", cf_name, exc)
|
|
589
|
+
return False
|
|
590
|
+
|
|
591
|
+
|
|
592
|
+
def _ensure_cf_built(out_cf: str, *, src_dir: str, platform_dir: Path) -> str:
|
|
593
|
+
"""Build a .cf artifact when XML sources are available; otherwise copy packaged CF."""
|
|
594
|
+
target = Path(out_cf)
|
|
595
|
+
source = Path(src_dir)
|
|
596
|
+
if not _source_newer_than(target, source):
|
|
597
|
+
return str(target)
|
|
598
|
+
if not source.exists():
|
|
599
|
+
if _copy_packaged_cf(target, src_dir):
|
|
600
|
+
return str(target)
|
|
601
|
+
raise FileNotFoundError(f"CF source directory not found and no packaged CF is available: {source}")
|
|
602
|
+
script = Path(__file__).resolve().parents[2] / "scripts" / "build_cf.py"
|
|
603
|
+
if not script.exists():
|
|
604
|
+
if _copy_packaged_cf(target, src_dir):
|
|
605
|
+
return str(target)
|
|
606
|
+
raise FileNotFoundError(f"CF build script not found and no packaged CF is available: {script}")
|
|
607
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
608
|
+
env = os.environ.copy()
|
|
609
|
+
env["ONEC_PLATFORM_DIR"] = str(platform_dir)
|
|
610
|
+
env["PYTHONPATH"] = str(Path(__file__).resolve().parents[1])
|
|
611
|
+
logging.info("Building CF artifact %s from %s", target, source)
|
|
612
|
+
completed = subprocess.run(
|
|
613
|
+
[sys.executable, str(script), str(source), str(target)],
|
|
614
|
+
cwd=str(Path(__file__).resolve().parents[2]),
|
|
615
|
+
env=env,
|
|
616
|
+
text=True,
|
|
617
|
+
stdout=subprocess.PIPE,
|
|
618
|
+
stderr=subprocess.STDOUT,
|
|
619
|
+
timeout=900,
|
|
620
|
+
)
|
|
621
|
+
if completed.returncode != 0:
|
|
622
|
+
raise RuntimeError(
|
|
623
|
+
f"Failed to build CF artifact {target} from {source} "
|
|
624
|
+
f"(rc={completed.returncode}):\n{completed.stdout}"
|
|
625
|
+
)
|
|
626
|
+
return str(target)
|
|
627
|
+
|
|
628
|
+
|
|
629
|
+
def _normalize_base_url(url: str) -> str:
|
|
630
|
+
return _platform.normalize_infobase_url(str(url or "").strip())
|
|
631
|
+
|
|
632
|
+
|
|
633
|
+
def _load_rag_bindings() -> dict[str, str]:
|
|
634
|
+
if not RAG_BINDINGS_FILE.exists():
|
|
635
|
+
return {}
|
|
636
|
+
try:
|
|
637
|
+
raw = json.loads(RAG_BINDINGS_FILE.read_text(encoding="utf-8"))
|
|
638
|
+
return {str(k): str(v) for k, v in dict(raw).items() if k and v}
|
|
639
|
+
except Exception as exc:
|
|
640
|
+
logging.warning("Could not read RAG bindings %s: %s", RAG_BINDINGS_FILE, exc)
|
|
641
|
+
return {}
|
|
642
|
+
|
|
643
|
+
|
|
644
|
+
def _save_rag_binding(base_url: str, snapshot: str) -> None:
|
|
645
|
+
key = _normalize_base_url(base_url)
|
|
646
|
+
if not key or not snapshot:
|
|
647
|
+
return
|
|
648
|
+
data = _load_rag_bindings()
|
|
649
|
+
data[key] = snapshot
|
|
650
|
+
RAG_BINDINGS_FILE.parent.mkdir(parents=True, exist_ok=True)
|
|
651
|
+
RAG_BINDINGS_FILE.write_text(json.dumps(data, ensure_ascii=False, indent=2), encoding="utf-8")
|
|
652
|
+
|
|
653
|
+
|
|
654
|
+
def _snapshot_for_base_url(base_url: str) -> str:
|
|
655
|
+
return _load_rag_bindings().get(_normalize_base_url(base_url), "")
|
|
656
|
+
|
|
657
|
+
|
|
658
|
+
def _normalize_connection_mode(mode: str, value: str, base_url: str = "") -> str:
|
|
659
|
+
requested = (mode or "auto").strip().lower()
|
|
660
|
+
if requested in {"http", "url"}:
|
|
661
|
+
return "ws"
|
|
662
|
+
if requested in {"direct-file", "direct_file", "file-direct", "file_direct"}:
|
|
663
|
+
return "file-direct"
|
|
664
|
+
if requested in {"ws", "file", "server"}:
|
|
665
|
+
return requested
|
|
666
|
+
if requested not in {"", "auto"}:
|
|
667
|
+
raise ValueError(f"Unsupported connection_mode: {mode!r}. Expected auto, ws, file, file-direct or server.")
|
|
668
|
+
effective = (value or base_url or "").strip()
|
|
669
|
+
lowered = effective.lower()
|
|
670
|
+
if lowered.startswith(("http://", "https://")):
|
|
671
|
+
return "ws"
|
|
672
|
+
if effective and (Path(effective).is_absolute() or Path(effective, "1Cv8.1CD").exists() or Path(effective, "1cv8.1cd").exists()):
|
|
673
|
+
return "file"
|
|
674
|
+
if effective:
|
|
675
|
+
return "server"
|
|
676
|
+
return "ws"
|
|
677
|
+
|
|
678
|
+
|
|
679
|
+
def _touch_session(sess: SessionState) -> None:
|
|
680
|
+
sess.last_activity_monotonic = time.monotonic()
|
|
681
|
+
|
|
682
|
+
|
|
683
|
+
async def _idle_watch_session(session_id: str) -> None:
|
|
684
|
+
while True:
|
|
685
|
+
await asyncio.sleep(30)
|
|
686
|
+
sess = _SESSIONS.get(session_id)
|
|
687
|
+
if sess is None:
|
|
688
|
+
return
|
|
689
|
+
timeout = max(1, int(sess.idle_timeout_seconds or 60 * 60))
|
|
690
|
+
if time.monotonic() - sess.last_activity_monotonic >= timeout:
|
|
691
|
+
logging.info("Session %s idle for %ss; stopping", session_id, timeout)
|
|
692
|
+
try:
|
|
693
|
+
await _stop_session_impl(session_id, clean_data=False)
|
|
694
|
+
except Exception as exc:
|
|
695
|
+
logging.warning("idle stop_session failed for %s: %s", session_id, exc)
|
|
696
|
+
return
|
|
697
|
+
|
|
698
|
+
|
|
699
|
+
def _load_persisted_sessions() -> dict[str, SessionState]:
|
|
700
|
+
if not STATE_FILE.exists():
|
|
701
|
+
return {}
|
|
702
|
+
try:
|
|
703
|
+
raw = json.loads(STATE_FILE.read_text(encoding="utf-8"))
|
|
704
|
+
except Exception as exc:
|
|
705
|
+
logging.warning("Could not read session state %s: %s", STATE_FILE, exc)
|
|
706
|
+
return {}
|
|
707
|
+
result: dict[str, SessionState] = {}
|
|
708
|
+
for sid, data in raw.items():
|
|
709
|
+
try:
|
|
710
|
+
result[str(sid)] = SessionState.from_json(data)
|
|
711
|
+
except Exception as exc:
|
|
712
|
+
logging.warning("Could not restore session %s: %s", sid, exc)
|
|
713
|
+
return result
|
|
714
|
+
|
|
715
|
+
|
|
716
|
+
def _session_process_substrings(sess: SessionState) -> list[str]:
|
|
717
|
+
return [
|
|
718
|
+
str(sess.data_dir),
|
|
719
|
+
str(sess.server_data_dir),
|
|
720
|
+
str(sess.client_server_data_dir),
|
|
721
|
+
str(sess.file_client_server_data_dir),
|
|
722
|
+
sess.bridge.url,
|
|
723
|
+
f"-TPort{sess.testclient_port}",
|
|
724
|
+
f"-TPort {sess.testclient_port}",
|
|
725
|
+
f"TPort{sess.testclient_port}",
|
|
726
|
+
]
|
|
727
|
+
|
|
728
|
+
|
|
729
|
+
def _cleanup_stale_session_processes(sess: SessionState, reason: str) -> None:
|
|
730
|
+
logging.info("Cleaning stale 1C processes for session %s: %s", sess.session_id, reason)
|
|
731
|
+
os_support.kill_processes_by_command_substrings(
|
|
732
|
+
_session_process_substrings(sess),
|
|
733
|
+
f"Answer42 session {sess.session_id}",
|
|
734
|
+
logging.getLogger(__name__),
|
|
735
|
+
)
|
|
736
|
+
|
|
737
|
+
|
|
738
|
+
async def _cleanup_failed_start_session(sess: SessionState, reason: str) -> None:
|
|
739
|
+
logging.info("Cleaning failed start_session %s: %s", sess.session_id, reason)
|
|
740
|
+
try:
|
|
741
|
+
await sess.bridge.call("disconnect_test_client", {})
|
|
742
|
+
except Exception:
|
|
743
|
+
pass
|
|
744
|
+
runtime.kill_process(sess.test_client_process, "test-client")
|
|
745
|
+
sess.test_client_process = None
|
|
746
|
+
for rt, manager_label, server_label in (
|
|
747
|
+
(sess.runtime, "test-manager", "ibsrv"),
|
|
748
|
+
(sess.client_runtime, "test-manager-client", "ibsrv-client"),
|
|
749
|
+
(sess.file_client_runtime, "test-manager-file-client", "ibsrv-file-client"),
|
|
750
|
+
):
|
|
751
|
+
if rt is not None:
|
|
752
|
+
runtime.kill_process(rt.manager_process, manager_label)
|
|
753
|
+
runtime.kill_process(rt.server_process, server_label)
|
|
754
|
+
try:
|
|
755
|
+
await sess.bridge.stop()
|
|
756
|
+
except Exception:
|
|
757
|
+
pass
|
|
758
|
+
runtime.stop_xvfb(sess.xvfb_process)
|
|
759
|
+
_cleanup_stale_session_processes(sess, reason)
|
|
760
|
+
if sess.data_dir.exists():
|
|
761
|
+
shutil.rmtree(sess.data_dir, ignore_errors=True)
|
|
762
|
+
_SESSIONS.pop(sess.session_id, None)
|
|
763
|
+
_persist_sessions()
|
|
764
|
+
|
|
765
|
+
|
|
766
|
+
async def _ensure_restored_session(session_id: str = DEFAULT_SESSION) -> SessionState:
|
|
767
|
+
if session_id in _SESSIONS:
|
|
768
|
+
return _SESSIONS[session_id]
|
|
769
|
+
persisted = _load_persisted_sessions()
|
|
770
|
+
sess = persisted.get(session_id)
|
|
771
|
+
if sess is None:
|
|
772
|
+
raise ValueError(f"Session {session_id!r} not found. Call start_session first.")
|
|
773
|
+
await sess.bridge.start()
|
|
774
|
+
_SESSIONS[session_id] = sess
|
|
775
|
+
logging.info("Restored MCP session %s from %s; bridge=%s", session_id, STATE_FILE, sess.bridge.url)
|
|
776
|
+
return sess
|
|
777
|
+
|
|
778
|
+
|
|
779
|
+
async def _wait_for_bridge_connected(sess: SessionState, timeout: float = 45.0) -> dict[str, Any]:
|
|
780
|
+
deadline = time.monotonic() + timeout
|
|
781
|
+
while time.monotonic() < deadline:
|
|
782
|
+
status = sess.bridge.status()
|
|
783
|
+
if status.get("connected"):
|
|
784
|
+
return status
|
|
785
|
+
await asyncio.sleep(0.5)
|
|
786
|
+
status = sess.bridge.status()
|
|
787
|
+
raise RuntimeError(
|
|
788
|
+
f"1C bridge did not connect to {sess.bridge.url} within {timeout:g}s; "
|
|
789
|
+
f"status={status}. Check test-manager log: {sess.log_dir / 'test_manager.log'}"
|
|
790
|
+
)
|
|
791
|
+
|
|
792
|
+
|
|
793
|
+
def _session(session_id: str = DEFAULT_SESSION) -> SessionState:
|
|
794
|
+
try:
|
|
795
|
+
return _SESSIONS[session_id]
|
|
796
|
+
except KeyError:
|
|
797
|
+
raise ValueError(f"Session {session_id!r} not found. Call start_session first.")
|
|
798
|
+
|
|
799
|
+
|
|
800
|
+
def _new_session_id() -> str:
|
|
801
|
+
return "sess_" + uuid.uuid4().hex[:12]
|
|
802
|
+
|
|
803
|
+
|
|
804
|
+
def _record_tool(
|
|
805
|
+
session_id: str,
|
|
806
|
+
action: str,
|
|
807
|
+
params: dict[str, Any],
|
|
808
|
+
result: Any,
|
|
809
|
+
) -> Any:
|
|
810
|
+
sess = _session(session_id)
|
|
811
|
+
_touch_session(sess)
|
|
812
|
+
text_only = action.startswith("rag_")
|
|
813
|
+
try:
|
|
814
|
+
sess.recorder.capture(
|
|
815
|
+
lambda path, window, display: _screenshot_for_recorder(path, window, display, session_id),
|
|
816
|
+
action, params, result, screenshot=not text_only,
|
|
817
|
+
)
|
|
818
|
+
except Exception as exc:
|
|
819
|
+
logging.debug("recording capture failed for %s/%s: %s", session_id, action, exc)
|
|
820
|
+
return result
|
|
821
|
+
|
|
822
|
+
|
|
823
|
+
def _norm_key(value: Any) -> str:
|
|
824
|
+
return str(value or "").strip().casefold()
|
|
825
|
+
|
|
826
|
+
|
|
827
|
+
def _walk_ui_tree(node: Any):
|
|
828
|
+
if not isinstance(node, dict):
|
|
829
|
+
return
|
|
830
|
+
yield node
|
|
831
|
+
for child in node.get("children") or []:
|
|
832
|
+
yield from _walk_ui_tree(child)
|
|
833
|
+
|
|
834
|
+
|
|
835
|
+
def _metadata_name_from_navigation_url(url: str) -> str:
|
|
836
|
+
"""Best-effort conversion from e1cib/list/... to a metadata full name."""
|
|
837
|
+
value = str(url or "")
|
|
838
|
+
for prefix in ("e1cib/list/", "e1cib/data/", "e1cib/command/"):
|
|
839
|
+
if value.startswith(prefix):
|
|
840
|
+
value = value[len(prefix):]
|
|
841
|
+
break
|
|
842
|
+
value = value.split("?", 1)[0]
|
|
843
|
+
ru_to_en = {
|
|
844
|
+
"Справочник.": "Catalog.",
|
|
845
|
+
"Документ.": "Document.",
|
|
846
|
+
"РегистрСведений.": "InformationRegister.",
|
|
847
|
+
"РегистрНакопления.": "AccumulationRegister.",
|
|
848
|
+
"РегистрБухгалтерии.": "AccountingRegister.",
|
|
849
|
+
"РегистрРасчета.": "CalculationRegister.",
|
|
850
|
+
"ПланВидовРасчета.": "ChartOfCalculationTypes.",
|
|
851
|
+
"ПланСчетов.": "ChartOfAccounts.",
|
|
852
|
+
"ПланВидовХарактеристик.": "ChartOfCharacteristicTypes.",
|
|
853
|
+
"ПланОбмена.": "ExchangePlan.",
|
|
854
|
+
"БизнесПроцесс.": "BusinessProcess.",
|
|
855
|
+
"Задача.": "Task.",
|
|
856
|
+
"Отчет.": "Report.",
|
|
857
|
+
"Обработка.": "DataProcessor.",
|
|
858
|
+
"Перечисление.": "Enum.",
|
|
859
|
+
"Константа.": "Constant.",
|
|
860
|
+
"ЖурналДокументов.": "DocumentJournal.",
|
|
861
|
+
"Последовательность.": "Sequence.",
|
|
862
|
+
"КритерийОтбора.": "FilterCriterion.",
|
|
863
|
+
"Нумератор.": "NumberingPrefix.",
|
|
864
|
+
"Подсистема.": "Subsystem.",
|
|
865
|
+
"ОбщаяКоманда.": "CommonCommand.",
|
|
866
|
+
"ГруппаКоманд.": "CommandGroup.",
|
|
867
|
+
"ХранилищеНастроек.": "SettingsStorage.",
|
|
868
|
+
"Роль.": "Role.",
|
|
869
|
+
"РегламентноеЗадание.": "ScheduledJob.",
|
|
870
|
+
"ОпределяемыйТип.": "DefinedType.",
|
|
871
|
+
"ОбщийМодуль.": "CommonModule.",
|
|
872
|
+
"ОбщаяФорма.": "CommonForm.",
|
|
873
|
+
"ОбщийМакет.": "CommonTemplate.",
|
|
874
|
+
"ОбщаяКартинка.": "CommonPicture.",
|
|
875
|
+
"ПараметрСеанса.": "SessionParameter.",
|
|
876
|
+
"ПодпискаНаСобытие.": "EventSubscription.",
|
|
877
|
+
"ФункциональнаяОпция.": "FunctionalOption.",
|
|
878
|
+
"ПараметрФункциональныхОпций.": "FunctionalOptionParameter.",
|
|
879
|
+
"Интерфейс.": "Interface.",
|
|
880
|
+
"Стиль.": "Style.",
|
|
881
|
+
"Язык.": "Language.",
|
|
882
|
+
"ПакетXDTO.": "XDTOPackage.",
|
|
883
|
+
"WebСервис.": "WebService.",
|
|
884
|
+
"WSСсылка.": "WSReference.",
|
|
885
|
+
"HTTPСервис.": "HTTPService.",
|
|
886
|
+
"СервисИнтеграции.": "IntegrationService.",
|
|
887
|
+
"ВнешнийИсточникДанных.": "ExternalDataSource.",
|
|
888
|
+
"Бот.": "Bot.",
|
|
889
|
+
"WebSocketКлиент.": "WebSocketClient.",
|
|
890
|
+
"ЦветПалитры.": "PaletteColor.",
|
|
891
|
+
"ЭлементСтиля.": "StyleElement.",
|
|
892
|
+
"ОбщийРеквизит.": "CommonAttribute.",
|
|
893
|
+
}
|
|
894
|
+
for ru, en in ru_to_en.items():
|
|
895
|
+
if value.startswith(ru):
|
|
896
|
+
return en + value[len(ru):]
|
|
897
|
+
return value
|
|
898
|
+
|
|
899
|
+
|
|
900
|
+
def _metadata_name_to_rag_name(value: str) -> str:
|
|
901
|
+
"""Convert runtime/e1cib English metadata names to Russian names used by RAG."""
|
|
902
|
+
text = str(value or "")
|
|
903
|
+
en_to_ru = {
|
|
904
|
+
"Catalog.": "Справочник.",
|
|
905
|
+
"Document.": "Документ.",
|
|
906
|
+
"InformationRegister.": "РегистрСведений.",
|
|
907
|
+
"AccumulationRegister.": "РегистрНакопления.",
|
|
908
|
+
"AccountingRegister.": "РегистрБухгалтерии.",
|
|
909
|
+
"CalculationRegister.": "РегистрРасчета.",
|
|
910
|
+
"ChartOfCalculationTypes.": "ПланВидовРасчета.",
|
|
911
|
+
"ChartOfAccounts.": "ПланСчетов.",
|
|
912
|
+
"ChartOfCharacteristicTypes.": "ПланВидовХарактеристик.",
|
|
913
|
+
"ExchangePlan.": "ПланОбмена.",
|
|
914
|
+
"BusinessProcess.": "БизнесПроцесс.",
|
|
915
|
+
"Task.": "Задача.",
|
|
916
|
+
"Report.": "Отчет.",
|
|
917
|
+
"DataProcessor.": "Обработка.",
|
|
918
|
+
"Enum.": "Перечисление.",
|
|
919
|
+
"Constant.": "Константа.",
|
|
920
|
+
"DocumentJournal.": "ЖурналДокументов.",
|
|
921
|
+
"Sequence.": "Последовательность.",
|
|
922
|
+
"FilterCriterion.": "КритерийОтбора.",
|
|
923
|
+
"NumberingPrefix.": "Нумератор.",
|
|
924
|
+
"Subsystem.": "Подсистема.",
|
|
925
|
+
"CommonCommand.": "ОбщаяКоманда.",
|
|
926
|
+
"CommandGroup.": "ГруппаКоманд.",
|
|
927
|
+
"SettingsStorage.": "ХранилищеНастроек.",
|
|
928
|
+
"Role.": "Роль.",
|
|
929
|
+
"ScheduledJob.": "РегламентноеЗадание.",
|
|
930
|
+
"DefinedType.": "ОпределяемыйТип.",
|
|
931
|
+
"CommonModule.": "ОбщийМодуль.",
|
|
932
|
+
"CommonForm.": "ОбщаяФорма.",
|
|
933
|
+
"CommonTemplate.": "ОбщийМакет.",
|
|
934
|
+
"CommonPicture.": "ОбщаяКартинка.",
|
|
935
|
+
"SessionParameter.": "ПараметрСеанса.",
|
|
936
|
+
"EventSubscription.": "ПодпискаНаСобытие.",
|
|
937
|
+
"FunctionalOption.": "ФункциональнаяОпция.",
|
|
938
|
+
"FunctionalOptionParameter.": "ПараметрФункциональныхОпций.",
|
|
939
|
+
"Interface.": "Интерфейс.",
|
|
940
|
+
"Style.": "Стиль.",
|
|
941
|
+
"Language.": "Язык.",
|
|
942
|
+
"XDTOPackage.": "ПакетXDTO.",
|
|
943
|
+
"WebService.": "WebСервис.",
|
|
944
|
+
"WSReference.": "WSСсылка.",
|
|
945
|
+
"HTTPService.": "HTTPСервис.",
|
|
946
|
+
"IntegrationService.": "СервисИнтеграции.",
|
|
947
|
+
"ExternalDataSource.": "ВнешнийИсточникДанных.",
|
|
948
|
+
"Bot.": "Бот.",
|
|
949
|
+
"WebSocketClient.": "WebSocketКлиент.",
|
|
950
|
+
"PaletteColor.": "ЦветПалитры.",
|
|
951
|
+
"StyleElement.": "ЭлементСтиля.",
|
|
952
|
+
"CommonAttribute.": "ОбщийРеквизит.",
|
|
953
|
+
}
|
|
954
|
+
for en, ru in en_to_ru.items():
|
|
955
|
+
if text.startswith(en):
|
|
956
|
+
return ru + text[len(en):]
|
|
957
|
+
return text
|
|
958
|
+
|
|
959
|
+
|
|
960
|
+
def _rag_form_element_index(snapshot: str | int | None = None) -> dict[str, list[dict[str, Any]]]:
|
|
961
|
+
try:
|
|
962
|
+
items = _RAG.lookup_form_element_types(snapshot=snapshot).get("items", [])
|
|
963
|
+
except Exception as exc:
|
|
964
|
+
logging.debug("RAG form element lookup failed: %s", exc)
|
|
965
|
+
return {}
|
|
966
|
+
index: dict[str, list[dict[str, Any]]] = {}
|
|
967
|
+
for item in items:
|
|
968
|
+
for key in (item.get("name"), item.get("title"), item.get("data_path"), item.get("command_name")):
|
|
969
|
+
nk = _norm_key(key)
|
|
970
|
+
if nk:
|
|
971
|
+
index.setdefault(nk, []).append(item)
|
|
972
|
+
return index
|
|
973
|
+
|
|
974
|
+
|
|
975
|
+
def _pick_rag_element(ui_node: dict[str, Any], candidates: list[dict[str, Any]], active_object: str = "") -> dict[str, Any] | None:
|
|
976
|
+
if not candidates:
|
|
977
|
+
return None
|
|
978
|
+
active_norm = _norm_key(active_object)
|
|
979
|
+
form_name = _norm_key(ui_node.get("form_name"))
|
|
980
|
+
|
|
981
|
+
def score(item: dict[str, Any]) -> int:
|
|
982
|
+
value = 0
|
|
983
|
+
if active_norm and active_norm in _norm_key(item.get("object_full_name")):
|
|
984
|
+
value += 20
|
|
985
|
+
if form_name and form_name == _norm_key(item.get("form_name")):
|
|
986
|
+
value += 5
|
|
987
|
+
if _norm_key(ui_node.get("name")) == _norm_key(item.get("name")):
|
|
988
|
+
value += 3
|
|
989
|
+
if _norm_key(ui_node.get("title")) == _norm_key(item.get("title")):
|
|
990
|
+
value += 2
|
|
991
|
+
return value
|
|
992
|
+
|
|
993
|
+
return sorted(candidates, key=score, reverse=True)[0]
|
|
994
|
+
|
|
995
|
+
|
|
996
|
+
def _enrich_ui_tree_with_rag_types(tree: Any, snapshot: str | int | None = None) -> Any:
|
|
997
|
+
if not isinstance(tree, dict):
|
|
998
|
+
return tree
|
|
999
|
+
index = _rag_form_element_index(snapshot=snapshot)
|
|
1000
|
+
if not index:
|
|
1001
|
+
return tree
|
|
1002
|
+
active_object = _metadata_name_from_navigation_url(str(tree.get("navigation_url") or ""))
|
|
1003
|
+
for node in _walk_ui_tree(tree):
|
|
1004
|
+
keys = [_norm_key(node.get("name")), _norm_key(node.get("title")), _norm_key(node.get("data_path"))]
|
|
1005
|
+
candidates: list[dict[str, Any]] = []
|
|
1006
|
+
seen: set[int] = set()
|
|
1007
|
+
for key in keys:
|
|
1008
|
+
for item in index.get(key, []):
|
|
1009
|
+
ident = id(item)
|
|
1010
|
+
if ident not in seen:
|
|
1011
|
+
candidates.append(item)
|
|
1012
|
+
seen.add(ident)
|
|
1013
|
+
item = _pick_rag_element(node, candidates, active_object)
|
|
1014
|
+
if item is None:
|
|
1015
|
+
continue
|
|
1016
|
+
value_type = item.get("attribute_type") or item.get("dcs_field_type") or item.get("element_type")
|
|
1017
|
+
node["rag_type"] = value_type
|
|
1018
|
+
node["rag"] = {
|
|
1019
|
+
"object": item.get("object_full_name"),
|
|
1020
|
+
"form": item.get("form_name"),
|
|
1021
|
+
"element_type": item.get("element_type"),
|
|
1022
|
+
"data_path": item.get("data_path"),
|
|
1023
|
+
"attribute_type": item.get("attribute_type"),
|
|
1024
|
+
"attribute_role": item.get("attribute_role"),
|
|
1025
|
+
"dcs_field_type": item.get("dcs_field_type"),
|
|
1026
|
+
"source_path": item.get("source_path"),
|
|
1027
|
+
}
|
|
1028
|
+
return tree
|
|
1029
|
+
|
|
1030
|
+
|
|
1031
|
+
def _standard_attributes_for_kind(kind: str) -> list[dict[str, str]]:
|
|
1032
|
+
"""Return standard (auto-generated) attributes for a metadata object kind.
|
|
1033
|
+
|
|
1034
|
+
1C automatically generates standard attributes for every object type.
|
|
1035
|
+
These are always available in dynamic lists and auto-generated forms.
|
|
1036
|
+
"""
|
|
1037
|
+
kind = str(kind or "").lower()
|
|
1038
|
+
common = [
|
|
1039
|
+
{"name": "Ссылка", "title": "Ссылка", "type": "Ref", "role": "standard"},
|
|
1040
|
+
{"name": "ПометкаУдаления", "title": "ПометкаУдаления", "type": "Boolean", "role": "standard"},
|
|
1041
|
+
]
|
|
1042
|
+
if kind in ("справочник", "catalog"):
|
|
1043
|
+
return common + [
|
|
1044
|
+
{"name": "Код", "title": "Код", "type": "String|Number", "role": "standard"},
|
|
1045
|
+
{"name": "Наименование", "title": "Наименование", "type": "String", "role": "standard"},
|
|
1046
|
+
{"name": "Предопределенный", "title": "Предопределенный", "type": "Boolean", "role": "standard"},
|
|
1047
|
+
{"name": "ИмяПредопределенныхДанных", "title": "ИмяПредопределенныхДанных", "type": "String", "role": "standard"},
|
|
1048
|
+
]
|
|
1049
|
+
if kind in ("документ", "document"):
|
|
1050
|
+
return common + [
|
|
1051
|
+
{"name": "Номер", "title": "Номер", "type": "String|Number", "role": "standard"},
|
|
1052
|
+
{"name": "Дата", "title": "Дата", "type": "Date", "role": "standard"},
|
|
1053
|
+
{"name": "Проведен", "title": "Проведен", "type": "Boolean", "role": "standard"},
|
|
1054
|
+
]
|
|
1055
|
+
if kind in ("регистрсведений", "informationregister"):
|
|
1056
|
+
return common + [
|
|
1057
|
+
{"name": "Период", "title": "Период", "type": "Date", "role": "standard"},
|
|
1058
|
+
]
|
|
1059
|
+
if kind in ("регистрнакопления", "accumulationregister"):
|
|
1060
|
+
return common + [
|
|
1061
|
+
{"name": "Период", "title": "Период", "type": "Date", "role": "standard"},
|
|
1062
|
+
]
|
|
1063
|
+
return common
|
|
1064
|
+
|
|
1065
|
+
|
|
1066
|
+
def _rag_fields_for_object(object_full_name: str, snapshot: str | int | None = None) -> list[dict[str, Any]]:
|
|
1067
|
+
if not object_full_name:
|
|
1068
|
+
return []
|
|
1069
|
+
rag_name = _metadata_name_to_rag_name(object_full_name)
|
|
1070
|
+
try:
|
|
1071
|
+
matches = _RAG.lookup_object(object=rag_name, snapshot=snapshot).get("matches", [])
|
|
1072
|
+
except Exception as exc:
|
|
1073
|
+
logging.debug("RAG object lookup failed for %s (%s): %s", object_full_name, rag_name, exc)
|
|
1074
|
+
return []
|
|
1075
|
+
fields: list[dict[str, Any]] = []
|
|
1076
|
+
for match in matches:
|
|
1077
|
+
kind = match.get("kind", "")
|
|
1078
|
+
for std in _standard_attributes_for_kind(kind):
|
|
1079
|
+
fields.append({
|
|
1080
|
+
"name": std["name"],
|
|
1081
|
+
"title": std["title"],
|
|
1082
|
+
"type": std["type"],
|
|
1083
|
+
"role": std["role"],
|
|
1084
|
+
"source": "rag.standard_attribute",
|
|
1085
|
+
"object": match.get("full_name"),
|
|
1086
|
+
"filter_supported": True,
|
|
1087
|
+
"order_supported": True,
|
|
1088
|
+
})
|
|
1089
|
+
for attr in match.get("attributes", []) or []:
|
|
1090
|
+
fields.append({
|
|
1091
|
+
"name": attr.get("name"),
|
|
1092
|
+
"title": attr.get("synonym") or attr.get("name"),
|
|
1093
|
+
"type": attr.get("type_name"),
|
|
1094
|
+
"role": attr.get("role"),
|
|
1095
|
+
"source": "rag.attribute",
|
|
1096
|
+
"object": match.get("full_name"),
|
|
1097
|
+
"filter_supported": True,
|
|
1098
|
+
"order_supported": True,
|
|
1099
|
+
})
|
|
1100
|
+
for dcf in match.get("data_composition_fields", []) or []:
|
|
1101
|
+
fields.append({
|
|
1102
|
+
"name": dcf.get("field") or dcf.get("data_path"),
|
|
1103
|
+
"title": dcf.get("title") or dcf.get("field") or dcf.get("data_path"),
|
|
1104
|
+
"type": dcf.get("field_type"),
|
|
1105
|
+
"data_path": dcf.get("data_path"),
|
|
1106
|
+
"expression": dcf.get("expression"),
|
|
1107
|
+
"source": "rag.dcs_field",
|
|
1108
|
+
"object": match.get("full_name"),
|
|
1109
|
+
"filter_supported": True,
|
|
1110
|
+
"order_supported": True,
|
|
1111
|
+
})
|
|
1112
|
+
dedup: dict[tuple[str, str], dict[str, Any]] = {}
|
|
1113
|
+
for item in fields:
|
|
1114
|
+
key = (_norm_key(item.get("name")), _norm_key(item.get("title")))
|
|
1115
|
+
dedup.setdefault(key, item)
|
|
1116
|
+
return list(dedup.values())
|
|
1117
|
+
|
|
1118
|
+
|
|
1119
|
+
async def _bridge_call_recorded(
|
|
1120
|
+
session_id: str,
|
|
1121
|
+
method: str,
|
|
1122
|
+
params: dict[str, Any],
|
|
1123
|
+
) -> Any:
|
|
1124
|
+
sess = await _ensure_restored_session(session_id)
|
|
1125
|
+
await _wait_for_bridge_connected(sess, timeout=60.0)
|
|
1126
|
+
_touch_session(sess)
|
|
1127
|
+
result = await sess.bridge.call(method, params)
|
|
1128
|
+
return _record_tool(session_id, method, params, result)
|
|
1129
|
+
|
|
1130
|
+
|
|
1131
|
+
def _screenshot_for_recorder(
|
|
1132
|
+
path: str,
|
|
1133
|
+
window: bool,
|
|
1134
|
+
display: str,
|
|
1135
|
+
session_id: str = DEFAULT_SESSION,
|
|
1136
|
+
) -> dict[str, Any]:
|
|
1137
|
+
return screenshot(path=path, window=window, display=display, session_id=session_id)
|
|
1138
|
+
|
|
1139
|
+
|
|
1140
|
+
def _resolve_runtime(
|
|
1141
|
+
platform_dir: Path,
|
|
1142
|
+
db_path: Path,
|
|
1143
|
+
ibsrv_address: str,
|
|
1144
|
+
ibsrv_port: int,
|
|
1145
|
+
ibsrv_name: str,
|
|
1146
|
+
server_data_dir: Path,
|
|
1147
|
+
) -> runtime.ManagerRuntime:
|
|
1148
|
+
return runtime.ManagerRuntime(
|
|
1149
|
+
infobase=runtime.InfobaseSpec(db_path=db_path),
|
|
1150
|
+
server=runtime.StandaloneServerSpec(
|
|
1151
|
+
address=ibsrv_address,
|
|
1152
|
+
port=ibsrv_port,
|
|
1153
|
+
name=ibsrv_name,
|
|
1154
|
+
data_dir=server_data_dir,
|
|
1155
|
+
),
|
|
1156
|
+
manager=runtime.TestManagerSpec(
|
|
1157
|
+
onec_path=str(platform_dir / os_support.executable_name("1cv8c")),
|
|
1158
|
+
),
|
|
1159
|
+
)
|
|
1160
|
+
|
|
1161
|
+
|
|
1162
|
+
# ---------------------------------------------------------------------------
|
|
1163
|
+
# 1C configuration RAG / metadata index tools
|
|
1164
|
+
# ---------------------------------------------------------------------------# ---------------------------------------------------------------------------
|
|
1165
|
+
# 1C configuration RAG / metadata index tools
|
|
1166
|
+
# ---------------------------------------------------------------------------
|
|
1167
|
+
|
|
1168
|
+
|
|
1169
|
+
@mcp.tool()
|
|
1170
|
+
def rag_source_add(
|
|
1171
|
+
name: Annotated[str, "Logical source name, e.g. Configuration or Extension1"],
|
|
1172
|
+
path: Annotated[str, "Path to 1C source directory"],
|
|
1173
|
+
kind: Annotated[str, "base | extension | configuration | standalone"] = "standalone",
|
|
1174
|
+
format: Annotated[str, "auto | edt | designer_xml"] = "auto",
|
|
1175
|
+
*,
|
|
1176
|
+
session_id: Annotated[str, "Session id"] = DEFAULT_SESSION,
|
|
1177
|
+
) -> dict[str, Any]:
|
|
1178
|
+
"""Register/update a 1C source tree; format is detected automatically when format=auto."""
|
|
1179
|
+
detected = _RAG.detect_source(path) if format == "auto" else {"format": format}
|
|
1180
|
+
effective_format = str(detected.get("format") or format)
|
|
1181
|
+
result = _RAG.add_source(name=name, path=path, kind=kind, format=effective_format)
|
|
1182
|
+
if isinstance(result, dict):
|
|
1183
|
+
result["detected"] = detected
|
|
1184
|
+
return _record_tool(session_id, "rag_source_add", {"name": name, "path": path, "kind": kind, "format": format}, result)
|
|
1185
|
+
|
|
1186
|
+
|
|
1187
|
+
@mcp.tool()
|
|
1188
|
+
def rag_sources_list(*,
|
|
1189
|
+
session_id: Annotated[str, "Session id"] = DEFAULT_SESSION,
|
|
1190
|
+
) -> list[dict[str, Any]]:
|
|
1191
|
+
"""List registered 1C source trees."""
|
|
1192
|
+
return _record_tool(session_id, "rag_sources_list", {}, _RAG.list_sources())
|
|
1193
|
+
|
|
1194
|
+
|
|
1195
|
+
@mcp.tool()
|
|
1196
|
+
def rag_snapshot_create(
|
|
1197
|
+
name: Annotated[str, "Snapshot name, e.g. MS+Platform42"],
|
|
1198
|
+
base: Annotated[str | None, "Base source name"] = None,
|
|
1199
|
+
extensions: Annotated[list[str] | None, "Extension source names in layer order"] = None,
|
|
1200
|
+
sources: Annotated[list[str] | None, "Standalone ordered source names"] = None,
|
|
1201
|
+
*,
|
|
1202
|
+
session_id: Annotated[str, "Session id"] = DEFAULT_SESSION,
|
|
1203
|
+
) -> dict[str, Any]:
|
|
1204
|
+
"""Create/update an effective configuration snapshot from base + extensions."""
|
|
1205
|
+
result = _RAG.create_snapshot(name=name, base=base, extensions=extensions, sources=sources)
|
|
1206
|
+
sess = _session(session_id)
|
|
1207
|
+
sess.rag_snapshot = str(result.get("name") or name)
|
|
1208
|
+
if sess.base_url:
|
|
1209
|
+
_save_rag_binding(sess.base_url, sess.rag_snapshot)
|
|
1210
|
+
_persist_sessions()
|
|
1211
|
+
return result
|
|
1212
|
+
|
|
1213
|
+
|
|
1214
|
+
@mcp.tool()
|
|
1215
|
+
def rag_snapshots_list(*,
|
|
1216
|
+
session_id: Annotated[str, "Session id"] = DEFAULT_SESSION,
|
|
1217
|
+
) -> list[dict[str, Any]]:
|
|
1218
|
+
"""List effective RAG snapshots and their layers."""
|
|
1219
|
+
return _record_tool(session_id, "rag_snapshots_list", {}, _RAG.list_snapshots())
|
|
1220
|
+
|
|
1221
|
+
|
|
1222
|
+
@mcp.tool()
|
|
1223
|
+
def rag_index_build(
|
|
1224
|
+
snapshot: Annotated[str | None, "Snapshot name/id to index"] = None,
|
|
1225
|
+
source: Annotated[str | None, "Single source name to index"] = None,
|
|
1226
|
+
*,
|
|
1227
|
+
session_id: Annotated[str, "Session id"] = DEFAULT_SESSION,
|
|
1228
|
+
) -> dict[str, Any]:
|
|
1229
|
+
"""Build/rebuild the SQLite/FTS RAG index for a source or snapshot."""
|
|
1230
|
+
sess = _session(session_id)
|
|
1231
|
+
effective_snapshot = snapshot or sess.rag_snapshot or None
|
|
1232
|
+
if effective_snapshot:
|
|
1233
|
+
sess.rag_snapshot = str(effective_snapshot)
|
|
1234
|
+
if sess.base_url:
|
|
1235
|
+
_save_rag_binding(sess.base_url, sess.rag_snapshot)
|
|
1236
|
+
_persist_sessions()
|
|
1237
|
+
return _record_tool(session_id, "rag_index_build", {"snapshot": effective_snapshot, "source": source}, _RAG.build_index(snapshot=effective_snapshot, source=source))
|
|
1238
|
+
|
|
1239
|
+
|
|
1240
|
+
@mcp.tool()
|
|
1241
|
+
def rag_lookup_object(
|
|
1242
|
+
object: Annotated[str, "1C object full name or metadata name"],
|
|
1243
|
+
snapshot: Annotated[str | None, "Optional snapshot name/id"] = None,
|
|
1244
|
+
*,
|
|
1245
|
+
session_id: Annotated[str, "Session id"] = DEFAULT_SESSION,
|
|
1246
|
+
) -> dict[str, Any]:
|
|
1247
|
+
"""Look up an indexed 1C metadata object, including navigation, fields and forms."""
|
|
1248
|
+
sess = _session(session_id)
|
|
1249
|
+
effective_snapshot = snapshot or sess.rag_snapshot or None
|
|
1250
|
+
return _record_tool(session_id, "rag_lookup_object", {"object": object, "snapshot": effective_snapshot}, _RAG.lookup_object(object=object, snapshot=effective_snapshot))
|
|
1251
|
+
|
|
1252
|
+
|
|
1253
|
+
@mcp.tool()
|
|
1254
|
+
def rag_query(
|
|
1255
|
+
query: Annotated[str, "Natural-language or metadata search query"],
|
|
1256
|
+
snapshot: Annotated[str | None, "Optional snapshot name/id"] = None,
|
|
1257
|
+
limit: Annotated[int, "Max result count"] = 10,
|
|
1258
|
+
*,
|
|
1259
|
+
session_id: Annotated[str, "Session id"] = DEFAULT_SESSION,
|
|
1260
|
+
) -> dict[str, Any]:
|
|
1261
|
+
"""Search business terms and full-text RAG chunks in the local SQLite index."""
|
|
1262
|
+
sess = _session(session_id)
|
|
1263
|
+
effective_snapshot = snapshot or sess.rag_snapshot or None
|
|
1264
|
+
return _record_tool(session_id, "rag_query", {"query": query, "snapshot": effective_snapshot, "limit": limit}, _RAG.query(text=query, snapshot=effective_snapshot, limit=limit))
|
|
1265
|
+
|
|
1266
|
+
|
|
1267
|
+
@mcp.tool()
|
|
1268
|
+
def sessions_list() -> dict[str, Any]:
|
|
1269
|
+
"""List active/restorable Answer42 sessions and their connection state."""
|
|
1270
|
+
persisted = _load_persisted_sessions()
|
|
1271
|
+
session_ids = sorted(set(persisted) | set(_SESSIONS))
|
|
1272
|
+
items: list[dict[str, Any]] = []
|
|
1273
|
+
for sid in session_ids:
|
|
1274
|
+
sess = _SESSIONS.get(sid) or persisted.get(sid)
|
|
1275
|
+
if sess is None:
|
|
1276
|
+
continue
|
|
1277
|
+
rt = sess.runtime
|
|
1278
|
+
client_rt = sess.client_runtime
|
|
1279
|
+
file_client_rt = sess.file_client_runtime
|
|
1280
|
+
bridge_status = sess.bridge.status()
|
|
1281
|
+
items.append({
|
|
1282
|
+
"session_id": sid,
|
|
1283
|
+
"running": sid in _SESSIONS,
|
|
1284
|
+
"base_url": sess.base_url,
|
|
1285
|
+
"version": _platform.platform_version_from_dir(sess.platform_dir),
|
|
1286
|
+
"display": sess.display,
|
|
1287
|
+
"ws_port": sess.ws_port,
|
|
1288
|
+
"bridge_connected": bool(bridge_status.get("connected")),
|
|
1289
|
+
"manager_alive": bool(rt and rt.manager_process and rt.manager_process.poll() is None),
|
|
1290
|
+
"ibsrv_alive": bool(rt and rt.server_process and rt.server_process.poll() is None),
|
|
1291
|
+
"testclient_port": sess.testclient_port,
|
|
1292
|
+
"client_ibsrv_port": sess.client_ibsrv_port,
|
|
1293
|
+
"client_ibsrv_alive": bool(client_rt and (
|
|
1294
|
+
(client_rt.server_process and client_rt.server_process.poll() is None)
|
|
1295
|
+
or (not client_rt.server_process and runtime.is_port_listening(client_rt.server.address, client_rt.server.port))
|
|
1296
|
+
)),
|
|
1297
|
+
"file_client_ibsrv_port": sess.file_client_ibsrv_port,
|
|
1298
|
+
"file_client_ibsrv_alive": bool(file_client_rt and (
|
|
1299
|
+
(file_client_rt.server_process and file_client_rt.server_process.poll() is None)
|
|
1300
|
+
or (not file_client_rt.server_process and runtime.is_port_listening(file_client_rt.server.address, file_client_rt.server.port))
|
|
1301
|
+
)),
|
|
1302
|
+
"rag_snapshot": sess.rag_snapshot,
|
|
1303
|
+
"idle_timeout_seconds": sess.idle_timeout_seconds,
|
|
1304
|
+
})
|
|
1305
|
+
return {"count": len(items), "sessions": items}
|
|
1306
|
+
|
|
1307
|
+
|
|
1308
|
+
@mcp.tool()
|
|
1309
|
+
def session_status(*,
|
|
1310
|
+
session_id: Annotated[str, "Session id"] = DEFAULT_SESSION,
|
|
1311
|
+
) -> dict[str, Any]:
|
|
1312
|
+
"""Return bridge, runtime, test-client and RAG state for a session."""
|
|
1313
|
+
sess = _SESSIONS.get(session_id) or _load_persisted_sessions().get(session_id)
|
|
1314
|
+
if sess is not None:
|
|
1315
|
+
_touch_session(sess)
|
|
1316
|
+
if sess is None:
|
|
1317
|
+
return {"running": False, "session_id": session_id, "reason": "session not found"}
|
|
1318
|
+
bridge = sess.bridge.status()
|
|
1319
|
+
rt = sess.runtime
|
|
1320
|
+
client_rt = sess.client_runtime
|
|
1321
|
+
file_client_rt = sess.file_client_runtime
|
|
1322
|
+
client_server_alive = False
|
|
1323
|
+
if client_rt:
|
|
1324
|
+
if client_rt.server_process:
|
|
1325
|
+
client_server_alive = client_rt.server_process.poll() is None
|
|
1326
|
+
else:
|
|
1327
|
+
client_server_alive = runtime.is_port_listening(client_rt.server.address, client_rt.server.port)
|
|
1328
|
+
file_client_server_alive = False
|
|
1329
|
+
if file_client_rt:
|
|
1330
|
+
if file_client_rt.server_process:
|
|
1331
|
+
file_client_server_alive = file_client_rt.server_process.poll() is None
|
|
1332
|
+
else:
|
|
1333
|
+
file_client_server_alive = runtime.is_port_listening(file_client_rt.server.address, file_client_rt.server.port)
|
|
1334
|
+
return {
|
|
1335
|
+
"running": True,
|
|
1336
|
+
"session_id": session_id,
|
|
1337
|
+
"base_url": sess.base_url,
|
|
1338
|
+
"version": _platform.platform_version_from_dir(sess.platform_dir),
|
|
1339
|
+
"display": sess.display,
|
|
1340
|
+
"bridge": bridge,
|
|
1341
|
+
"manager": None if rt is None else {
|
|
1342
|
+
"pid": rt.manager_process.pid if rt.manager_process else None,
|
|
1343
|
+
"alive": rt.manager_process.poll() is None if rt.manager_process else False,
|
|
1344
|
+
"server_pid": rt.server_process.pid if rt.server_process else None,
|
|
1345
|
+
"server_alive": rt.server_process.poll() is None if rt.server_process else False,
|
|
1346
|
+
"port": rt.server.port,
|
|
1347
|
+
"log_dir": str(rt.log_dir),
|
|
1348
|
+
},
|
|
1349
|
+
"test_client": {
|
|
1350
|
+
"port": sess.testclient_port,
|
|
1351
|
+
"base_url": sess.base_url,
|
|
1352
|
+
"client_ibsrv_port": sess.client_ibsrv_port,
|
|
1353
|
+
"server_alive": client_server_alive,
|
|
1354
|
+
"shared_client_key": sess.shared_client_key,
|
|
1355
|
+
"file_client_ibsrv_port": sess.file_client_ibsrv_port,
|
|
1356
|
+
"file_client_server_alive": file_client_server_alive,
|
|
1357
|
+
"shared_file_key": sess.shared_file_key,
|
|
1358
|
+
},
|
|
1359
|
+
"rag_snapshot": sess.rag_snapshot,
|
|
1360
|
+
"idle_timeout_seconds": sess.idle_timeout_seconds,
|
|
1361
|
+
}
|
|
1362
|
+
|
|
1363
|
+
|
|
1364
|
+
@mcp.tool()
|
|
1365
|
+
async def start_session(
|
|
1366
|
+
base_url: Annotated[str, "Target 1C infobase URL; used for version detection and test-client connection"] = "",
|
|
1367
|
+
username: Annotated[str, "1C username"] = "",
|
|
1368
|
+
password: Annotated[str, "1C password"] = "",
|
|
1369
|
+
version: Annotated[str, "1C platform version override (auto-detected from base_url if empty)"] = "",
|
|
1370
|
+
session_id: Annotated[str, "Desired session id; auto-generated when empty"] = "",
|
|
1371
|
+
idle_timeout_minutes: Annotated[int, "Minutes of inactivity before automatic stop_session; 0 disables"] = 60,
|
|
1372
|
+
extra_args: Annotated[str, "Extra raw 1C command-line arguments for the test client"] = "",
|
|
1373
|
+
execute: Annotated[str, "External data processor/report path for the test-client /Execute command-line parameter"] = "",
|
|
1374
|
+
command_parameter: Annotated[str, "Additional test-client /C value; when extra_args already contains /C, this value is appended with ';'"] = "",
|
|
1375
|
+
) -> dict[str, Any]:
|
|
1376
|
+
"""One-shot session factory: detect version, start infrastructure and connect the 1C test client."""
|
|
1377
|
+
base_url = _platform.normalize_infobase_url(base_url)
|
|
1378
|
+
connection_mode = "auto"
|
|
1379
|
+
connection_value = ""
|
|
1380
|
+
initial_connection_mode = _normalize_connection_mode(connection_mode, base_url, base_url)
|
|
1381
|
+
if base_url and initial_connection_mode != "ws":
|
|
1382
|
+
# Non-http(s) base_url is treated as an auto-detected test-client target:
|
|
1383
|
+
# a file infobase path (/F or ibsrv) or a server/infobase string (/S).
|
|
1384
|
+
connection_value = base_url
|
|
1385
|
+
base_url = ""
|
|
1386
|
+
resolved_version = version or None
|
|
1387
|
+
if not resolved_version and base_url and initial_connection_mode == "ws":
|
|
1388
|
+
resolved_version = _platform.detect_version_from_url(base_url)
|
|
1389
|
+
if resolved_version:
|
|
1390
|
+
_platform.require_websocket_support(resolved_version)
|
|
1391
|
+
|
|
1392
|
+
platform_dir = _platform.find_platform_dir(resolved_version)
|
|
1393
|
+
actual_version = _platform.platform_version_from_dir(platform_dir)
|
|
1394
|
+
_platform.require_websocket_support(actual_version)
|
|
1395
|
+
logging.info("start_session: version=%s platform_dir=%s", actual_version, platform_dir)
|
|
1396
|
+
|
|
1397
|
+
sid = session_id or DEFAULT_SESSION
|
|
1398
|
+
if sid in _SESSIONS:
|
|
1399
|
+
raise ValueError(f"Session {sid!r} already exists.")
|
|
1400
|
+
|
|
1401
|
+
data_dir = os_support.default_data_dir() / "sessions" / sid
|
|
1402
|
+
with _port_allocation_lock():
|
|
1403
|
+
port_offset = _stable_session_port_offset(sid)
|
|
1404
|
+
display_offset = port_offset // _PORT_BAND_SIZE
|
|
1405
|
+
display = "" if os_support.IS_WINDOWS else runtime.find_free_display(runtime.DEFAULT_DISPLAY_START + display_offset)
|
|
1406
|
+
ws_port = _find_session_port(runtime.DEFAULT_WS_PORT, port_offset, sub_offset=0)
|
|
1407
|
+
ibsrv_port = _find_session_port(runtime.DEFAULT_IBSRV_HTTP_PORT, port_offset, sub_offset=0)
|
|
1408
|
+
ibsrv_direct_regport = _find_session_port(runtime.DEFAULT_IBSRV_DIRECT_PORT, port_offset, sub_offset=0)
|
|
1409
|
+
testclient_port = _find_session_port(runtime.DEFAULT_TEST_CLIENT_PORT, port_offset, sub_offset=0)
|
|
1410
|
+
|
|
1411
|
+
sess = SessionState(
|
|
1412
|
+
session_id=sid, display=display, platform_dir=platform_dir,
|
|
1413
|
+
ws_port=ws_port, ibsrv_port=ibsrv_port, testclient_port=testclient_port,
|
|
1414
|
+
data_dir=data_dir, base_url=base_url, username=username, password=password
|
|
1415
|
+
)
|
|
1416
|
+
sess.idle_timeout_seconds = max(0, int(idle_timeout_minutes)) * 60
|
|
1417
|
+
_cleanup_stale_session_processes(sess, "before start_session")
|
|
1418
|
+
if base_url:
|
|
1419
|
+
bound_snapshot = _snapshot_for_base_url(base_url)
|
|
1420
|
+
if bound_snapshot:
|
|
1421
|
+
sess.rag_snapshot = bound_snapshot
|
|
1422
|
+
if display:
|
|
1423
|
+
sess.xvfb_process = runtime.start_xvfb(display)
|
|
1424
|
+
await sess.bridge.start()
|
|
1425
|
+
_SESSIONS[sid] = sess
|
|
1426
|
+
_persist_sessions()
|
|
1427
|
+
logging.info("start_session: bridge listening on ws://127.0.0.1:%s", ws_port)
|
|
1428
|
+
|
|
1429
|
+
log_dir = sess.log_dir
|
|
1430
|
+
log_dir.mkdir(parents=True, exist_ok=True)
|
|
1431
|
+
ibsrv_address = "127.0.0.1"
|
|
1432
|
+
ibsrv_name = "mcp"
|
|
1433
|
+
|
|
1434
|
+
runtime_state = _resolve_runtime(
|
|
1435
|
+
platform_dir=platform_dir, db_path=sess.db_path,
|
|
1436
|
+
ibsrv_address=ibsrv_address, ibsrv_port=ibsrv_port,
|
|
1437
|
+
ibsrv_name=ibsrv_name, server_data_dir=sess.server_data_dir,
|
|
1438
|
+
)
|
|
1439
|
+
runtime_state.log_dir = log_dir
|
|
1440
|
+
|
|
1441
|
+
try:
|
|
1442
|
+
with _port_allocation_lock():
|
|
1443
|
+
runtime.create_file_infobase(sess.ibcmd, sess.db_path)
|
|
1444
|
+
cf_path = _ensure_cf_built(
|
|
1445
|
+
runtime.DEFAULT_CF_BUILT,
|
|
1446
|
+
src_dir="src/cf",
|
|
1447
|
+
platform_dir=platform_dir,
|
|
1448
|
+
)
|
|
1449
|
+
runtime.load_configuration(sess.ibcmd, sess.db_path, cf_path)
|
|
1450
|
+
except Exception:
|
|
1451
|
+
await _cleanup_failed_start_session(sess, "infobase preparation failure")
|
|
1452
|
+
raise
|
|
1453
|
+
|
|
1454
|
+
ibsrv_available = Path(sess.ibsrv).exists()
|
|
1455
|
+
sess.runtime = runtime_state
|
|
1456
|
+
try:
|
|
1457
|
+
if ibsrv_available:
|
|
1458
|
+
server_proc = runtime.start_standalone_server(
|
|
1459
|
+
ibsrv=sess.ibsrv, address=ibsrv_address, port=ibsrv_port,
|
|
1460
|
+
name=ibsrv_name, data_dir=sess.server_data_dir,
|
|
1461
|
+
log_dir=log_dir, db_path=sess.db_path,
|
|
1462
|
+
direct_regport=ibsrv_direct_regport,
|
|
1463
|
+
direct_range=_direct_range_from_regport(ibsrv_direct_regport),
|
|
1464
|
+
)
|
|
1465
|
+
runtime_state.server_process = server_proc
|
|
1466
|
+
ws_url = runtime.build_thin_client_ws_url(ibsrv_address, ibsrv_port)
|
|
1467
|
+
manager_proc = runtime.start_thin_test_manager(
|
|
1468
|
+
onec_path=sess.onec_thin, ws_url=ws_url,
|
|
1469
|
+
websocket_url=sess.bridge.url, log_dir=log_dir, display=display,
|
|
1470
|
+
)
|
|
1471
|
+
else:
|
|
1472
|
+
logging.info("ibsrv not found; starting test-manager in file-direct mode")
|
|
1473
|
+
sess.ibsrv_port = 0
|
|
1474
|
+
ws_url = ""
|
|
1475
|
+
manager_proc = runtime.start_thin_test_manager_file_direct(
|
|
1476
|
+
onec_path=sess.onec_thin,
|
|
1477
|
+
db_path=sess.db_path,
|
|
1478
|
+
websocket_url=sess.bridge.url,
|
|
1479
|
+
log_dir=log_dir,
|
|
1480
|
+
display=display,
|
|
1481
|
+
)
|
|
1482
|
+
runtime_state.manager_process = manager_proc
|
|
1483
|
+
await _wait_for_bridge_connected(sess, timeout=60.0)
|
|
1484
|
+
except Exception as exc:
|
|
1485
|
+
diagnostic_error = _manager_failure_diagnostics(sess, exc)
|
|
1486
|
+
await _cleanup_failed_start_session(sess, "manager startup failure")
|
|
1487
|
+
raise diagnostic_error from exc
|
|
1488
|
+
_persist_sessions()
|
|
1489
|
+
|
|
1490
|
+
pipeline_info: dict[str, Any] = {"manager_pid": manager_proc.pid, "ws_url": ws_url, "log_dir": str(log_dir)}
|
|
1491
|
+
|
|
1492
|
+
try:
|
|
1493
|
+
# --- Built-in demo client ibsrv (second autonomous server for /TESTCLIENT) ---
|
|
1494
|
+
# It is only needed for self-contained E2E sessions. When the caller
|
|
1495
|
+
# passed a target URL/file/server infobase, connect the test client to
|
|
1496
|
+
# that target instead of creating an extra demo infobase.
|
|
1497
|
+
if not (base_url or connection_value) and ibsrv_available:
|
|
1498
|
+
shared_client_id = os.getenv("ONEC_MCP_SHARED_CLIENT_BASE_ID") or "default"
|
|
1499
|
+
shared_client_root = Path(os.getenv("ONEC_MCP_SHARED_CLIENT_BASE_DIR", str(os_support.default_data_dir() / "shared-client-bases")))
|
|
1500
|
+
shared_client_db = shared_client_root / shared_client_id / "ib"
|
|
1501
|
+
client_cf_path = _ensure_cf_built(
|
|
1502
|
+
runtime.DEFAULT_CLIENT_CF_BUILT,
|
|
1503
|
+
src_dir="src/client_cf",
|
|
1504
|
+
platform_dir=platform_dir,
|
|
1505
|
+
)
|
|
1506
|
+
shared_client = _acquire_shared_file_server(
|
|
1507
|
+
owner_session_id=sid,
|
|
1508
|
+
kind="demo-client",
|
|
1509
|
+
platform_dir=platform_dir,
|
|
1510
|
+
ibcmd=sess.ibcmd,
|
|
1511
|
+
ibsrv=sess.ibsrv,
|
|
1512
|
+
db_path=shared_client_db,
|
|
1513
|
+
cf_path=client_cf_path,
|
|
1514
|
+
create_if_missing=True,
|
|
1515
|
+
port_seed=f"demo-client:{shared_client_id}",
|
|
1516
|
+
server_name="mcpclient",
|
|
1517
|
+
)
|
|
1518
|
+
sess.client_ibsrv_port = shared_client.port
|
|
1519
|
+
sess.shared_client_key = shared_client.key
|
|
1520
|
+
sess.client_runtime = runtime.ManagerRuntime(
|
|
1521
|
+
infobase=runtime.InfobaseSpec(db_path=shared_client.db_path),
|
|
1522
|
+
server=runtime.StandaloneServerSpec(
|
|
1523
|
+
address=shared_client.address,
|
|
1524
|
+
port=shared_client.port,
|
|
1525
|
+
name=shared_client.name,
|
|
1526
|
+
data_dir=shared_client.data_dir,
|
|
1527
|
+
),
|
|
1528
|
+
manager=runtime.TestManagerSpec(onec_path=sess.onec_thin),
|
|
1529
|
+
server_process=shared_client.process,
|
|
1530
|
+
log_dir=shared_client.log_dir,
|
|
1531
|
+
extra={"shared_key": shared_client.key, "shared_refcount": len(shared_client.owners)},
|
|
1532
|
+
)
|
|
1533
|
+
|
|
1534
|
+
client_ws_url = runtime.build_thin_client_ws_url(shared_client.address, shared_client.port)
|
|
1535
|
+
pipeline_info.update({
|
|
1536
|
+
"client_ibsrv_port": shared_client.port,
|
|
1537
|
+
"client_ws_url": client_ws_url,
|
|
1538
|
+
"client_base_url": shared_client.base_url,
|
|
1539
|
+
"client_shared": True,
|
|
1540
|
+
"client_shared_refcount": len(shared_client.owners),
|
|
1541
|
+
})
|
|
1542
|
+
elif not (base_url or connection_value):
|
|
1543
|
+
logging.info("ibsrv not found; preparing demo test-client in file-direct mode")
|
|
1544
|
+
runtime.create_file_infobase(sess.ibcmd, sess.client_db_path)
|
|
1545
|
+
client_cf_path = _ensure_cf_built(
|
|
1546
|
+
runtime.DEFAULT_CLIENT_CF_BUILT,
|
|
1547
|
+
src_dir="src/client_cf",
|
|
1548
|
+
platform_dir=platform_dir,
|
|
1549
|
+
)
|
|
1550
|
+
runtime.load_configuration(sess.ibcmd, sess.client_db_path, client_cf_path)
|
|
1551
|
+
sess.client_runtime = runtime.ManagerRuntime(
|
|
1552
|
+
infobase=runtime.InfobaseSpec(db_path=sess.client_db_path),
|
|
1553
|
+
server=runtime.StandaloneServerSpec(address="", port=0, name="file-direct", data_dir=sess.client_server_data_dir),
|
|
1554
|
+
manager=runtime.TestManagerSpec(onec_path=sess.onec_thin),
|
|
1555
|
+
server_process=None,
|
|
1556
|
+
log_dir=sess.client_log_dir,
|
|
1557
|
+
extra={"target_connection_mode": "file-direct"},
|
|
1558
|
+
)
|
|
1559
|
+
pipeline_info.update({
|
|
1560
|
+
"client_connection_mode": "file-direct",
|
|
1561
|
+
"client_connection_value": str(sess.client_db_path),
|
|
1562
|
+
"client_base_url": str(sess.client_db_path),
|
|
1563
|
+
"client_shared": False,
|
|
1564
|
+
})
|
|
1565
|
+
except Exception:
|
|
1566
|
+
await _cleanup_failed_start_session(sess, "demo test-client preparation failure")
|
|
1567
|
+
raise
|
|
1568
|
+
|
|
1569
|
+
connect_result: Any = None
|
|
1570
|
+
connect_base_url = base_url
|
|
1571
|
+
connect_connection_value = connection_value
|
|
1572
|
+
connect_connection_mode = connection_mode
|
|
1573
|
+
if not connect_base_url and pipeline_info.get("client_base_url"):
|
|
1574
|
+
connect_base_url = str(pipeline_info["client_base_url"])
|
|
1575
|
+
if not connect_connection_value and pipeline_info.get("client_connection_value"):
|
|
1576
|
+
connect_connection_value = str(pipeline_info["client_connection_value"])
|
|
1577
|
+
connect_connection_mode = str(pipeline_info.get("client_connection_mode") or "file-direct")
|
|
1578
|
+
if connect_base_url or connect_connection_value:
|
|
1579
|
+
try:
|
|
1580
|
+
connect_result = await _connect_test_client_impl(
|
|
1581
|
+
sess, base_url=connect_base_url, username=username, password=password,
|
|
1582
|
+
port=testclient_port, onec_path=sess.onec_thin, host="localhost",
|
|
1583
|
+
extra_args=extra_args, execute=execute, command_parameter=command_parameter,
|
|
1584
|
+
connection_mode=connect_connection_mode, connection_value=connect_connection_value,
|
|
1585
|
+
)
|
|
1586
|
+
except Exception as exc:
|
|
1587
|
+
enriched = await _enrich_auth_failure_for_web_base(exc, connect_base_url)
|
|
1588
|
+
target_mode = _normalize_connection_mode(
|
|
1589
|
+
connect_connection_mode, connect_connection_value or connect_base_url, connect_base_url,
|
|
1590
|
+
)
|
|
1591
|
+
diagnostic_error = _client_failure_diagnostics(sess, enriched, target_mode)
|
|
1592
|
+
await _cleanup_failed_start_session(sess, "test-client startup failure")
|
|
1593
|
+
raise diagnostic_error from exc
|
|
1594
|
+
_auto_maximize_test_client_result(connect_result, sess.display)
|
|
1595
|
+
|
|
1596
|
+
if sess.idle_timeout_seconds > 0:
|
|
1597
|
+
asyncio.create_task(_idle_watch_session(sid))
|
|
1598
|
+
|
|
1599
|
+
_persist_sessions()
|
|
1600
|
+
return {
|
|
1601
|
+
"session_id": sid, "version": actual_version,
|
|
1602
|
+
"platform_dir": str(platform_dir), "display": display,
|
|
1603
|
+
"ws_port": ws_port, "ibsrv_port": ibsrv_port,
|
|
1604
|
+
"testclient_port": testclient_port, "data_dir": str(data_dir),
|
|
1605
|
+
"bridge_url": sess.bridge.url, "rag_snapshot": sess.rag_snapshot,
|
|
1606
|
+
"idle_timeout_seconds": sess.idle_timeout_seconds,
|
|
1607
|
+
"test_client": connect_result, **pipeline_info,
|
|
1608
|
+
}
|
|
1609
|
+
|
|
1610
|
+
|
|
1611
|
+
async def _stop_session_impl(session_id: str = DEFAULT_SESSION, clean_data: bool = False) -> dict[str, Any]:
|
|
1612
|
+
try:
|
|
1613
|
+
sess = await _ensure_restored_session(session_id)
|
|
1614
|
+
_SESSIONS.pop(session_id, None)
|
|
1615
|
+
except ValueError:
|
|
1616
|
+
return {"stopped": False, "reason": f"session {session_id!r} not found"}
|
|
1617
|
+
stopped: list[str] = []
|
|
1618
|
+
try:
|
|
1619
|
+
await sess.bridge.call("disconnect_test_client", {})
|
|
1620
|
+
stopped.append("test-client")
|
|
1621
|
+
except Exception:
|
|
1622
|
+
pass
|
|
1623
|
+
if sess.test_client_process is not None:
|
|
1624
|
+
runtime.kill_process(sess.test_client_process, "test-client")
|
|
1625
|
+
stopped.append("test-client-process")
|
|
1626
|
+
if sess.runtime is not None:
|
|
1627
|
+
runtime.kill_process(sess.runtime.manager_process, "test-manager")
|
|
1628
|
+
runtime.kill_process(sess.runtime.server_process, "ibsrv")
|
|
1629
|
+
stopped.append("pipeline")
|
|
1630
|
+
if sess.client_runtime is not None:
|
|
1631
|
+
if sess.shared_client_key:
|
|
1632
|
+
if _release_shared_file_server(sess.shared_client_key, session_id):
|
|
1633
|
+
stopped.append("shared-client-ibsrv")
|
|
1634
|
+
else:
|
|
1635
|
+
runtime.kill_process(sess.client_runtime.server_process, "ibsrv-client")
|
|
1636
|
+
stopped.append("client-ibsrv")
|
|
1637
|
+
if sess.file_client_runtime is not None:
|
|
1638
|
+
if sess.shared_file_key:
|
|
1639
|
+
if _release_shared_file_server(sess.shared_file_key, session_id):
|
|
1640
|
+
stopped.append("shared-file-ibsrv")
|
|
1641
|
+
else:
|
|
1642
|
+
runtime.kill_process(sess.file_client_runtime.server_process, "ibsrv-file-client")
|
|
1643
|
+
stopped.append("file-client-ibsrv")
|
|
1644
|
+
try:
|
|
1645
|
+
await sess.bridge.stop()
|
|
1646
|
+
stopped.append("bridge")
|
|
1647
|
+
except Exception as exc:
|
|
1648
|
+
logging.warning("stop_session: bridge.stop failed: %s", exc)
|
|
1649
|
+
runtime.stop_xvfb(sess.xvfb_process)
|
|
1650
|
+
stopped.append("xvfb")
|
|
1651
|
+
_cleanup_stale_session_processes(sess, "stop_session")
|
|
1652
|
+
stopped.append("stale-processes")
|
|
1653
|
+
if clean_data and sess.data_dir.exists():
|
|
1654
|
+
shutil.rmtree(sess.data_dir, ignore_errors=True)
|
|
1655
|
+
stopped.append("data_dir")
|
|
1656
|
+
_persist_sessions()
|
|
1657
|
+
return {"stopped": True, "session_id": session_id, "components": stopped}
|
|
1658
|
+
|
|
1659
|
+
|
|
1660
|
+
@mcp.tool()
|
|
1661
|
+
async def stop_session(
|
|
1662
|
+
session_id: Annotated[str, "Session to tear down"] = DEFAULT_SESSION,
|
|
1663
|
+
clean_data: Annotated[bool, "Remove the session data directory"] = False,
|
|
1664
|
+
) -> dict[str, Any]:
|
|
1665
|
+
"""Tear down a session: disconnect test-client, stop test-manager, ibsrv, Xvfb and bridge."""
|
|
1666
|
+
return await _stop_session_impl(session_id=session_id, clean_data=clean_data)
|
|
1667
|
+
|
|
1668
|
+
|
|
1669
|
+
def launch_manager(
|
|
1670
|
+
platform_dir: Annotated[str, "1C platform directory override"] = "",
|
|
1671
|
+
db_path: Annotated[str, "File infobase path override"] = "",
|
|
1672
|
+
server_data_dir: Annotated[str, "Standalone server data dir override"] = "",
|
|
1673
|
+
ibsrv_address: Annotated[str, "Standalone server listen address"] = "127.0.0.1",
|
|
1674
|
+
ibsrv_port: Annotated[int, "Standalone server HTTP port (0 = use session's)"] = 0,
|
|
1675
|
+
ibsrv_name: Annotated[str, "Standalone server infobase name"] = "mcp",
|
|
1676
|
+
create_infobase: Annotated[bool, "(Re)create the file infobase"] = True,
|
|
1677
|
+
load_configuration: Annotated[bool, "Load .cf into the infobase"] = True,
|
|
1678
|
+
cf_path: Annotated[str, "Path to .cf file"] = runtime.DEFAULT_CF_BUILT,
|
|
1679
|
+
start_standalone: Annotated[bool, "Start ibsrv"] = True,
|
|
1680
|
+
use_thin_client: Annotated[bool, "Use 1cv8c /WS"] = True,
|
|
1681
|
+
extra_args: Annotated[str, "Extra raw 1C command-line arguments"] = "",
|
|
1682
|
+
*,
|
|
1683
|
+
session_id: Annotated[str, "Session id"] = DEFAULT_SESSION,
|
|
1684
|
+
) -> dict[str, Any]:
|
|
1685
|
+
"""Launch the 1C test infrastructure inside an existing session."""
|
|
1686
|
+
sess = _session(session_id)
|
|
1687
|
+
pd = Path(platform_dir) if platform_dir else sess.platform_dir
|
|
1688
|
+
db = Path(db_path) if db_path else sess.db_path
|
|
1689
|
+
sdd = Path(server_data_dir) if server_data_dir else sess.server_data_dir
|
|
1690
|
+
port = ibsrv_port if ibsrv_port else sess.ibsrv_port
|
|
1691
|
+
|
|
1692
|
+
if sess.runtime is not None:
|
|
1693
|
+
runtime.kill_process(sess.runtime.manager_process, "test-manager")
|
|
1694
|
+
runtime.kill_process(sess.runtime.server_process, "ibsrv")
|
|
1695
|
+
sess.runtime = None
|
|
1696
|
+
|
|
1697
|
+
runtime_state = _resolve_runtime(pd, db, ibsrv_address, port, ibsrv_name, sdd)
|
|
1698
|
+
log_dir = sess.log_dir
|
|
1699
|
+
log_dir.mkdir(parents=True, exist_ok=True)
|
|
1700
|
+
runtime_state.log_dir = log_dir
|
|
1701
|
+
|
|
1702
|
+
ibcmd = str(pd / os_support.executable_name("ibcmd"))
|
|
1703
|
+
ibsrv = str(pd / os_support.executable_name("ibsrv"))
|
|
1704
|
+
onec_thin = str(pd / os_support.executable_name("1cv8c"))
|
|
1705
|
+
|
|
1706
|
+
if create_infobase:
|
|
1707
|
+
runtime.create_file_infobase(ibcmd, db)
|
|
1708
|
+
if load_configuration and cf_path:
|
|
1709
|
+
resolved_cf_path = cf_path
|
|
1710
|
+
if cf_path == runtime.DEFAULT_CF_BUILT:
|
|
1711
|
+
resolved_cf_path = _ensure_cf_built(cf_path, src_dir="src/cf", platform_dir=pd)
|
|
1712
|
+
runtime.load_configuration(ibcmd, db, resolved_cf_path)
|
|
1713
|
+
if start_standalone:
|
|
1714
|
+
with _port_allocation_lock():
|
|
1715
|
+
port_offset = _stable_session_port_offset(f"launch:{session_id}:{port}")
|
|
1716
|
+
direct_regport = _find_session_port(runtime.DEFAULT_IBSRV_DIRECT_PORT, port_offset, sub_offset=0)
|
|
1717
|
+
server_proc = runtime.start_standalone_server(
|
|
1718
|
+
ibsrv=ibsrv, address=ibsrv_address, port=port,
|
|
1719
|
+
name=ibsrv_name, data_dir=sdd, log_dir=log_dir, db_path=db,
|
|
1720
|
+
direct_regport=direct_regport,
|
|
1721
|
+
direct_range=_direct_range_from_regport(direct_regport),
|
|
1722
|
+
)
|
|
1723
|
+
runtime_state.server_process = server_proc
|
|
1724
|
+
|
|
1725
|
+
if use_thin_client:
|
|
1726
|
+
ws_url = runtime.build_thin_client_ws_url(ibsrv_address, port)
|
|
1727
|
+
argv = runtime.build_thin_test_manager_argv(onec_thin, ws_url, sess.bridge.url)
|
|
1728
|
+
manager_proc = runtime.start_thin_test_manager(
|
|
1729
|
+
onec_path=onec_thin, ws_url=ws_url,
|
|
1730
|
+
websocket_url=sess.bridge.url, log_dir=log_dir, display=sess.display,
|
|
1731
|
+
)
|
|
1732
|
+
else:
|
|
1733
|
+
raise ValueError("Thick-client fallback removed; use thin /WS /TESTMANAGER")
|
|
1734
|
+
|
|
1735
|
+
if extra_args:
|
|
1736
|
+
argv.extend(shlex.split(extra_args))
|
|
1737
|
+
runtime_state.manager_process = manager_proc
|
|
1738
|
+
sess.runtime = runtime_state
|
|
1739
|
+
return {
|
|
1740
|
+
"pid": manager_proc.pid, "argv": argv,
|
|
1741
|
+
"websocket_url": sess.bridge.url, "ws_url": ws_url, "log_dir": str(log_dir),
|
|
1742
|
+
}
|
|
1743
|
+
|
|
1744
|
+
|
|
1745
|
+
@mcp.tool()
|
|
1746
|
+
def export_eventlog(
|
|
1747
|
+
platform_dir: Annotated[str, "1C platform dir override"] = "",
|
|
1748
|
+
eventlog_dir: Annotated[str, "Event log directory"] = "",
|
|
1749
|
+
out_path: Annotated[str, "Output file path"] = "",
|
|
1750
|
+
from_time: Annotated[str, "Start timestamp"] = "",
|
|
1751
|
+
to_time: Annotated[str, "End timestamp"] = "",
|
|
1752
|
+
fmt: Annotated[str, "Export format: json or xml"] = "json",
|
|
1753
|
+
*,
|
|
1754
|
+
session_id: Annotated[str, "Session id"] = DEFAULT_SESSION,
|
|
1755
|
+
) -> dict[str, Any]:
|
|
1756
|
+
"""Export the 1C event log."""
|
|
1757
|
+
sess = _session(session_id)
|
|
1758
|
+
ibcmd = str(Path(platform_dir) / "ibcmd") if platform_dir else sess.ibcmd
|
|
1759
|
+
if sess.runtime is not None and not eventlog_dir:
|
|
1760
|
+
eventlog_path = sess.runtime.server.data_dir / "log-data"
|
|
1761
|
+
log_dir = sess.runtime.log_dir
|
|
1762
|
+
else:
|
|
1763
|
+
eventlog_path = Path(eventlog_dir or str(Path.home() / ".1cv8/1C/1cv8/standalone-server/log-data"))
|
|
1764
|
+
log_dir = sess.log_dir
|
|
1765
|
+
if not out_path:
|
|
1766
|
+
out = log_dir / f"eventlog_{datetime.now().strftime('%Y%m%d_%H%M%S')}.{fmt}"
|
|
1767
|
+
else:
|
|
1768
|
+
out = Path(out_path)
|
|
1769
|
+
exported = runtime.export_eventlog(ibcmd, eventlog_path, out, from_time or None, to_time or None, fmt)
|
|
1770
|
+
return {"path": str(exported), "format": fmt, "eventlog_dir": str(eventlog_path)}
|
|
1771
|
+
|
|
1772
|
+
|
|
1773
|
+
@mcp.tool()
|
|
1774
|
+
def credentials_check(base_url: str) -> dict[str, object]:
|
|
1775
|
+
"""Check whether credentials exist for a base URL without returning secrets."""
|
|
1776
|
+
credentials = _credentials_store().lookup(base_url)
|
|
1777
|
+
if credentials is None:
|
|
1778
|
+
return {"base_url": base_url, "found": False}
|
|
1779
|
+
return {
|
|
1780
|
+
"base_url": base_url,
|
|
1781
|
+
"found": True,
|
|
1782
|
+
}
|
|
1783
|
+
|
|
1784
|
+
|
|
1785
|
+
@mcp.tool()
|
|
1786
|
+
def credentials_save(
|
|
1787
|
+
url: Annotated[str, "Base URL pattern (exact or wildcard like https://*.example.com/*)"],
|
|
1788
|
+
username: Annotated[str, "1C username"],
|
|
1789
|
+
password: Annotated[str, "1C password"],
|
|
1790
|
+
) -> dict[str, object]:
|
|
1791
|
+
"""Save or update credentials for a URL pattern in the local credentials store.
|
|
1792
|
+
|
|
1793
|
+
The credentials file is never committed to git. Use this tool to persist
|
|
1794
|
+
login/password pairs so that ``start_session`` can pick them up automatically
|
|
1795
|
+
without exposing secrets in MCP tool-call arguments.
|
|
1796
|
+
"""
|
|
1797
|
+
updated = _credentials_store().upsert(url, username, password)
|
|
1798
|
+
return {
|
|
1799
|
+
"url": url,
|
|
1800
|
+
"saved": True,
|
|
1801
|
+
"updated": updated,
|
|
1802
|
+
}
|
|
1803
|
+
|
|
1804
|
+
|
|
1805
|
+
@mcp.tool()
|
|
1806
|
+
def credentials_remove(
|
|
1807
|
+
url: Annotated[str, "Exact URL pattern to remove from the credentials store"],
|
|
1808
|
+
) -> dict[str, object]:
|
|
1809
|
+
"""Remove a credential entry from the local store by exact URL match."""
|
|
1810
|
+
removed = _credentials_store().remove(url)
|
|
1811
|
+
return {"url": url, "removed": removed}
|
|
1812
|
+
|
|
1813
|
+
|
|
1814
|
+
@mcp.tool()
|
|
1815
|
+
def credentials_list() -> list[dict[str, str]]:
|
|
1816
|
+
"""List configured credential URL patterns (no usernames or passwords)."""
|
|
1817
|
+
return _credentials_store().list_entries()
|
|
1818
|
+
|
|
1819
|
+
|
|
1820
|
+
_PLACEHOLDER_PASSWORD_RE = re.compile(r"^\*{3,}$")
|
|
1821
|
+
_AUTH_FAILURE_MARKERS = (
|
|
1822
|
+
"пользователь иб не идентифицирован",
|
|
1823
|
+
"пользователь не идентифицирован",
|
|
1824
|
+
"ошибка аутентификации",
|
|
1825
|
+
"ошибка авторизации",
|
|
1826
|
+
"неверный пароль",
|
|
1827
|
+
"неправильный пароль",
|
|
1828
|
+
"authentication failed",
|
|
1829
|
+
"authorization failed",
|
|
1830
|
+
"invalid user",
|
|
1831
|
+
"invalid password",
|
|
1832
|
+
)
|
|
1833
|
+
_EVENTLOG_ERROR_MARKERS = (
|
|
1834
|
+
"ошиб",
|
|
1835
|
+
"error",
|
|
1836
|
+
"exception",
|
|
1837
|
+
"исключен",
|
|
1838
|
+
"failed",
|
|
1839
|
+
"fail",
|
|
1840
|
+
)
|
|
1841
|
+
|
|
1842
|
+
|
|
1843
|
+
def _is_placeholder_password(password: str) -> bool:
|
|
1844
|
+
"""Return True when *password* looks like a redacted placeholder (e.g. ``***``)."""
|
|
1845
|
+
return bool(password) and bool(_PLACEHOLDER_PASSWORD_RE.match(password))
|
|
1846
|
+
|
|
1847
|
+
|
|
1848
|
+
def _is_web_base_url(base_url: str) -> bool:
|
|
1849
|
+
scheme = urlsplit(base_url).scheme.lower()
|
|
1850
|
+
return scheme in {"http", "https"}
|
|
1851
|
+
|
|
1852
|
+
|
|
1853
|
+
def _users_url_for_base_url(base_url: str) -> str:
|
|
1854
|
+
parts = urlsplit(_platform.normalize_infobase_url(base_url).rstrip("/"))
|
|
1855
|
+
path = parts.path.rstrip("/") + "/ru_RU/e1cib/users"
|
|
1856
|
+
return urlunsplit((parts.scheme, parts.netloc, path, "", ""))
|
|
1857
|
+
|
|
1858
|
+
|
|
1859
|
+
def _parse_users_plain_text(text: str) -> list[str]:
|
|
1860
|
+
users: list[str] = []
|
|
1861
|
+
seen: set[str] = set()
|
|
1862
|
+
for raw_line in text.splitlines():
|
|
1863
|
+
user = raw_line.strip().lstrip("\ufeff")
|
|
1864
|
+
if not user or user in seen:
|
|
1865
|
+
continue
|
|
1866
|
+
seen.add(user)
|
|
1867
|
+
users.append(user)
|
|
1868
|
+
return users
|
|
1869
|
+
|
|
1870
|
+
|
|
1871
|
+
def _fetch_available_users_sync(base_url: str, timeout: float = 10.0) -> dict[str, Any]:
|
|
1872
|
+
users_url = _users_url_for_base_url(base_url)
|
|
1873
|
+
request = urllib.request.Request(users_url, headers={"User-Agent": "Answer42/1.0"})
|
|
1874
|
+
try:
|
|
1875
|
+
with urllib.request.urlopen(request, timeout=timeout) as response:
|
|
1876
|
+
charset = response.headers.get_content_charset() or "utf-8"
|
|
1877
|
+
text = response.read().decode(charset, errors="replace")
|
|
1878
|
+
users = _parse_users_plain_text(text)
|
|
1879
|
+
return {"url": users_url, "users": users, "count": len(users)}
|
|
1880
|
+
except Exception as exc:
|
|
1881
|
+
return {"url": users_url, "users": [], "count": 0, "error": str(exc)}
|
|
1882
|
+
|
|
1883
|
+
|
|
1884
|
+
async def _fetch_available_users(base_url: str) -> dict[str, Any]:
|
|
1885
|
+
return await asyncio.to_thread(_fetch_available_users_sync, base_url)
|
|
1886
|
+
|
|
1887
|
+
|
|
1888
|
+
def _looks_like_auth_failure(error_text: str) -> bool:
|
|
1889
|
+
normalized = error_text.lower()
|
|
1890
|
+
return any(marker in normalized for marker in _AUTH_FAILURE_MARKERS)
|
|
1891
|
+
|
|
1892
|
+
|
|
1893
|
+
def _append_available_users_to_auth_error(error_text: str, users_info: dict[str, Any]) -> str:
|
|
1894
|
+
users = users_info.get("users") or []
|
|
1895
|
+
if users:
|
|
1896
|
+
users_text = "\n".join(f"- {user}" for user in users)
|
|
1897
|
+
return (
|
|
1898
|
+
f"{error_text}\n\n"
|
|
1899
|
+
f"Доступные пользователи из {users_info.get('url')}:\n{users_text}"
|
|
1900
|
+
)
|
|
1901
|
+
if users_info.get("error"):
|
|
1902
|
+
return (
|
|
1903
|
+
f"{error_text}\n\n"
|
|
1904
|
+
f"Не удалось получить список доступных пользователей из {users_info.get('url')}: {users_info.get('error')}"
|
|
1905
|
+
)
|
|
1906
|
+
return f"{error_text}\n\nСписок доступных пользователей из {users_info.get('url')} пуст."
|
|
1907
|
+
|
|
1908
|
+
|
|
1909
|
+
async def _enrich_auth_failure_for_web_base(error: Exception, base_url: str) -> Exception:
|
|
1910
|
+
error_text = str(error)
|
|
1911
|
+
if not base_url or not _is_web_base_url(base_url) or not _looks_like_auth_failure(error_text):
|
|
1912
|
+
return error
|
|
1913
|
+
users_info = await _fetch_available_users(base_url)
|
|
1914
|
+
enriched_text = _append_available_users_to_auth_error(error_text, users_info)
|
|
1915
|
+
return type(error)(enriched_text)
|
|
1916
|
+
|
|
1917
|
+
|
|
1918
|
+
def _tail_text_file(path: Path | str, *, max_lines: int = 80, max_chars: int = 20000) -> str:
|
|
1919
|
+
file_path = Path(path)
|
|
1920
|
+
if not file_path.exists():
|
|
1921
|
+
return ""
|
|
1922
|
+
try:
|
|
1923
|
+
lines = file_path.read_text(encoding="utf-8", errors="replace").splitlines()
|
|
1924
|
+
except Exception as exc:
|
|
1925
|
+
return f"<не удалось прочитать {file_path}: {exc}>"
|
|
1926
|
+
text = "\n".join(lines[-max_lines:])
|
|
1927
|
+
if len(text) > max_chars:
|
|
1928
|
+
text = text[-max_chars:]
|
|
1929
|
+
return text
|
|
1930
|
+
|
|
1931
|
+
|
|
1932
|
+
def _append_file_tail(message: str, title: str, path: Path | str, *, max_lines: int = 80) -> str:
|
|
1933
|
+
tail = _tail_text_file(path, max_lines=max_lines)
|
|
1934
|
+
if not tail:
|
|
1935
|
+
return f"{message}\n\n{title}: {path}\n<лог пуст или не найден>"
|
|
1936
|
+
return f"{message}\n\n{title}: {path}\n{tail}"
|
|
1937
|
+
|
|
1938
|
+
|
|
1939
|
+
def _extract_error_lines(text: str, *, max_lines: int = 80) -> str:
|
|
1940
|
+
lines = text.splitlines()
|
|
1941
|
+
error_lines = [line for line in lines if any(marker in line.lower() for marker in _EVENTLOG_ERROR_MARKERS)]
|
|
1942
|
+
selected = error_lines[-max_lines:] if error_lines else lines[-max_lines:]
|
|
1943
|
+
return "\n".join(selected)
|
|
1944
|
+
|
|
1945
|
+
|
|
1946
|
+
def _eventlog_errors_for_runtime(
|
|
1947
|
+
sess: SessionState,
|
|
1948
|
+
rt: runtime.ManagerRuntime | None,
|
|
1949
|
+
label: str,
|
|
1950
|
+
*,
|
|
1951
|
+
max_lines: int = 80,
|
|
1952
|
+
) -> dict[str, str]:
|
|
1953
|
+
if rt is None or rt.server_process is None:
|
|
1954
|
+
return {}
|
|
1955
|
+
eventlog_dir = rt.server.data_dir / "log-data"
|
|
1956
|
+
out_path = rt.log_dir / f"eventlog_{label}_{datetime.now().strftime('%Y%m%d_%H%M%S')}.json"
|
|
1957
|
+
try:
|
|
1958
|
+
exported = runtime.export_eventlog(sess.ibcmd, eventlog_dir, out_path, None, None, "json")
|
|
1959
|
+
text = _tail_text_file(exported, max_lines=500, max_chars=120000)
|
|
1960
|
+
return {"path": str(exported), "tail": _extract_error_lines(text, max_lines=max_lines)}
|
|
1961
|
+
except Exception as exc:
|
|
1962
|
+
return {"path": str(out_path), "error": str(exc)}
|
|
1963
|
+
|
|
1964
|
+
|
|
1965
|
+
def _append_eventlog_diagnostics(message: str, diagnostics: dict[str, str], title: str) -> str:
|
|
1966
|
+
if not diagnostics:
|
|
1967
|
+
return message
|
|
1968
|
+
if diagnostics.get("tail"):
|
|
1969
|
+
return f"{message}\n\n{title}: {diagnostics.get('path')}\n{diagnostics.get('tail')}"
|
|
1970
|
+
return f"{message}\n\n{title}: {diagnostics.get('path')}\n<не удалось выгрузить/прочитать ЖР: {diagnostics.get('error', 'нет данных')}>"
|
|
1971
|
+
|
|
1972
|
+
|
|
1973
|
+
def _manager_failure_diagnostics(sess: SessionState, error: Exception) -> RuntimeError:
|
|
1974
|
+
message = str(error)
|
|
1975
|
+
message = _append_file_tail(message, "Лог stdout/stderr менеджера тестирования", sess.log_dir / "test_manager.log")
|
|
1976
|
+
message = _append_file_tail(message, "Лог 1С /Out менеджера тестирования", sess.log_dir / "test_manager_1c_out.log")
|
|
1977
|
+
diagnostics = _eventlog_errors_for_runtime(sess, sess.runtime, "manager")
|
|
1978
|
+
message = _append_eventlog_diagnostics(message, diagnostics, "Последние ошибки ЖР автономного сервера менеджера")
|
|
1979
|
+
return RuntimeError(message)
|
|
1980
|
+
|
|
1981
|
+
|
|
1982
|
+
def _client_runtime_for_eventlog(sess: SessionState, target_connection_mode: str) -> runtime.ManagerRuntime | None:
|
|
1983
|
+
if target_connection_mode == "file":
|
|
1984
|
+
return sess.file_client_runtime
|
|
1985
|
+
if target_connection_mode == "ws" and sess.client_runtime is not None:
|
|
1986
|
+
# Built-in demo client is served through a local autonomous server.
|
|
1987
|
+
return sess.client_runtime
|
|
1988
|
+
return None
|
|
1989
|
+
|
|
1990
|
+
|
|
1991
|
+
def _client_failure_diagnostics(sess: SessionState, error: Exception, target_connection_mode: str) -> RuntimeError:
|
|
1992
|
+
message = str(error)
|
|
1993
|
+
message = _append_file_tail(message, "Лог stdout/stderr клиента тестирования", sess.client_log_dir / "test_client_stdout.log")
|
|
1994
|
+
message = _append_file_tail(message, "Лог 1С /Out клиента тестирования", sess.client_log_dir / "test_client_1c_out.log")
|
|
1995
|
+
rt = _client_runtime_for_eventlog(sess, target_connection_mode)
|
|
1996
|
+
diagnostics = _eventlog_errors_for_runtime(sess, rt, "client")
|
|
1997
|
+
message = _append_eventlog_diagnostics(message, diagnostics, "Последние ошибки ЖР автономного сервера клиента тестирования")
|
|
1998
|
+
return RuntimeError(message)
|
|
1999
|
+
|
|
2000
|
+
|
|
2001
|
+
async def _connect_test_client_impl(
|
|
2002
|
+
sess: SessionState,
|
|
2003
|
+
*,
|
|
2004
|
+
base_url: str = "",
|
|
2005
|
+
username: str = "",
|
|
2006
|
+
password: str = "",
|
|
2007
|
+
port: int = 0,
|
|
2008
|
+
onec_path: str = "",
|
|
2009
|
+
host: str = "localhost",
|
|
2010
|
+
extra_args: str = "",
|
|
2011
|
+
execute: str = "",
|
|
2012
|
+
command_parameter: str = "",
|
|
2013
|
+
connection_mode: str = "auto",
|
|
2014
|
+
connection_value: str = "",
|
|
2015
|
+
) -> Any:
|
|
2016
|
+
"""Launch 1cv8c /TESTCLIENT from Python and attach the test manager to it."""
|
|
2017
|
+
await _wait_for_bridge_connected(sess, timeout=60.0)
|
|
2018
|
+
r_base = _platform.normalize_infobase_url(base_url or sess.base_url)
|
|
2019
|
+
r_value = connection_value or r_base
|
|
2020
|
+
mode = _normalize_connection_mode(connection_mode, r_value, r_base)
|
|
2021
|
+
r_user = username or sess.username
|
|
2022
|
+
r_pass = password if password else sess.password
|
|
2023
|
+
r_port = port if port else sess.testclient_port
|
|
2024
|
+
r_onec = onec_path or sess.onec_thin
|
|
2025
|
+
|
|
2026
|
+
if mode == "ws":
|
|
2027
|
+
if not r_value and sess.client_ibsrv_port:
|
|
2028
|
+
r_value = f"http://127.0.0.1:{sess.client_ibsrv_port}"
|
|
2029
|
+
r_user = r_user or ""
|
|
2030
|
+
r_pass = r_pass or ""
|
|
2031
|
+
if not r_value:
|
|
2032
|
+
raise ValueError("base_url or connection_value is required")
|
|
2033
|
+
r_base = r_value
|
|
2034
|
+
if not r_user and not r_pass:
|
|
2035
|
+
credentials = _credentials_store().lookup(r_base)
|
|
2036
|
+
if credentials:
|
|
2037
|
+
r_user = credentials["username"]
|
|
2038
|
+
r_pass = credentials["password"]
|
|
2039
|
+
elif mode == "file-direct":
|
|
2040
|
+
if not r_value:
|
|
2041
|
+
raise ValueError("connection_value or base_url must contain a file infobase directory for connection_mode='file-direct'")
|
|
2042
|
+
db_path = Path(r_value).expanduser()
|
|
2043
|
+
if not db_path.exists():
|
|
2044
|
+
raise FileNotFoundError(f"File infobase directory not found: {db_path}")
|
|
2045
|
+
r_base = str(db_path)
|
|
2046
|
+
r_value = str(db_path)
|
|
2047
|
+
target_connection_mode = "file-direct"
|
|
2048
|
+
elif mode == "file":
|
|
2049
|
+
if not r_value:
|
|
2050
|
+
raise ValueError("connection_value or base_url must contain a file infobase directory for connection_mode='file'")
|
|
2051
|
+
db_path = Path(r_value).expanduser()
|
|
2052
|
+
if not db_path.exists():
|
|
2053
|
+
raise FileNotFoundError(f"File infobase directory not found: {db_path}")
|
|
2054
|
+
if sess.shared_file_key:
|
|
2055
|
+
_release_shared_file_server(sess.shared_file_key, sess.session_id)
|
|
2056
|
+
sess.shared_file_key = ""
|
|
2057
|
+
elif sess.file_client_runtime is not None:
|
|
2058
|
+
runtime.kill_process(sess.file_client_runtime.server_process, "ibsrv-file-client")
|
|
2059
|
+
sess.file_client_runtime = None
|
|
2060
|
+
shared_file = _acquire_shared_file_server(
|
|
2061
|
+
owner_session_id=sess.session_id,
|
|
2062
|
+
kind="file",
|
|
2063
|
+
platform_dir=sess.platform_dir,
|
|
2064
|
+
ibcmd=sess.ibcmd,
|
|
2065
|
+
ibsrv=sess.ibsrv,
|
|
2066
|
+
db_path=db_path,
|
|
2067
|
+
create_if_missing=False,
|
|
2068
|
+
port_seed=f"file:{db_path.resolve()}",
|
|
2069
|
+
server_name="mcpfileclient",
|
|
2070
|
+
)
|
|
2071
|
+
sess.file_client_ibsrv_port = shared_file.port
|
|
2072
|
+
sess.shared_file_key = shared_file.key
|
|
2073
|
+
sess.file_client_runtime = runtime.ManagerRuntime(
|
|
2074
|
+
infobase=runtime.InfobaseSpec(db_path=shared_file.db_path),
|
|
2075
|
+
server=runtime.StandaloneServerSpec(
|
|
2076
|
+
address=shared_file.address,
|
|
2077
|
+
port=shared_file.port,
|
|
2078
|
+
name=shared_file.name,
|
|
2079
|
+
data_dir=shared_file.data_dir,
|
|
2080
|
+
),
|
|
2081
|
+
manager=runtime.TestManagerSpec(onec_path=sess.onec_thin),
|
|
2082
|
+
server_process=shared_file.process,
|
|
2083
|
+
log_dir=shared_file.log_dir,
|
|
2084
|
+
extra={"target_connection_mode": "file", "shared_key": shared_file.key, "shared_refcount": len(shared_file.owners)},
|
|
2085
|
+
)
|
|
2086
|
+
r_base = str(db_path)
|
|
2087
|
+
r_value = shared_file.base_url
|
|
2088
|
+
mode = "ws"
|
|
2089
|
+
target_connection_mode = "file"
|
|
2090
|
+
elif mode == "server":
|
|
2091
|
+
if not r_value:
|
|
2092
|
+
raise ValueError("connection_value or base_url must contain server/infobase for connection_mode='server'")
|
|
2093
|
+
r_base = r_value
|
|
2094
|
+
target_connection_mode = "server"
|
|
2095
|
+
else:
|
|
2096
|
+
raise ValueError(f"Unsupported connection mode after normalization: {mode!r}")
|
|
2097
|
+
if "target_connection_mode" not in locals():
|
|
2098
|
+
target_connection_mode = mode
|
|
2099
|
+
|
|
2100
|
+
if not r_user and not r_pass and r_base:
|
|
2101
|
+
credentials = _credentials_store().lookup(r_base)
|
|
2102
|
+
if credentials:
|
|
2103
|
+
r_user = credentials["username"]
|
|
2104
|
+
r_pass = credentials["password"]
|
|
2105
|
+
|
|
2106
|
+
if _is_placeholder_password(r_pass):
|
|
2107
|
+
raise ValueError(
|
|
2108
|
+
"Передан пароль-заглушка из звёздочек вместо реального пароля. "
|
|
2109
|
+
"Укажите настоящий пароль или сохраните корректные креды через credentials_save()."
|
|
2110
|
+
)
|
|
2111
|
+
|
|
2112
|
+
sess.base_url = r_base
|
|
2113
|
+
sess.username = r_user
|
|
2114
|
+
sess.password = r_pass
|
|
2115
|
+
if sess.rag_snapshot and r_base:
|
|
2116
|
+
_save_rag_binding(r_base, sess.rag_snapshot)
|
|
2117
|
+
else:
|
|
2118
|
+
bound_snapshot = _snapshot_for_base_url(r_base) if r_base else ""
|
|
2119
|
+
if bound_snapshot:
|
|
2120
|
+
sess.rag_snapshot = bound_snapshot
|
|
2121
|
+
runtime.kill_process(sess.test_client_process, "test-client")
|
|
2122
|
+
sess.client_log_dir.mkdir(parents=True, exist_ok=True)
|
|
2123
|
+
client_out_log = sess.client_log_dir / "test_client_1c_out.log"
|
|
2124
|
+
client_stdout_log = "test_client_stdout.log"
|
|
2125
|
+
argv = runtime.build_test_client_argv(
|
|
2126
|
+
r_onec,
|
|
2127
|
+
connection_mode=mode,
|
|
2128
|
+
connection_value=r_value,
|
|
2129
|
+
port=r_port,
|
|
2130
|
+
username=r_user,
|
|
2131
|
+
password=r_pass,
|
|
2132
|
+
extra_args=extra_args,
|
|
2133
|
+
execute=execute,
|
|
2134
|
+
command_parameter=command_parameter,
|
|
2135
|
+
out_log=client_out_log,
|
|
2136
|
+
)
|
|
2137
|
+
sess.test_client_process = runtime.start_test_client(
|
|
2138
|
+
argv,
|
|
2139
|
+
log_dir=sess.client_log_dir,
|
|
2140
|
+
display=sess.display,
|
|
2141
|
+
log_name=client_stdout_log,
|
|
2142
|
+
)
|
|
2143
|
+
params = {"base_url": r_base, "username": r_user, "password": r_pass,
|
|
2144
|
+
"port": r_port, "host": host, "client_pid": sess.test_client_process.pid,
|
|
2145
|
+
"client_log": str(client_out_log), "client_stdout_log": str(sess.client_log_dir / client_stdout_log),
|
|
2146
|
+
"connection_mode": mode, "connection_value": r_value,
|
|
2147
|
+
"target_connection_mode": target_connection_mode}
|
|
2148
|
+
_touch_session(sess)
|
|
2149
|
+
try:
|
|
2150
|
+
result = await sess.bridge.call("attach_test_client", params)
|
|
2151
|
+
except Exception:
|
|
2152
|
+
runtime.kill_process(sess.test_client_process, "test-client")
|
|
2153
|
+
sess.test_client_process = None
|
|
2154
|
+
raise
|
|
2155
|
+
return _record_tool(sess.session_id, "connect_test_client", params, result)
|
|
2156
|
+
|
|
2157
|
+
|
|
2158
|
+
def _auto_maximize_on_error(display: str) -> None:
|
|
2159
|
+
try:
|
|
2160
|
+
_maximize_external_window(title_contains="", display=display, width=None, height=None)
|
|
2161
|
+
except Exception as exc:
|
|
2162
|
+
logging.debug("auto maximize after failed connect_test_client failed: %s", exc)
|
|
2163
|
+
|
|
2164
|
+
|
|
2165
|
+
def _auto_maximize_test_client_result(result: Any, display: str) -> None:
|
|
2166
|
+
try:
|
|
2167
|
+
mr = _maximize_external_window(title_contains="", display=display, width=None, height=None)
|
|
2168
|
+
if isinstance(result, dict):
|
|
2169
|
+
result["auto_maximize"] = mr
|
|
2170
|
+
except Exception as exc:
|
|
2171
|
+
logging.debug("auto maximize after connect_test_client failed: %s", exc)
|
|
2172
|
+
if isinstance(result, dict):
|
|
2173
|
+
result["auto_maximize_error"] = str(exc)
|
|
2174
|
+
|
|
2175
|
+
|
|
2176
|
+
@mcp.tool()
|
|
2177
|
+
async def active_window(*,
|
|
2178
|
+
session_id: Annotated[str, "Session id"] = DEFAULT_SESSION,
|
|
2179
|
+
) -> Any:
|
|
2180
|
+
"""Return active 1C test client window state."""
|
|
2181
|
+
return await _bridge_call_recorded(session_id, "active_window", {})
|
|
2182
|
+
|
|
2183
|
+
|
|
2184
|
+
@mcp.tool()
|
|
2185
|
+
async def windows_list(*,
|
|
2186
|
+
max_depth: Annotated[int, "Serialization depth for each window"] = 2,
|
|
2187
|
+
session_id: Annotated[str, "Session id"] = DEFAULT_SESSION,
|
|
2188
|
+
) -> Any:
|
|
2189
|
+
"""Return all windows of the tested 1C client application."""
|
|
2190
|
+
return await _bridge_call_recorded(session_id, "windows_list", {"max_depth": max_depth})
|
|
2191
|
+
|
|
2192
|
+
|
|
2193
|
+
@mcp.tool()
|
|
2194
|
+
async def activate_window(
|
|
2195
|
+
index: Annotated[int, "Window index from windows_list; -1 means search by name/title"] = -1,
|
|
2196
|
+
title: Annotated[str, "Case-insensitive title substring"] = "",
|
|
2197
|
+
name: Annotated[str, "Exact window name when available"] = "",
|
|
2198
|
+
max_depth: Annotated[int, "Serialization depth for returned window"] = 2,
|
|
2199
|
+
*,
|
|
2200
|
+
session_id: Annotated[str, "Session id"] = DEFAULT_SESSION,
|
|
2201
|
+
) -> Any:
|
|
2202
|
+
"""Activate/switch to a tested 1C client window by index, title or name."""
|
|
2203
|
+
return await _bridge_call_recorded(
|
|
2204
|
+
session_id,
|
|
2205
|
+
"activate_window",
|
|
2206
|
+
{"index": index, "title": title, "name": name, "max_depth": max_depth},
|
|
2207
|
+
)
|
|
2208
|
+
|
|
2209
|
+
|
|
2210
|
+
@mcp.tool()
|
|
2211
|
+
async def goto_start_page(*,
|
|
2212
|
+
max_depth: Annotated[int, "Serialization depth for returned window"] = 2,
|
|
2213
|
+
session_id: Annotated[str, "Session id"] = DEFAULT_SESSION,
|
|
2214
|
+
) -> Any:
|
|
2215
|
+
"""Navigate the main test-client window to the start page."""
|
|
2216
|
+
return await _bridge_call_recorded(
|
|
2217
|
+
session_id,
|
|
2218
|
+
"goto_start_page",
|
|
2219
|
+
{"max_depth": max_depth},
|
|
2220
|
+
)
|
|
2221
|
+
|
|
2222
|
+
|
|
2223
|
+
@mcp.tool()
|
|
2224
|
+
async def goto_previous_window(*,
|
|
2225
|
+
max_depth: Annotated[int, "Serialization depth for returned window"] = 2,
|
|
2226
|
+
session_id: Annotated[str, "Session id"] = DEFAULT_SESSION,
|
|
2227
|
+
) -> Any:
|
|
2228
|
+
"""Navigate the main test-client window to the previous open window."""
|
|
2229
|
+
return await _bridge_call_recorded(
|
|
2230
|
+
session_id,
|
|
2231
|
+
"goto_previous_window",
|
|
2232
|
+
{"max_depth": max_depth},
|
|
2233
|
+
)
|
|
2234
|
+
|
|
2235
|
+
|
|
2236
|
+
@mcp.tool()
|
|
2237
|
+
async def goto_next_window(*,
|
|
2238
|
+
max_depth: Annotated[int, "Serialization depth for returned window"] = 2,
|
|
2239
|
+
session_id: Annotated[str, "Session id"] = DEFAULT_SESSION,
|
|
2240
|
+
) -> Any:
|
|
2241
|
+
"""Navigate the main test-client window to the next open window."""
|
|
2242
|
+
return await _bridge_call_recorded(
|
|
2243
|
+
session_id,
|
|
2244
|
+
"goto_next_window",
|
|
2245
|
+
{"max_depth": max_depth},
|
|
2246
|
+
)
|
|
2247
|
+
|
|
2248
|
+
|
|
2249
|
+
|
|
2250
|
+
@mcp.tool()
|
|
2251
|
+
async def wait_object(
|
|
2252
|
+
name: Annotated[str, "Object technical name"] = "",
|
|
2253
|
+
title: Annotated[str, "Object title substring"] = "",
|
|
2254
|
+
type: Annotated[str, "1C test object type"] = "",
|
|
2255
|
+
timeout: Annotated[int, "Timeout in seconds"] = 15,
|
|
2256
|
+
max_depth: Annotated[int, "Serialization depth for returned object"] = 2,
|
|
2257
|
+
*,
|
|
2258
|
+
session_id: Annotated[str, "Session id"] = DEFAULT_SESSION,
|
|
2259
|
+
) -> Any:
|
|
2260
|
+
"""Wait until an object appears in the tested client UI tree."""
|
|
2261
|
+
return await _bridge_call_recorded(session_id, "wait_object", {"name": name, "title": title, "type": type, "timeout": timeout, "max_depth": max_depth})
|
|
2262
|
+
|
|
2263
|
+
|
|
2264
|
+
@mcp.tool()
|
|
2265
|
+
async def wait_form(
|
|
2266
|
+
form_name: Annotated[str, "1C metadata form name, e.g. Catalog.X.Form.ItemForm"] = "",
|
|
2267
|
+
title: Annotated[str, "Form title substring"] = "",
|
|
2268
|
+
timeout: Annotated[int, "Timeout in seconds"] = 15,
|
|
2269
|
+
max_depth: Annotated[int, "Serialization depth for returned form"] = 2,
|
|
2270
|
+
*,
|
|
2271
|
+
session_id: Annotated[str, "Session id"] = DEFAULT_SESSION,
|
|
2272
|
+
) -> Any:
|
|
2273
|
+
"""Wait until a form appears."""
|
|
2274
|
+
return await _bridge_call_recorded(session_id, "wait_form", {"form_name": form_name, "title": title, "timeout": timeout, "max_depth": max_depth})
|
|
2275
|
+
|
|
2276
|
+
|
|
2277
|
+
@mcp.tool()
|
|
2278
|
+
async def wait_window(
|
|
2279
|
+
title: Annotated[str, "Window title substring"] = "",
|
|
2280
|
+
name: Annotated[str, "Window exact name when available"] = "",
|
|
2281
|
+
timeout: Annotated[int, "Timeout in seconds"] = 15,
|
|
2282
|
+
max_depth: Annotated[int, "Serialization depth for returned window"] = 2,
|
|
2283
|
+
*,
|
|
2284
|
+
session_id: Annotated[str, "Session id"] = DEFAULT_SESSION,
|
|
2285
|
+
) -> Any:
|
|
2286
|
+
"""Wait until a client window appears."""
|
|
2287
|
+
return await _bridge_call_recorded(session_id, "wait_window", {"title": title, "name": name, "timeout": timeout, "max_depth": max_depth})
|
|
2288
|
+
|
|
2289
|
+
|
|
2290
|
+
@mcp.tool()
|
|
2291
|
+
async def user_messages(*,
|
|
2292
|
+
session_id: Annotated[str, "Session id"] = DEFAULT_SESSION,
|
|
2293
|
+
) -> Any:
|
|
2294
|
+
"""Return current user messages shown in the active test-client window."""
|
|
2295
|
+
return await _bridge_call_recorded(session_id, "user_messages", {})
|
|
2296
|
+
|
|
2297
|
+
|
|
2298
|
+
@mcp.tool()
|
|
2299
|
+
async def close_user_messages_panel(*,
|
|
2300
|
+
session_id: Annotated[str, "Session id"] = DEFAULT_SESSION,
|
|
2301
|
+
) -> Any:
|
|
2302
|
+
"""Close the user messages panel in the active test-client window."""
|
|
2303
|
+
return await _bridge_call_recorded(session_id, "close_user_messages_panel", {})
|
|
2304
|
+
|
|
2305
|
+
|
|
2306
|
+
@mcp.tool()
|
|
2307
|
+
async def current_error_info(*,
|
|
2308
|
+
session_id: Annotated[str, "Session id"] = DEFAULT_SESSION,
|
|
2309
|
+
) -> Any:
|
|
2310
|
+
"""Return current 1C script error information displayed in the tested client, if any."""
|
|
2311
|
+
return await _bridge_call_recorded(session_id, "current_error_info", {})
|
|
2312
|
+
|
|
2313
|
+
|
|
2314
|
+
@mcp.tool()
|
|
2315
|
+
async def set_file_dialog_result(
|
|
2316
|
+
paths: Annotated[str, "JSON array of file paths, or a single file path string"],
|
|
2317
|
+
*,
|
|
2318
|
+
session_id: Annotated[str, "Session id"] = DEFAULT_SESSION,
|
|
2319
|
+
) -> Any:
|
|
2320
|
+
"""Preload the result for the next 1C file selection dialog."""
|
|
2321
|
+
return await _bridge_call_recorded(session_id, "set_file_dialog_result", {"paths": paths})
|
|
2322
|
+
|
|
2323
|
+
|
|
2324
|
+
@mcp.tool()
|
|
2325
|
+
async def clear_file_dialog_result(*,
|
|
2326
|
+
session_id: Annotated[str, "Session id"] = DEFAULT_SESSION,
|
|
2327
|
+
) -> Any:
|
|
2328
|
+
"""Clear queued file selection dialog results."""
|
|
2329
|
+
return await _bridge_call_recorded(session_id, "clear_file_dialog_result", {})
|
|
2330
|
+
|
|
2331
|
+
|
|
2332
|
+
@mcp.tool()
|
|
2333
|
+
async def field_info(
|
|
2334
|
+
field_name: Annotated[str, "Field name, or title when search_by_title=true"],
|
|
2335
|
+
search_by_title: Annotated[bool, "Search by visible title instead of technical name"] = False,
|
|
2336
|
+
*,
|
|
2337
|
+
session_id: Annotated[str, "Session id"] = DEFAULT_SESSION,
|
|
2338
|
+
) -> Any:
|
|
2339
|
+
"""Return diagnostics for a form field: visibility, availability, readonly, kind, title."""
|
|
2340
|
+
return await _bridge_call_recorded(session_id, "field_info", {"field_name": field_name, "search_by_title": search_by_title})
|
|
2341
|
+
|
|
2342
|
+
|
|
2343
|
+
@mcp.tool()
|
|
2344
|
+
async def field_value_text(
|
|
2345
|
+
field_name: Annotated[str, "Field name, or title when search_by_title=true"],
|
|
2346
|
+
search_by_title: Annotated[bool, "Search by visible title instead of technical name"] = False,
|
|
2347
|
+
*,
|
|
2348
|
+
session_id: Annotated[str, "Session id"] = DEFAULT_SESSION,
|
|
2349
|
+
) -> Any:
|
|
2350
|
+
"""Return field data presentation text via ПолучитьПредставлениеДанных()."""
|
|
2351
|
+
return await _bridge_call_recorded(session_id, "field_value_text", {"field_name": field_name, "search_by_title": search_by_title})
|
|
2352
|
+
|
|
2353
|
+
|
|
2354
|
+
@mcp.tool()
|
|
2355
|
+
async def field_tooltip(
|
|
2356
|
+
field_name: Annotated[str, "Field name, or title when search_by_title=true"],
|
|
2357
|
+
search_by_title: Annotated[bool, "Search by visible title instead of technical name"] = False,
|
|
2358
|
+
*,
|
|
2359
|
+
session_id: Annotated[str, "Session id"] = DEFAULT_SESSION,
|
|
2360
|
+
) -> Any:
|
|
2361
|
+
"""Return field tooltip text."""
|
|
2362
|
+
return await _bridge_call_recorded(session_id, "field_tooltip", {"field_name": field_name, "search_by_title": search_by_title})
|
|
2363
|
+
|
|
2364
|
+
|
|
2365
|
+
@mcp.tool()
|
|
2366
|
+
async def form_next_element(*,
|
|
2367
|
+
session_id: Annotated[str, "Session id"] = DEFAULT_SESSION,
|
|
2368
|
+
) -> Any:
|
|
2369
|
+
"""Move focus to the next form element and return the current element."""
|
|
2370
|
+
return await _bridge_call_recorded(session_id, "form_next_element", {})
|
|
2371
|
+
|
|
2372
|
+
|
|
2373
|
+
@mcp.tool()
|
|
2374
|
+
async def form_previous_element(*,
|
|
2375
|
+
session_id: Annotated[str, "Session id"] = DEFAULT_SESSION,
|
|
2376
|
+
) -> Any:
|
|
2377
|
+
"""Move focus to the previous form element and return the current element."""
|
|
2378
|
+
return await _bridge_call_recorded(session_id, "form_previous_element", {})
|
|
2379
|
+
|
|
2380
|
+
|
|
2381
|
+
@mcp.tool()
|
|
2382
|
+
async def form_state(*,
|
|
2383
|
+
session_id: Annotated[str, "Session id"] = DEFAULT_SESSION,
|
|
2384
|
+
) -> Any:
|
|
2385
|
+
"""Return active form availability/modified/readonly state."""
|
|
2386
|
+
return await _bridge_call_recorded(session_id, "form_state", {})
|
|
2387
|
+
|
|
2388
|
+
|
|
2389
|
+
@mcp.tool()
|
|
2390
|
+
async def form_default_button(*,
|
|
2391
|
+
session_id: Annotated[str, "Session id"] = DEFAULT_SESSION,
|
|
2392
|
+
) -> Any:
|
|
2393
|
+
"""Return the active form default button, when available."""
|
|
2394
|
+
return await _bridge_call_recorded(session_id, "form_default_button", {})
|
|
2395
|
+
|
|
2396
|
+
|
|
2397
|
+
@mcp.tool()
|
|
2398
|
+
async def wait_form_closed(
|
|
2399
|
+
timeout: Annotated[int, "Timeout in seconds"] = 15,
|
|
2400
|
+
*,
|
|
2401
|
+
session_id: Annotated[str, "Session id"] = DEFAULT_SESSION,
|
|
2402
|
+
) -> Any:
|
|
2403
|
+
"""Wait until the active form closes."""
|
|
2404
|
+
return await _bridge_call_recorded(session_id, "wait_form_closed", {"timeout": timeout})
|
|
2405
|
+
|
|
2406
|
+
|
|
2407
|
+
@mcp.tool()
|
|
2408
|
+
async def object_command_panel(
|
|
2409
|
+
name: Annotated[str, "Object technical name"] = "",
|
|
2410
|
+
title: Annotated[str, "Object title substring"] = "",
|
|
2411
|
+
type: Annotated[str, "1C test object type"] = "",
|
|
2412
|
+
max_depth: Annotated[int, "Serialization depth for returned panel"] = 2,
|
|
2413
|
+
*,
|
|
2414
|
+
session_id: Annotated[str, "Session id"] = DEFAULT_SESSION,
|
|
2415
|
+
) -> Any:
|
|
2416
|
+
"""Return command panel for a found UI object, when supported."""
|
|
2417
|
+
return await _bridge_call_recorded(session_id, "object_command_panel", {"name": name, "title": title, "type": type, "max_depth": max_depth})
|
|
2418
|
+
|
|
2419
|
+
|
|
2420
|
+
@mcp.tool()
|
|
2421
|
+
async def object_context_menu(
|
|
2422
|
+
name: Annotated[str, "Object technical name"] = "",
|
|
2423
|
+
title: Annotated[str, "Object title substring"] = "",
|
|
2424
|
+
type: Annotated[str, "1C test object type"] = "",
|
|
2425
|
+
max_depth: Annotated[int, "Serialization depth for returned menu"] = 2,
|
|
2426
|
+
*,
|
|
2427
|
+
session_id: Annotated[str, "Session id"] = DEFAULT_SESSION,
|
|
2428
|
+
) -> Any:
|
|
2429
|
+
"""Return context menu for a found UI object, when supported."""
|
|
2430
|
+
return await _bridge_call_recorded(session_id, "object_context_menu", {"name": name, "title": title, "type": type, "max_depth": max_depth})
|
|
2431
|
+
|
|
2432
|
+
|
|
2433
|
+
@mcp.tool()
|
|
2434
|
+
async def window_command_interface(
|
|
2435
|
+
max_depth: Annotated[int, "Serialization depth for returned command interface"] = 2,
|
|
2436
|
+
*,
|
|
2437
|
+
session_id: Annotated[str, "Session id"] = DEFAULT_SESSION,
|
|
2438
|
+
) -> Any:
|
|
2439
|
+
"""Return active window command interface, when supported."""
|
|
2440
|
+
return await _bridge_call_recorded(session_id, "window_command_interface", {"max_depth": max_depth})
|
|
2441
|
+
|
|
2442
|
+
|
|
2443
|
+
@mcp.tool()
|
|
2444
|
+
async def group_expand(name: Annotated[str, "Group technical name"] = "", title: Annotated[str, "Group title substring"] = "", *, session_id: Annotated[str, "Session id"] = DEFAULT_SESSION) -> Any:
|
|
2445
|
+
"""Expand a collapsible form group."""
|
|
2446
|
+
return await _bridge_call_recorded(session_id, "group_expand", {"name": name, "title": title})
|
|
2447
|
+
|
|
2448
|
+
|
|
2449
|
+
@mcp.tool()
|
|
2450
|
+
async def group_collapse(name: Annotated[str, "Group technical name"] = "", title: Annotated[str, "Group title substring"] = "", *, session_id: Annotated[str, "Session id"] = DEFAULT_SESSION) -> Any:
|
|
2451
|
+
"""Collapse a collapsible form group."""
|
|
2452
|
+
return await _bridge_call_recorded(session_id, "group_collapse", {"name": name, "title": title})
|
|
2453
|
+
|
|
2454
|
+
|
|
2455
|
+
@mcp.tool()
|
|
2456
|
+
async def group_current_page(name: Annotated[str, "Group technical name"] = "", title: Annotated[str, "Group title substring"] = "", max_depth: Annotated[int, "Serialization depth for returned page"] = 2, *, session_id: Annotated[str, "Session id"] = DEFAULT_SESSION) -> Any:
|
|
2457
|
+
"""Return current page of a page-group form element."""
|
|
2458
|
+
return await _bridge_call_recorded(session_id, "group_current_page", {"name": name, "title": title, "max_depth": max_depth})
|
|
2459
|
+
|
|
2460
|
+
|
|
2461
|
+
@mcp.tool()
|
|
2462
|
+
async def group_state(name: Annotated[str, "Group technical name"] = "", title: Annotated[str, "Group title substring"] = "", *, session_id: Annotated[str, "Session id"] = DEFAULT_SESSION) -> Any:
|
|
2463
|
+
"""Return form group expanded/visible/available state."""
|
|
2464
|
+
return await _bridge_call_recorded(session_id, "group_state", {"name": name, "title": title})
|
|
2465
|
+
|
|
2466
|
+
|
|
2467
|
+
@mcp.tool()
|
|
2468
|
+
async def decoration_links(name: Annotated[str, "Decoration technical name"] = "", title: Annotated[str, "Decoration title substring"] = "", *, session_id: Annotated[str, "Session id"] = DEFAULT_SESSION) -> Any:
|
|
2469
|
+
"""Return hyperlink presentations from a formatted-string decoration."""
|
|
2470
|
+
return await _bridge_call_recorded(session_id, "decoration_links", {"name": name, "title": title})
|
|
2471
|
+
|
|
2472
|
+
|
|
2473
|
+
@mcp.tool()
|
|
2474
|
+
async def decoration_click_link(name: Annotated[str, "Decoration technical name"] = "", title: Annotated[str, "Decoration title substring"] = "", presentation: Annotated[str, "Hyperlink presentation to click"] = "", *, session_id: Annotated[str, "Session id"] = DEFAULT_SESSION) -> Any:
|
|
2475
|
+
"""Click a hyperlink inside a formatted-string decoration."""
|
|
2476
|
+
return await _bridge_call_recorded(session_id, "decoration_click_link", {"name": name, "title": title, "presentation": presentation})
|
|
2477
|
+
|
|
2478
|
+
|
|
2479
|
+
@mcp.tool()
|
|
2480
|
+
async def decoration_click(name: Annotated[str, "Decoration technical name"] = "", title: Annotated[str, "Decoration title substring"] = "", *, session_id: Annotated[str, "Session id"] = DEFAULT_SESSION) -> Any:
|
|
2481
|
+
"""Click a form decoration."""
|
|
2482
|
+
return await _bridge_call_recorded(session_id, "decoration_click", {"name": name, "title": title})
|
|
2483
|
+
|
|
2484
|
+
|
|
2485
|
+
@mcp.tool()
|
|
2486
|
+
async def maximize_test_client_window(*,
|
|
2487
|
+
session_id: Annotated[str, "Session id"] = DEFAULT_SESSION,
|
|
2488
|
+
) -> Any:
|
|
2489
|
+
"""Activate and maximize the 1C test-client OS window via wmctrl/xdotool/Xlib."""
|
|
2490
|
+
sess = _session(session_id)
|
|
2491
|
+
return _maximize_external_window(title_contains="", display=sess.display, width=None, height=None)
|
|
2492
|
+
|
|
2493
|
+
|
|
2494
|
+
@mcp.tool()
|
|
2495
|
+
def maximize_test_client_external_window(
|
|
2496
|
+
title_contains: Annotated[str, "Window title substring; empty means choose likely 1C test-client window"] = "",
|
|
2497
|
+
display: Annotated[str, "X11 display override (empty = use session's)"] = "",
|
|
2498
|
+
width: Annotated[int, "Target width; 0 means display width"] = 0,
|
|
2499
|
+
height: Annotated[int, "Target height; 0 means display height"] = 0,
|
|
2500
|
+
*,
|
|
2501
|
+
session_id: Annotated[str, "Session id"] = DEFAULT_SESSION,
|
|
2502
|
+
) -> dict[str, Any]:
|
|
2503
|
+
"""Maximize/resize the 1C test-client OS window: wmctrl -> xdotool -> Xlib fallback."""
|
|
2504
|
+
sess = _session(session_id)
|
|
2505
|
+
return _maximize_external_window(
|
|
2506
|
+
title_contains=title_contains,
|
|
2507
|
+
display=display or sess.display,
|
|
2508
|
+
width=width or None,
|
|
2509
|
+
height=height or None,
|
|
2510
|
+
)
|
|
2511
|
+
|
|
2512
|
+
|
|
2513
|
+
@mcp.tool()
|
|
2514
|
+
async def ui_tree(max_depth: Annotated[int, "Maximum UI tree depth"] = 4,
|
|
2515
|
+
include_rag_types: Annotated[bool, "Enrich UI elements with best-effort types from the local RAG index"] = True,
|
|
2516
|
+
include_hidden: Annotated[bool, "Include invisible form elements; visible/available flags are always returned"] = False,
|
|
2517
|
+
rag_snapshot: Annotated[str, "Optional RAG snapshot name/id used for type enrichment"] = "",
|
|
2518
|
+
*,
|
|
2519
|
+
session_id: Annotated[str, "Session id"] = DEFAULT_SESSION,
|
|
2520
|
+
) -> Any:
|
|
2521
|
+
"""Return a serialized tree of the active window/forms/elements.
|
|
2522
|
+
|
|
2523
|
+
When RAG data is available, each matching element gets `rag_type` and `rag`
|
|
2524
|
+
fields with metadata-derived type/source information. Runtime 1C UI data is
|
|
2525
|
+
returned unchanged when the RAG index has no match. Invisible elements are
|
|
2526
|
+
omitted by default; pass include_hidden=true to inspect the full 1C form tree.
|
|
2527
|
+
"""
|
|
2528
|
+
tree = await _bridge_call_recorded(session_id, "ui_tree", {"max_depth": max_depth, "include_hidden": include_hidden})
|
|
2529
|
+
if include_rag_types:
|
|
2530
|
+
sess = _session(session_id)
|
|
2531
|
+
tree = _enrich_ui_tree_with_rag_types(tree, snapshot=rag_snapshot or sess.rag_snapshot or None)
|
|
2532
|
+
return tree
|
|
2533
|
+
|
|
2534
|
+
|
|
2535
|
+
@mcp.tool()
|
|
2536
|
+
async def command_bar(*,
|
|
2537
|
+
session_id: Annotated[str, "Session id"] = DEFAULT_SESSION,
|
|
2538
|
+
) -> Any:
|
|
2539
|
+
"""Return command interface/panel information for the active window/form when available."""
|
|
2540
|
+
return await _bridge_call_recorded(session_id, "command_bar", {})
|
|
2541
|
+
|
|
2542
|
+
|
|
2543
|
+
@mcp.tool()
|
|
2544
|
+
async def click_button(
|
|
2545
|
+
name: Annotated[str, "Button name or title text"],
|
|
2546
|
+
search_by_title: Annotated[bool, "If true, match visible title instead of object name"] = False,
|
|
2547
|
+
*,
|
|
2548
|
+
session_id: Annotated[str, "Session id"] = DEFAULT_SESSION,
|
|
2549
|
+
) -> Any:
|
|
2550
|
+
"""Find a button in the active UI tree and press it."""
|
|
2551
|
+
return await _bridge_call_recorded(session_id, "click_button", {"name": name, "search_by_title": search_by_title})
|
|
2552
|
+
|
|
2553
|
+
|
|
2554
|
+
@mcp.tool()
|
|
2555
|
+
async def find_object(
|
|
2556
|
+
name: Annotated[str, "Object technical name"] = "",
|
|
2557
|
+
title: Annotated[str, "Object visible title/caption"] = "",
|
|
2558
|
+
type: Annotated[str, "1C test object type, e.g. ТестируемаяФорма"] = "",
|
|
2559
|
+
max_depth: Annotated[int, "Maximum search depth"] = 8,
|
|
2560
|
+
*,
|
|
2561
|
+
session_id: Annotated[str, "Session id"] = DEFAULT_SESSION,
|
|
2562
|
+
) -> Any:
|
|
2563
|
+
"""Find an object in the active UI hierarchy by name, title, or test object type."""
|
|
2564
|
+
return await _bridge_call_recorded(session_id, "find_object", {"name": name, "title": title, "type": type, "max_depth": max_depth})
|
|
2565
|
+
|
|
2566
|
+
|
|
2567
|
+
@mcp.tool()
|
|
2568
|
+
async def get_object(
|
|
2569
|
+
name: Annotated[str, "Object name pattern"] = "",
|
|
2570
|
+
title: Annotated[str, "Object title pattern"] = "",
|
|
2571
|
+
type: Annotated[str, "1C tested object type name, optional"] = "",
|
|
2572
|
+
timeout: Annotated[int, "Search timeout in seconds"] = 0,
|
|
2573
|
+
max_depth: Annotated[int, "Children depth to serialize"] = 2,
|
|
2574
|
+
*,
|
|
2575
|
+
session_id: Annotated[str, "Session id"] = DEFAULT_SESSION,
|
|
2576
|
+
) -> Any:
|
|
2577
|
+
"""Call TestedApplication.GetObject with optional wildcard name/title."""
|
|
2578
|
+
return await _bridge_call_recorded(session_id, "get_object", {"name": name, "title": title, "type": type, "timeout": timeout, "max_depth": max_depth})
|
|
2579
|
+
|
|
2580
|
+
|
|
2581
|
+
@mcp.tool()
|
|
2582
|
+
async def activate_object(
|
|
2583
|
+
name: Annotated[str, "Object technical name"] = "",
|
|
2584
|
+
title: Annotated[str, "Object visible title/caption"] = "",
|
|
2585
|
+
type: Annotated[str, "1C test object type"] = "",
|
|
2586
|
+
max_depth: Annotated[int, "Maximum search depth"] = 8,
|
|
2587
|
+
*,
|
|
2588
|
+
session_id: Annotated[str, "Session id"] = DEFAULT_SESSION,
|
|
2589
|
+
) -> Any:
|
|
2590
|
+
"""Find and activate a UI object in the active test client window."""
|
|
2591
|
+
return await _bridge_call_recorded(session_id, "activate_object", {"name": name, "title": title, "type": type, "max_depth": max_depth})
|
|
2592
|
+
|
|
2593
|
+
|
|
2594
|
+
@mcp.tool()
|
|
2595
|
+
async def open_navigation_link(navigation_link: Annotated[str, "Explicit 1C navigation link, e.g. e1cib/list/..."],
|
|
2596
|
+
*,
|
|
2597
|
+
session_id: Annotated[str, "Session id"] = DEFAULT_SESSION,
|
|
2598
|
+
) -> Any:
|
|
2599
|
+
"""Open an explicit 1C navigation link in the active test-client window.
|
|
2600
|
+
|
|
2601
|
+
This tool intentionally rejects arbitrary form command names such as
|
|
2602
|
+
`ФормаНайти` or `СледующаяСтрока`. Use first-class MCP tools for form
|
|
2603
|
+
actions, table navigation, search/settings, and dynamic-list operations.
|
|
2604
|
+
"""
|
|
2605
|
+
if not navigation_link.startswith("e1cib/"):
|
|
2606
|
+
raise ValueError(f"open_navigation_link accepts only explicit e1cib/... navigation links; rejected: {navigation_link}")
|
|
2607
|
+
return await _bridge_call_recorded(session_id, "open_navigation_link", {"navigation_link": navigation_link})
|
|
2608
|
+
|
|
2609
|
+
|
|
2610
|
+
@mcp.tool()
|
|
2611
|
+
async def current_element(*,
|
|
2612
|
+
session_id: Annotated[str, "Session id"] = DEFAULT_SESSION,
|
|
2613
|
+
) -> Any:
|
|
2614
|
+
"""Return the current active form element."""
|
|
2615
|
+
return await _bridge_call_recorded(session_id, "current_element", {})
|
|
2616
|
+
|
|
2617
|
+
|
|
2618
|
+
@mcp.tool()
|
|
2619
|
+
async def table_current_row(
|
|
2620
|
+
name: Annotated[str, "Table technical name"] = "",
|
|
2621
|
+
title: Annotated[str, "Table visible title"] = "",
|
|
2622
|
+
max_depth: Annotated[int, "Maximum search depth"] = 8,
|
|
2623
|
+
*,
|
|
2624
|
+
session_id: Annotated[str, "Session id"] = DEFAULT_SESSION,
|
|
2625
|
+
) -> Any:
|
|
2626
|
+
"""Return current row of a tested table as key/value text mapping."""
|
|
2627
|
+
return await _bridge_call_recorded(session_id, "table_current_row", {"name": name, "title": title, "max_depth": max_depth})
|
|
2628
|
+
|
|
2629
|
+
|
|
2630
|
+
@mcp.tool()
|
|
2631
|
+
async def table_selected_rows(
|
|
2632
|
+
name: Annotated[str, "Table technical name"] = "",
|
|
2633
|
+
title: Annotated[str, "Table visible title"] = "",
|
|
2634
|
+
max_depth: Annotated[int, "Maximum search depth"] = 8,
|
|
2635
|
+
*,
|
|
2636
|
+
session_id: Annotated[str, "Session id"] = DEFAULT_SESSION,
|
|
2637
|
+
) -> Any:
|
|
2638
|
+
"""Return selected rows of a tested table as key/value text mappings."""
|
|
2639
|
+
return await _bridge_call_recorded(session_id, "table_selected_rows", {"name": name, "title": title, "max_depth": max_depth})
|
|
2640
|
+
|
|
2641
|
+
|
|
2642
|
+
@mcp.tool()
|
|
2643
|
+
async def table_cell_text(
|
|
2644
|
+
cell: Annotated[str, "Column name or index; empty returns current cell"] = "",
|
|
2645
|
+
name: Annotated[str, "Table technical name"] = "",
|
|
2646
|
+
title: Annotated[str, "Table visible title"] = "",
|
|
2647
|
+
max_depth: Annotated[int, "Maximum search depth"] = 8,
|
|
2648
|
+
*,
|
|
2649
|
+
session_id: Annotated[str, "Session id"] = DEFAULT_SESSION,
|
|
2650
|
+
) -> Any:
|
|
2651
|
+
"""Return cell text from a tested table via GetCellText()."""
|
|
2652
|
+
return await _bridge_call_recorded(session_id, "table_cell_text", {"name": name, "title": title, "cell": cell, "max_depth": max_depth})
|
|
2653
|
+
|
|
2654
|
+
|
|
2655
|
+
@mcp.tool()
|
|
2656
|
+
async def tabular_documents(
|
|
2657
|
+
include_text: Annotated[bool, "Include tabular document text"] = False,
|
|
2658
|
+
max_depth: Annotated[int, "Maximum search depth"] = 8,
|
|
2659
|
+
*,
|
|
2660
|
+
session_id: Annotated[str, "Session id"] = DEFAULT_SESSION,
|
|
2661
|
+
) -> Any:
|
|
2662
|
+
"""List tabular document fields in the active window; optionally include their text."""
|
|
2663
|
+
return await _bridge_call_recorded(session_id, "tabular_documents", {"include_text": include_text, "max_depth": max_depth})
|
|
2664
|
+
|
|
2665
|
+
|
|
2666
|
+
@mcp.tool()
|
|
2667
|
+
async def tabular_document_text(
|
|
2668
|
+
name: Annotated[str, "Tabular document field technical name"] = "",
|
|
2669
|
+
title: Annotated[str, "Tabular document field visible title"] = "",
|
|
2670
|
+
max_depth: Annotated[int, "Maximum search depth"] = 8,
|
|
2671
|
+
*,
|
|
2672
|
+
session_id: Annotated[str, "Session id"] = DEFAULT_SESSION,
|
|
2673
|
+
) -> Any:
|
|
2674
|
+
"""Return text and dimensions of a tabular document field in the active window."""
|
|
2675
|
+
return await _bridge_call_recorded(session_id, "tabular_document_text", {"name": name, "title": title, "max_depth": max_depth})
|
|
2676
|
+
|
|
2677
|
+
|
|
2678
|
+
@mcp.tool()
|
|
2679
|
+
async def tabular_document_save(
|
|
2680
|
+
name: Annotated[str, "Tabular document field technical name"] = "",
|
|
2681
|
+
title: Annotated[str, "Tabular document field visible title"] = "",
|
|
2682
|
+
path: Annotated[str, "Output file path; empty = auto-generated temp path"] = "",
|
|
2683
|
+
format: Annotated[str, "Output format: pdf | xlsx | xls | mxl | html | txt"] = "pdf",
|
|
2684
|
+
max_depth: Annotated[int, "Maximum search depth"] = 8,
|
|
2685
|
+
*,
|
|
2686
|
+
session_id: Annotated[str, "Session id"] = DEFAULT_SESSION,
|
|
2687
|
+
) -> Any:
|
|
2688
|
+
"""Save a tabular document field to a file in the selected format."""
|
|
2689
|
+
return await _bridge_call_recorded(session_id, "tabular_document_save", {"name": name, "title": title, "path": path, "format": format, "max_depth": max_depth})
|
|
2690
|
+
|
|
2691
|
+
|
|
2692
|
+
@mcp.tool()
|
|
2693
|
+
async def toggle_field(
|
|
2694
|
+
field_name: Annotated[str, "Field technical name"],
|
|
2695
|
+
value: Annotated[bool, "Desired checkbox value"] = False,
|
|
2696
|
+
*,
|
|
2697
|
+
session_id: Annotated[str, "Session id"] = DEFAULT_SESSION,
|
|
2698
|
+
) -> Any:
|
|
2699
|
+
"""Set a checkbox-like field using the 1C test client API."""
|
|
2700
|
+
return await _bridge_call_recorded(session_id, "toggle_field", {"field_name": field_name, "value": value})
|
|
2701
|
+
|
|
2702
|
+
|
|
2703
|
+
@mcp.tool()
|
|
2704
|
+
async def set_field_value(
|
|
2705
|
+
field_name: Annotated[str, "Field technical name or title"],
|
|
2706
|
+
value: Annotated[str, "Value to set"],
|
|
2707
|
+
search_by_title: Annotated[bool, "If true, match visible title instead of object name"] = False,
|
|
2708
|
+
*,
|
|
2709
|
+
session_id: Annotated[str, "Session id"] = DEFAULT_SESSION,
|
|
2710
|
+
) -> Any:
|
|
2711
|
+
"""Set a value in a form field by name or title."""
|
|
2712
|
+
return await _bridge_call_recorded(session_id, "set_field_value", {"field_name": field_name, "value": value, "search_by_title": search_by_title})
|
|
2713
|
+
|
|
2714
|
+
|
|
2715
|
+
@mcp.tool()
|
|
2716
|
+
async def activate_field(
|
|
2717
|
+
field_name: Annotated[str, "Field technical name or title"],
|
|
2718
|
+
search_by_title: Annotated[bool, "If true, match visible title instead of object name"] = False,
|
|
2719
|
+
*,
|
|
2720
|
+
session_id: Annotated[str, "Session id"] = DEFAULT_SESSION,
|
|
2721
|
+
) -> Any:
|
|
2722
|
+
"""Activate/focus a form field by name or title."""
|
|
2723
|
+
return await _bridge_call_recorded(session_id, "activate_field", {"field_name": field_name, "search_by_title": search_by_title})
|
|
2724
|
+
|
|
2725
|
+
|
|
2726
|
+
@mcp.tool()
|
|
2727
|
+
async def choose_field_from_list(
|
|
2728
|
+
field_name: Annotated[str, "Reference field technical name or title"],
|
|
2729
|
+
search_by_title: Annotated[bool, "If true, match visible title instead of object name"] = False,
|
|
2730
|
+
*,
|
|
2731
|
+
session_id: Annotated[str, "Session id"] = DEFAULT_SESSION,
|
|
2732
|
+
) -> Any:
|
|
2733
|
+
"""Open the standard selection form for a reference field."""
|
|
2734
|
+
return await _bridge_call_recorded(session_id, "choose_field_from_list", {"field_name": field_name, "search_by_title": search_by_title})
|
|
2735
|
+
|
|
2736
|
+
|
|
2737
|
+
@mcp.tool()
|
|
2738
|
+
async def choose_field_first_row(
|
|
2739
|
+
field_name: Annotated[str, "Reference field technical name or title"],
|
|
2740
|
+
search_by_title: Annotated[bool, "If true, match visible title instead of object name"] = False,
|
|
2741
|
+
*,
|
|
2742
|
+
session_id: Annotated[str, "Session id"] = DEFAULT_SESSION,
|
|
2743
|
+
) -> Any:
|
|
2744
|
+
"""Open reference selection form and press its standard Choose button."""
|
|
2745
|
+
return await _bridge_call_recorded(session_id, "choose_field_first_row", {"field_name": field_name, "search_by_title": search_by_title})
|
|
2746
|
+
|
|
2747
|
+
|
|
2748
|
+
@mcp.tool()
|
|
2749
|
+
async def choose_current_row(
|
|
2750
|
+
name: Annotated[str, "Table technical name"] = "Список",
|
|
2751
|
+
*,
|
|
2752
|
+
session_id: Annotated[str, "Session id"] = DEFAULT_SESSION,
|
|
2753
|
+
) -> Any:
|
|
2754
|
+
"""Choose the current row in the active selection/list form."""
|
|
2755
|
+
return await _bridge_call_recorded(session_id, "choose_current_row", {"name": name})
|
|
2756
|
+
|
|
2757
|
+
|
|
2758
|
+
@mcp.tool()
|
|
2759
|
+
async def table_rows(
|
|
2760
|
+
name: Annotated[str, "Table technical name"] = "",
|
|
2761
|
+
title: Annotated[str, "Table visible title"] = "",
|
|
2762
|
+
limit: Annotated[int, "Maximum rows to return"] = 50,
|
|
2763
|
+
max_depth: Annotated[int, "Maximum search depth"] = 8,
|
|
2764
|
+
*,
|
|
2765
|
+
session_id: Annotated[str, "Session id"] = DEFAULT_SESSION,
|
|
2766
|
+
) -> Any:
|
|
2767
|
+
"""Return rows of a tested table as key/value mappings."""
|
|
2768
|
+
return await _bridge_call_recorded(session_id, "table_rows", {"name": name, "title": title, "limit": limit, "max_depth": max_depth})
|
|
2769
|
+
|
|
2770
|
+
|
|
2771
|
+
@mcp.tool()
|
|
2772
|
+
async def table_find_row(
|
|
2773
|
+
text: Annotated[str, "Substring to search in serialized row values"],
|
|
2774
|
+
name: Annotated[str, "Table technical name"] = "",
|
|
2775
|
+
title: Annotated[str, "Table visible title"] = "",
|
|
2776
|
+
limit: Annotated[int, "Maximum rows to scan"] = 200,
|
|
2777
|
+
max_depth: Annotated[int, "Maximum search depth"] = 8,
|
|
2778
|
+
*,
|
|
2779
|
+
session_id: Annotated[str, "Session id"] = DEFAULT_SESSION,
|
|
2780
|
+
) -> Any:
|
|
2781
|
+
"""Find a tested table row containing text."""
|
|
2782
|
+
return await _bridge_call_recorded(session_id, "table_find_row", {"name": name, "title": title, "text": text, "limit": limit, "max_depth": max_depth})
|
|
2783
|
+
|
|
2784
|
+
|
|
2785
|
+
@mcp.tool()
|
|
2786
|
+
async def table_goto_row(
|
|
2787
|
+
text: Annotated[str, "Substring to search in serialized row values"],
|
|
2788
|
+
name: Annotated[str, "Table technical name"] = "",
|
|
2789
|
+
title: Annotated[str, "Table visible title"] = "",
|
|
2790
|
+
limit: Annotated[int, "Maximum rows to scan"] = 200,
|
|
2791
|
+
max_depth: Annotated[int, "Maximum search depth"] = 8,
|
|
2792
|
+
*,
|
|
2793
|
+
session_id: Annotated[str, "Session id"] = DEFAULT_SESSION,
|
|
2794
|
+
) -> Any:
|
|
2795
|
+
"""Move cursor to the first tested table row containing text."""
|
|
2796
|
+
return await _bridge_call_recorded(session_id, "table_goto_row", {"name": name, "title": title, "text": text, "limit": limit, "max_depth": max_depth})
|
|
2797
|
+
|
|
2798
|
+
|
|
2799
|
+
@mcp.tool()
|
|
2800
|
+
async def table_move_row(
|
|
2801
|
+
direction: Annotated[str, "first | last | next | previous | expand | collapse | level_down | level_up"] = "next",
|
|
2802
|
+
steps: Annotated[int, "Number of row moves"] = 1,
|
|
2803
|
+
name: Annotated[str, "Table technical name"] = "",
|
|
2804
|
+
title: Annotated[str, "Table visible title"] = "",
|
|
2805
|
+
max_depth: Annotated[int, "Maximum search depth"] = 8,
|
|
2806
|
+
*,
|
|
2807
|
+
session_id: Annotated[str, "Session id"] = DEFAULT_SESSION,
|
|
2808
|
+
) -> Any:
|
|
2809
|
+
"""Move current row using official test-client table navigation methods."""
|
|
2810
|
+
return await _bridge_call_recorded(session_id, "table_move_row",
|
|
2811
|
+
{"name": name, "title": title, "direction": direction, "steps": steps, "max_depth": max_depth},
|
|
2812
|
+
)
|
|
2813
|
+
|
|
2814
|
+
|
|
2815
|
+
@mcp.tool()
|
|
2816
|
+
async def table_add_row(
|
|
2817
|
+
name: Annotated[str, "Table technical name"] = "",
|
|
2818
|
+
title: Annotated[str, "Table visible title"] = "",
|
|
2819
|
+
max_depth: Annotated[int, "Maximum UI search depth"] = 8,
|
|
2820
|
+
*,
|
|
2821
|
+
session_id: Annotated[str, "Session id"] = DEFAULT_SESSION,
|
|
2822
|
+
) -> Any:
|
|
2823
|
+
"""Add a row to the active tested table via the 1C test-client API (Insert / plus)."""
|
|
2824
|
+
return await _bridge_call_recorded(session_id, "table_add_row", {"name": name, "title": title, "max_depth": max_depth})
|
|
2825
|
+
|
|
2826
|
+
|
|
2827
|
+
@mcp.tool()
|
|
2828
|
+
async def table_set_field(
|
|
2829
|
+
table: Annotated[str, "Table technical name"],
|
|
2830
|
+
field: Annotated[str, "Column field technical name or title"],
|
|
2831
|
+
value: Annotated[str, "Value to set"],
|
|
2832
|
+
search_by_title: Annotated[bool, "If true, match visible title instead of object name"] = False,
|
|
2833
|
+
*,
|
|
2834
|
+
session_id: Annotated[str, "Session id"] = DEFAULT_SESSION,
|
|
2835
|
+
) -> Any:
|
|
2836
|
+
"""Set a field in the currently edited row of a form table."""
|
|
2837
|
+
return await _bridge_call_recorded(session_id, "table_set_field", {"table": table, "field": field, "value": value, "search_by_title": search_by_title})
|
|
2838
|
+
|
|
2839
|
+
|
|
2840
|
+
@mcp.tool()
|
|
2841
|
+
async def table_choose_field_from_list(
|
|
2842
|
+
table: Annotated[str, "Table technical name"],
|
|
2843
|
+
field: Annotated[str, "Column field technical name or title"],
|
|
2844
|
+
search_by_title: Annotated[bool, "If true, match visible title instead of object name"] = False,
|
|
2845
|
+
*,
|
|
2846
|
+
session_id: Annotated[str, "Session id"] = DEFAULT_SESSION,
|
|
2847
|
+
) -> Any:
|
|
2848
|
+
"""Open the selection form for a reference field in a form table row."""
|
|
2849
|
+
return await _bridge_call_recorded(session_id, "table_choose_field_from_list", {"table": table, "field": field, "search_by_title": search_by_title})
|
|
2850
|
+
|
|
2851
|
+
|
|
2852
|
+
@mcp.tool()
|
|
2853
|
+
async def table_edit_info(
|
|
2854
|
+
table: Annotated[str, "Table technical name"] = "ДополнительнаяИнформация",
|
|
2855
|
+
*,
|
|
2856
|
+
session_id: Annotated[str, "Session id"] = DEFAULT_SESSION,
|
|
2857
|
+
) -> Any:
|
|
2858
|
+
"""Return diagnostics for currently edited table row and its child fields."""
|
|
2859
|
+
return await _bridge_call_recorded(session_id, "table_edit_info", {"table": table})
|
|
2860
|
+
|
|
2861
|
+
|
|
2862
|
+
@mcp.tool()
|
|
2863
|
+
async def table_select_all(
|
|
2864
|
+
name: Annotated[str, "Table technical name"] = "",
|
|
2865
|
+
title: Annotated[str, "Table title substring"] = "",
|
|
2866
|
+
*,
|
|
2867
|
+
session_id: Annotated[str, "Session id"] = DEFAULT_SESSION,
|
|
2868
|
+
) -> Any:
|
|
2869
|
+
"""Select all rows in a form table."""
|
|
2870
|
+
return await _bridge_call_recorded(session_id, "table_select_all", {"name": name, "title": title})
|
|
2871
|
+
|
|
2872
|
+
|
|
2873
|
+
@mcp.tool()
|
|
2874
|
+
async def table_clear_selection(
|
|
2875
|
+
name: Annotated[str, "Table technical name"] = "",
|
|
2876
|
+
title: Annotated[str, "Table title substring"] = "",
|
|
2877
|
+
*,
|
|
2878
|
+
session_id: Annotated[str, "Session id"] = DEFAULT_SESSION,
|
|
2879
|
+
) -> Any:
|
|
2880
|
+
"""Clear all row selection in a form table."""
|
|
2881
|
+
return await _bridge_call_recorded(session_id, "table_clear_selection", {"name": name, "title": title})
|
|
2882
|
+
|
|
2883
|
+
|
|
2884
|
+
@mcp.tool()
|
|
2885
|
+
async def table_delete_row(
|
|
2886
|
+
name: Annotated[str, "Table technical name"] = "",
|
|
2887
|
+
title: Annotated[str, "Table title substring"] = "",
|
|
2888
|
+
*,
|
|
2889
|
+
session_id: Annotated[str, "Session id"] = DEFAULT_SESSION,
|
|
2890
|
+
) -> Any:
|
|
2891
|
+
"""Delete the current row in a form table."""
|
|
2892
|
+
return await _bridge_call_recorded(session_id, "table_delete_row", {"name": name, "title": title})
|
|
2893
|
+
|
|
2894
|
+
|
|
2895
|
+
@mcp.tool()
|
|
2896
|
+
async def table_copy_row(
|
|
2897
|
+
name: Annotated[str, "Table technical name"] = "",
|
|
2898
|
+
title: Annotated[str, "Table title substring"] = "",
|
|
2899
|
+
*,
|
|
2900
|
+
session_id: Annotated[str, "Session id"] = DEFAULT_SESSION,
|
|
2901
|
+
) -> Any:
|
|
2902
|
+
"""Copy the current row in a form table."""
|
|
2903
|
+
return await _bridge_call_recorded(session_id, "table_copy_row", {"name": name, "title": title})
|
|
2904
|
+
|
|
2905
|
+
|
|
2906
|
+
@mcp.tool()
|
|
2907
|
+
async def table_toggle_delete_mark(
|
|
2908
|
+
name: Annotated[str, "Table technical name"] = "",
|
|
2909
|
+
title: Annotated[str, "Table title substring"] = "",
|
|
2910
|
+
*,
|
|
2911
|
+
session_id: Annotated[str, "Session id"] = DEFAULT_SESSION,
|
|
2912
|
+
) -> Any:
|
|
2913
|
+
"""Toggle the delete mark on the current row."""
|
|
2914
|
+
return await _bridge_call_recorded(session_id, "table_toggle_delete_mark", {"name": name, "title": title})
|
|
2915
|
+
|
|
2916
|
+
|
|
2917
|
+
@mcp.tool()
|
|
2918
|
+
async def table_begin_edit(
|
|
2919
|
+
name: Annotated[str, "Table technical name"] = "",
|
|
2920
|
+
title: Annotated[str, "Table title substring"] = "",
|
|
2921
|
+
*,
|
|
2922
|
+
session_id: Annotated[str, "Session id"] = DEFAULT_SESSION,
|
|
2923
|
+
) -> Any:
|
|
2924
|
+
"""Begin editing the current row in a form table."""
|
|
2925
|
+
return await _bridge_call_recorded(session_id, "table_begin_edit", {"name": name, "title": title})
|
|
2926
|
+
|
|
2927
|
+
|
|
2928
|
+
@mcp.tool()
|
|
2929
|
+
async def table_end_edit(
|
|
2930
|
+
name: Annotated[str, "Table technical name"] = "",
|
|
2931
|
+
title: Annotated[str, "Table title substring"] = "",
|
|
2932
|
+
*,
|
|
2933
|
+
session_id: Annotated[str, "Session id"] = DEFAULT_SESSION,
|
|
2934
|
+
) -> Any:
|
|
2935
|
+
"""End editing the current row in a form table."""
|
|
2936
|
+
return await _bridge_call_recorded(session_id, "table_end_edit", {"name": name, "title": title})
|
|
2937
|
+
|
|
2938
|
+
|
|
2939
|
+
@mcp.tool()
|
|
2940
|
+
async def table_editing_state(
|
|
2941
|
+
name: Annotated[str, "Table technical name"] = "",
|
|
2942
|
+
title: Annotated[str, "Table title substring"] = "",
|
|
2943
|
+
*,
|
|
2944
|
+
session_id: Annotated[str, "Session id"] = DEFAULT_SESSION,
|
|
2945
|
+
) -> Any:
|
|
2946
|
+
"""Return whether a form table is currently in edit mode."""
|
|
2947
|
+
return await _bridge_call_recorded(session_id, "table_editing_state", {"name": name, "title": title})
|
|
2948
|
+
|
|
2949
|
+
|
|
2950
|
+
@mcp.tool()
|
|
2951
|
+
async def dynamic_list_available_fields(
|
|
2952
|
+
object: Annotated[str, "1C metadata object full name, e.g. Catalog.ПС_Справочник1; empty = derive from active window"] = "",
|
|
2953
|
+
rag_snapshot: Annotated[str, "Optional RAG snapshot name/id"] = "",
|
|
2954
|
+
*,
|
|
2955
|
+
session_id: Annotated[str, "Session id"] = DEFAULT_SESSION,
|
|
2956
|
+
) -> Any:
|
|
2957
|
+
"""Return RAG-known fields available for dynamic-list filtering and ordering."""
|
|
2958
|
+
sess = _session(session_id)
|
|
2959
|
+
if not object:
|
|
2960
|
+
try:
|
|
2961
|
+
win = await _bridge_call_recorded(session_id, "active_window", {})
|
|
2962
|
+
object = _metadata_name_from_navigation_url(str(win.get("navigation_url") or ""))
|
|
2963
|
+
except Exception:
|
|
2964
|
+
object = ""
|
|
2965
|
+
effective_snapshot = rag_snapshot or sess.rag_snapshot or None
|
|
2966
|
+
fields = _rag_fields_for_object(object, snapshot=effective_snapshot)
|
|
2967
|
+
return {"object": object, "rag_object": _metadata_name_to_rag_name(object), "snapshot": effective_snapshot, "count": len(fields), "fields": fields}
|
|
2968
|
+
|
|
2969
|
+
|
|
2970
|
+
@mcp.tool()
|
|
2971
|
+
async def dynamic_list_apply_settings(
|
|
2972
|
+
filters: Annotated[str, "JSON array of filter objects [{field, value, comparison?}] or empty string"] = "",
|
|
2973
|
+
orders: Annotated[str, "JSON array of order objects [{field, direction?}] or empty string"] = "",
|
|
2974
|
+
clear_first: Annotated[bool, "Reset settings to defaults before applying new ones"] = False,
|
|
2975
|
+
name: Annotated[str, "Dynamic list/table technical name"] = "Список",
|
|
2976
|
+
title: Annotated[str, "Dynamic list/table visible title"] = "",
|
|
2977
|
+
max_depth: Annotated[int, "Maximum search depth"] = 8,
|
|
2978
|
+
*,
|
|
2979
|
+
session_id: Annotated[str, "Session id"] = DEFAULT_SESSION,
|
|
2980
|
+
) -> Any:
|
|
2981
|
+
"""Apply dynamic-list filters/orders and optionally clear settings first.
|
|
2982
|
+
|
|
2983
|
+
`orders` are applied with the stable test-client API `УстановитьПорядок`.
|
|
2984
|
+
`filters` are applied through the standard "Настроить список" dialog using
|
|
2985
|
+
best-effort field/value filling; if a configuration renders the settings
|
|
2986
|
+
form differently, the returned step contains the exact diagnostic/error.
|
|
2987
|
+
"""
|
|
2988
|
+
steps: list[dict[str, Any]] = []
|
|
2989
|
+
if clear_first:
|
|
2990
|
+
cleared = await _bridge_call_recorded(session_id, "dynamic_list_clear_settings", {"button": ""})
|
|
2991
|
+
steps.append({"action": "clear", "result": cleared})
|
|
2992
|
+
filter_list = json.loads(filters) if filters else []
|
|
2993
|
+
order_list = json.loads(orders) if orders else []
|
|
2994
|
+
if filter_list:
|
|
2995
|
+
opened = await _bridge_call_recorded(session_id, "dynamic_list_open_settings", {"button": ""})
|
|
2996
|
+
steps.append({"action": "open_settings", "result": opened})
|
|
2997
|
+
for spec in filter_list:
|
|
2998
|
+
applied = await _bridge_call_recorded(session_id, "dynamic_list_apply_filter", {
|
|
2999
|
+
"field": spec.get("field", ""),
|
|
3000
|
+
"value": spec.get("value", ""),
|
|
3001
|
+
"comparison": spec.get("comparison", "Equal"),
|
|
3002
|
+
"name": name,
|
|
3003
|
+
"title": title,
|
|
3004
|
+
"max_depth": max_depth,
|
|
3005
|
+
})
|
|
3006
|
+
steps.append({"action": "filter", "spec": spec, "result": applied})
|
|
3007
|
+
closed = await _bridge_call_recorded(session_id, "close_form", {})
|
|
3008
|
+
steps.append({"action": "close_settings", "result": closed})
|
|
3009
|
+
for spec in order_list:
|
|
3010
|
+
field = spec.get("field", "")
|
|
3011
|
+
ordered = await _bridge_call_recorded(session_id, "dynamic_list_set_order", {
|
|
3012
|
+
"name": name, "title": title, "column_title": field, "max_depth": max_depth,
|
|
3013
|
+
})
|
|
3014
|
+
steps.append({"action": "order", "spec": spec, "result": ordered})
|
|
3015
|
+
return {"applied": True, "filters": len(filter_list), "orders": len(order_list), "steps": steps}
|
|
3016
|
+
|
|
3017
|
+
|
|
3018
|
+
@mcp.tool()
|
|
3019
|
+
async def dynamic_list_set_order(
|
|
3020
|
+
column_title: Annotated[str, "Visible column title to sort by using 1C УстановитьПорядок(<ЗаголовокКолонки>)"],
|
|
3021
|
+
name: Annotated[str, "Dynamic list/table technical name"] = "Список",
|
|
3022
|
+
title: Annotated[str, "Dynamic list/table visible title"] = "",
|
|
3023
|
+
max_depth: Annotated[int, "Maximum search depth"] = 8,
|
|
3024
|
+
*,
|
|
3025
|
+
session_id: Annotated[str, "Session id"] = DEFAULT_SESSION,
|
|
3026
|
+
) -> Any:
|
|
3027
|
+
"""Sort a dynamic list by visible column title via the 1C test-client API."""
|
|
3028
|
+
return await _bridge_call_recorded(session_id, "dynamic_list_set_order", {"name": name, "title": title, "column_title": column_title, "max_depth": max_depth})
|
|
3029
|
+
|
|
3030
|
+
|
|
3031
|
+
@mcp.tool()
|
|
3032
|
+
async def dynamic_list_output(
|
|
3033
|
+
button: Annotated[str, "Optional exact 1C button name/title; empty tries common Вывести список buttons"] = "",
|
|
3034
|
+
wait_seconds: Annotated[int, "Seconds to wait for the tabular document form"] = 2,
|
|
3035
|
+
max_depth: Annotated[int, "Maximum UI search depth"] = 8,
|
|
3036
|
+
*,
|
|
3037
|
+
session_id: Annotated[str, "Session id"] = DEFAULT_SESSION,
|
|
3038
|
+
) -> Any:
|
|
3039
|
+
"""Press dynamic list 'Вывести список' button and return detected tabular document(s), including text when possible."""
|
|
3040
|
+
return await _bridge_call_recorded(session_id, "dynamic_list_output", {"button": button, "wait_seconds": wait_seconds, "max_depth": max_depth})
|
|
3041
|
+
|
|
3042
|
+
|
|
3043
|
+
@mcp.tool()
|
|
3044
|
+
async def dynamic_list_open_settings(
|
|
3045
|
+
button: Annotated[str, "Optional exact 1C button name/title; empty tries common Настроить список buttons"] = "",
|
|
3046
|
+
*,
|
|
3047
|
+
session_id: Annotated[str, "Session id"] = DEFAULT_SESSION,
|
|
3048
|
+
) -> Any:
|
|
3049
|
+
"""Open dynamic-list settings (filter/order/conditional formatting/grouping) via 'Настроить список'."""
|
|
3050
|
+
return await _bridge_call_recorded(session_id, "dynamic_list_open_settings", {"button": button})
|
|
3051
|
+
|
|
3052
|
+
|
|
3053
|
+
@mcp.tool()
|
|
3054
|
+
async def dynamic_list_clear_settings(
|
|
3055
|
+
button: Annotated[str, "Optional exact 1C button name/title; empty tries common reset/clear settings buttons"] = "",
|
|
3056
|
+
*,
|
|
3057
|
+
session_id: Annotated[str, "Session id"] = DEFAULT_SESSION,
|
|
3058
|
+
) -> Any:
|
|
3059
|
+
"""Quickly reset/clear dynamic-list filter/order/conditional formatting/grouping settings."""
|
|
3060
|
+
return await _bridge_call_recorded(session_id, "dynamic_list_clear_settings", {"button": button})
|
|
3061
|
+
|
|
3062
|
+
|
|
3063
|
+
@mcp.tool()
|
|
3064
|
+
async def dynamic_list_open_form_settings(
|
|
3065
|
+
button: Annotated[str, "Optional exact 1C button name/title; empty tries Изменить форму buttons"] = "",
|
|
3066
|
+
*,
|
|
3067
|
+
session_id: Annotated[str, "Session id"] = DEFAULT_SESSION,
|
|
3068
|
+
) -> Any:
|
|
3069
|
+
"""Open form customization via 'Изменить форму'."""
|
|
3070
|
+
return await _bridge_call_recorded(session_id, "dynamic_list_open_form_settings", {"button": button})
|
|
3071
|
+
|
|
3072
|
+
|
|
3073
|
+
@mcp.tool()
|
|
3074
|
+
async def create_new_item(*,
|
|
3075
|
+
session_id: Annotated[str, "Session id"] = DEFAULT_SESSION,
|
|
3076
|
+
) -> Any:
|
|
3077
|
+
"""Create a new item in the active form (e.g. new catalog/dictionary entry)."""
|
|
3078
|
+
return await _bridge_call_recorded(session_id, "create_new_item", {})
|
|
3079
|
+
|
|
3080
|
+
|
|
3081
|
+
@mcp.tool()
|
|
3082
|
+
async def save_form(*,
|
|
3083
|
+
session_id: Annotated[str, "Session id"] = DEFAULT_SESSION,
|
|
3084
|
+
) -> Any:
|
|
3085
|
+
"""Save/write the active form."""
|
|
3086
|
+
return await _bridge_call_recorded(session_id, "save_form", {})
|
|
3087
|
+
|
|
3088
|
+
|
|
3089
|
+
@mcp.tool()
|
|
3090
|
+
async def close_form(*,
|
|
3091
|
+
session_id: Annotated[str, "Session id"] = DEFAULT_SESSION,
|
|
3092
|
+
) -> Any:
|
|
3093
|
+
"""Close the active form."""
|
|
3094
|
+
return await _bridge_call_recorded(session_id, "close_form", {})
|
|
3095
|
+
|
|
3096
|
+
|
|
3097
|
+
def _active_x11_window_geometry(display: str | None = None) -> dict[str, int] | None:
|
|
3098
|
+
env = os.environ.copy()
|
|
3099
|
+
if display:
|
|
3100
|
+
env["DISPLAY"] = display
|
|
3101
|
+
try:
|
|
3102
|
+
tree = subprocess.run(
|
|
3103
|
+
["xwininfo", "-root", "-tree"],
|
|
3104
|
+
env=env,
|
|
3105
|
+
check=True,
|
|
3106
|
+
text=True,
|
|
3107
|
+
stdout=subprocess.PIPE,
|
|
3108
|
+
stderr=subprocess.PIPE,
|
|
3109
|
+
timeout=10,
|
|
3110
|
+
).stdout
|
|
3111
|
+
except (subprocess.SubprocessError, FileNotFoundError):
|
|
3112
|
+
return None
|
|
3113
|
+
|
|
3114
|
+
candidates: list[tuple[int, dict[str, int]]] = []
|
|
3115
|
+
for line in tree.splitlines():
|
|
3116
|
+
if '("1cv8c" "1cv8c")' not in line and '("1cv8" "1cv8")' not in line:
|
|
3117
|
+
continue
|
|
3118
|
+
match = re.search(r'0x[0-9a-f]+ "([^"]*)".*?(\d+)x(\d+)\+(-?\d+)\+(-?\d+)', line)
|
|
3119
|
+
if not match:
|
|
3120
|
+
continue
|
|
3121
|
+
title = match.group(1)
|
|
3122
|
+
width, height, left, top = map(int, match.groups()[1:])
|
|
3123
|
+
if width < 300 or height < 150:
|
|
3124
|
+
continue
|
|
3125
|
+
if left < 0 or top < 0:
|
|
3126
|
+
continue
|
|
3127
|
+
priority = 0
|
|
3128
|
+
if title and title not in {"1cv8c", "1cv8", "Конфигурация"}:
|
|
3129
|
+
priority += 100
|
|
3130
|
+
if "1С:Предприятие" in title:
|
|
3131
|
+
priority += 50
|
|
3132
|
+
if any(word in title for word in ("создание", "Инструменты", "Шаблон", "Сессия", "DEV-AREA")):
|
|
3133
|
+
priority += 25
|
|
3134
|
+
priority += min(width * height // 10000, 50)
|
|
3135
|
+
candidates.append((priority, {"left": left, "top": top, "width": width, "height": height, "title": title}))
|
|
3136
|
+
if not candidates:
|
|
3137
|
+
return None
|
|
3138
|
+
candidates.sort(key=lambda item: item[0], reverse=True)
|
|
3139
|
+
return candidates[0][1]
|
|
3140
|
+
|
|
3141
|
+
|
|
3142
|
+
@mcp.tool()
|
|
3143
|
+
def recording_start(
|
|
3144
|
+
output_dir: Annotated[str, "Directory for frames/slides/media"] = "build/recordings/session",
|
|
3145
|
+
display: Annotated[str, "X11 display to capture"] = "",
|
|
3146
|
+
window: Annotated[bool, "Capture best 1C window instead of whole display"] = False,
|
|
3147
|
+
*,
|
|
3148
|
+
session_id: Annotated[str, "Session id"] = DEFAULT_SESSION,
|
|
3149
|
+
) -> dict[str, Any]:
|
|
3150
|
+
"""Start automatic evidence recording: each recorded tool call gets screenshot + annotated slide."""
|
|
3151
|
+
return _session(session_id).recorder.start(output_dir=output_dir, display=display, window=window)
|
|
3152
|
+
|
|
3153
|
+
|
|
3154
|
+
@mcp.tool()
|
|
3155
|
+
def recording_capture(
|
|
3156
|
+
action: Annotated[str, "Manual step/action label"],
|
|
3157
|
+
note: Annotated[str, "Text to put into the slide"] = "",
|
|
3158
|
+
*,
|
|
3159
|
+
session_id: Annotated[str, "Session id"] = DEFAULT_SESSION,
|
|
3160
|
+
) -> dict[str, Any]:
|
|
3161
|
+
"""Manually capture a screenshot/slide in the active recording session."""
|
|
3162
|
+
event = _session(session_id).recorder.capture(
|
|
3163
|
+
lambda path, window, display: _screenshot_for_recorder(path, window, display, session_id),
|
|
3164
|
+
action,
|
|
3165
|
+
{"note": note},
|
|
3166
|
+
{"note": note},
|
|
3167
|
+
)
|
|
3168
|
+
return event or {"active": False}
|
|
3169
|
+
|
|
3170
|
+
|
|
3171
|
+
@mcp.tool()
|
|
3172
|
+
def recording_stop(*,
|
|
3173
|
+
session_id: Annotated[str, "Session id"] = DEFAULT_SESSION,
|
|
3174
|
+
) -> dict[str, Any]:
|
|
3175
|
+
"""Stop recording and build PDF evidence from captured slides."""
|
|
3176
|
+
return _session(session_id).recorder.stop()
|
|
3177
|
+
|
|
3178
|
+
|
|
3179
|
+
@mcp.tool()
|
|
3180
|
+
def screenshot(
|
|
3181
|
+
path: Annotated[str, "Output image path"] = "build/screenshots/screenshot.png",
|
|
3182
|
+
window: Annotated[bool, "Capture the best visible 1C window instead of the whole virtual display"] = True,
|
|
3183
|
+
display: Annotated[str, "X11 display to inspect/capture. Defaults to the session's display."] = "",
|
|
3184
|
+
*,
|
|
3185
|
+
session_id: Annotated[str, "Session id"] = DEFAULT_SESSION,
|
|
3186
|
+
) -> dict[str, Any]:
|
|
3187
|
+
"""Capture a screenshot from the MCP host. Defaults to the active visible 1C window."""
|
|
3188
|
+
output_path = Path(path)
|
|
3189
|
+
output_path.parent.mkdir(parents=True, exist_ok=True)
|
|
3190
|
+
|
|
3191
|
+
sess = _session(session_id)
|
|
3192
|
+
resolved_display = display or sess.display
|
|
3193
|
+
|
|
3194
|
+
try:
|
|
3195
|
+
import mss # type: ignore
|
|
3196
|
+
import mss.tools # type: ignore
|
|
3197
|
+
|
|
3198
|
+
if os_support.IS_WINDOWS:
|
|
3199
|
+
geometry = _active_windows_1c_window_geometry() if window else None
|
|
3200
|
+
with mss.mss() as sct:
|
|
3201
|
+
monitor = geometry or sct.monitors[0]
|
|
3202
|
+
image = sct.grab(monitor)
|
|
3203
|
+
mss.tools.to_png(image.rgb, image.size, output=str(output_path))
|
|
3204
|
+
else:
|
|
3205
|
+
if not resolved_display:
|
|
3206
|
+
raise RuntimeError("Cannot capture screenshot: no display set for session and no display argument provided")
|
|
3207
|
+
|
|
3208
|
+
previous_display = os.environ.get("DISPLAY")
|
|
3209
|
+
os.environ["DISPLAY"] = resolved_display
|
|
3210
|
+
try:
|
|
3211
|
+
geometry = _active_x11_window_geometry(resolved_display) if window else None
|
|
3212
|
+
with mss.mss() as sct:
|
|
3213
|
+
monitor = geometry or sct.monitors[0]
|
|
3214
|
+
image = sct.grab(monitor)
|
|
3215
|
+
mss.tools.to_png(image.rgb, image.size, output=str(output_path))
|
|
3216
|
+
finally:
|
|
3217
|
+
if previous_display is None:
|
|
3218
|
+
os.environ.pop("DISPLAY", None)
|
|
3219
|
+
else:
|
|
3220
|
+
os.environ["DISPLAY"] = previous_display
|
|
3221
|
+
except ImportError as exc:
|
|
3222
|
+
raise RuntimeError("Install screenshot extra: pip install '.[screenshot]' or mss") from exc
|
|
3223
|
+
|
|
3224
|
+
data = output_path.read_bytes()
|
|
3225
|
+
result = {"path": str(output_path), "bytes": len(data), "base64": base64.b64encode(data).decode("ascii")}
|
|
3226
|
+
if window:
|
|
3227
|
+
result["window_geometry"] = geometry
|
|
3228
|
+
return result
|
|
3229
|
+
|
|
3230
|
+
|
|
3231
|
+
async def _run() -> None:
|
|
3232
|
+
try:
|
|
3233
|
+
await mcp.run_stdio_async()
|
|
3234
|
+
finally:
|
|
3235
|
+
for sid, sess in list(_SESSIONS.items()):
|
|
3236
|
+
if sess.runtime is not None:
|
|
3237
|
+
runtime.kill_process(sess.runtime.manager_process, "test-manager")
|
|
3238
|
+
runtime.kill_process(sess.runtime.server_process, "ibsrv")
|
|
3239
|
+
if sess.client_runtime is not None:
|
|
3240
|
+
if sess.shared_client_key:
|
|
3241
|
+
_release_shared_file_server(sess.shared_client_key, sid)
|
|
3242
|
+
else:
|
|
3243
|
+
runtime.kill_process(sess.client_runtime.server_process, "ibsrv-client")
|
|
3244
|
+
if sess.file_client_runtime is not None:
|
|
3245
|
+
if sess.shared_file_key:
|
|
3246
|
+
_release_shared_file_server(sess.shared_file_key, sid)
|
|
3247
|
+
else:
|
|
3248
|
+
runtime.kill_process(sess.file_client_runtime.server_process, "ibsrv-file-client")
|
|
3249
|
+
try:
|
|
3250
|
+
await sess.bridge.stop()
|
|
3251
|
+
except Exception:
|
|
3252
|
+
pass
|
|
3253
|
+
runtime.stop_xvfb(sess.xvfb_process)
|
|
3254
|
+
_SESSIONS.clear()
|
|
3255
|
+
|
|
3256
|
+
|
|
3257
|
+
def main() -> None:
|
|
3258
|
+
if len(sys.argv) > 1 and sys.argv[1] in {"install-skills", "skills-install"}:
|
|
3259
|
+
raise SystemExit(skill_installer.main(sys.argv[2:]))
|
|
3260
|
+
if len(sys.argv) > 1 and sys.argv[1] in {"release", "release-pypi"}:
|
|
3261
|
+
raise SystemExit(release_helper.main(sys.argv[2:]))
|
|
3262
|
+
|
|
3263
|
+
parser = argparse.ArgumentParser(description="MCP server for 1C:Enterprise test manager")
|
|
3264
|
+
parser.add_argument("--log-level", default=os.getenv("ONEC_MCP_LOG_LEVEL", "INFO"))
|
|
3265
|
+
parser.add_argument(
|
|
3266
|
+
"--dev",
|
|
3267
|
+
default=os.getenv("ONEC_MCP_DEV", ""),
|
|
3268
|
+
choices=("", "openclaw"),
|
|
3269
|
+
help="enable development integrations; currently only 'openclaw' starts the MCP autoreload watchdog",
|
|
3270
|
+
)
|
|
3271
|
+
parser.add_argument(
|
|
3272
|
+
"--dev-restart-timeout",
|
|
3273
|
+
type=float,
|
|
3274
|
+
default=float(os.getenv("ONEC_MCP_DEV_RESTART_TIMEOUT", "30")),
|
|
3275
|
+
help="seconds of no file changes before the OpenClaw MCP autoreload watchdog triggers reload",
|
|
3276
|
+
)
|
|
3277
|
+
args = parser.parse_args()
|
|
3278
|
+
|
|
3279
|
+
logging.basicConfig(level=getattr(logging, args.log_level.upper(), logging.INFO))
|
|
3280
|
+
_start_autoreload_watcher(args.dev, args.dev_restart_timeout)
|
|
3281
|
+
asyncio.run(_run())
|
|
3282
|
+
|
|
3283
|
+
|
|
3284
|
+
if __name__ == "__main__":
|
|
3285
|
+
main()
|