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.
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()