answer42 0.4.97__tar.gz → 0.4.99__tar.gz

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.
Files changed (64) hide show
  1. {answer42-0.4.97 → answer42-0.4.99}/PKG-INFO +24 -1
  2. {answer42-0.4.97 → answer42-0.4.99}/README.md +23 -0
  3. {answer42-0.4.97 → answer42-0.4.99}/pyproject.toml +1 -1
  4. {answer42-0.4.97 → answer42-0.4.99}/src/mcp_1c/assets/MCPTestClient.cf +0 -0
  5. {answer42-0.4.97 → answer42-0.4.99}/src/mcp_1c/assets/MCPTestManager.cf +0 -0
  6. {answer42-0.4.97 → answer42-0.4.99}/src/mcp_1c/runtime.py +47 -4
  7. {answer42-0.4.97 → answer42-0.4.99}/src/mcp_1c/server.py +71 -8
  8. {answer42-0.4.97 → answer42-0.4.99}/.gitignore +0 -0
  9. {answer42-0.4.97 → answer42-0.4.99}/LICENSE +0 -0
  10. {answer42-0.4.97 → answer42-0.4.99}/credentials.example.json +0 -0
  11. {answer42-0.4.97 → answer42-0.4.99}/docs/agent-installation.md +0 -0
  12. {answer42-0.4.97 → answer42-0.4.99}/docs/architecture.md +0 -0
  13. {answer42-0.4.97 → answer42-0.4.99}/docs/assets/answer42-logo.png +0 -0
  14. {answer42-0.4.97 → answer42-0.4.99}/docs/assets/platform42-logo.svg +0 -0
  15. {answer42-0.4.97 → answer42-0.4.99}/docs/installation.md +0 -0
  16. {answer42-0.4.97 → answer42-0.4.99}/scripts/build_cf.py +0 -0
  17. {answer42-0.4.97 → answer42-0.4.99}/scripts/build_pages.py +0 -0
  18. {answer42-0.4.97 → answer42-0.4.99}/scripts/e2e_http_smoke.py +0 -0
  19. {answer42-0.4.97 → answer42-0.4.99}/scripts/e2e_stable.py +0 -0
  20. {answer42-0.4.97 → answer42-0.4.99}/scripts/openclaw_mcp_autoreload.py +0 -0
  21. {answer42-0.4.97 → answer42-0.4.99}/scripts/rag_cli.py +0 -0
  22. {answer42-0.4.97 → answer42-0.4.99}/src/cf/ConfigDumpInfo.xml +0 -0
  23. {answer42-0.4.97 → answer42-0.4.99}/src/cf/Configuration.xml +0 -0
  24. {answer42-0.4.97 → answer42-0.4.99}/src/cf/DataProcessors/MCPTestManager/Ext/ObjectModule.bsl +0 -0
  25. {answer42-0.4.97 → answer42-0.4.99}/src/cf/DataProcessors/MCPTestManager/Forms//320/244/320/276/321/200/320/274/320/260/Ext/Form/Module.bsl" +0 -0
  26. {answer42-0.4.97 → answer42-0.4.99}/src/cf/DataProcessors/MCPTestManager/Forms//320/244/320/276/321/200/320/274/320/260/Ext/Form.xml" +0 -0
  27. {answer42-0.4.97 → answer42-0.4.99}/src/cf/DataProcessors/MCPTestManager/Forms//320/244/320/276/321/200/320/274/320/260.xml" +0 -0
  28. {answer42-0.4.97 → answer42-0.4.99}/src/cf/DataProcessors/MCPTestManager.xml +0 -0
  29. {answer42-0.4.97 → answer42-0.4.99}/src/cf/Ext/ManagedApplicationModule.bsl +0 -0
  30. {answer42-0.4.97 → answer42-0.4.99}/src/cf/Languages//320/240/321/203/321/201/321/201/320/272/320/270/320/271.xml" +0 -0
  31. {answer42-0.4.97 → answer42-0.4.99}/src/client_cf/Catalogs//320/237/320/241_/320/222/320/273/320/260/320/264/320/265/320/273/320/265/321/206.xml" +0 -0
  32. {answer42-0.4.97 → answer42-0.4.99}/src/client_cf/Catalogs//320/237/320/241_/320/241/320/277/321/200/320/260/320/262/320/276/321/207/320/275/320/270/320/2721.xml" +0 -0
  33. {answer42-0.4.97 → answer42-0.4.99}/src/client_cf/ConfigDumpInfo.xml +0 -0
  34. {answer42-0.4.97 → answer42-0.4.99}/src/client_cf/Configuration.xml +0 -0
  35. {answer42-0.4.97 → answer42-0.4.99}/src/client_cf/Languages//320/240/321/203/321/201/321/201/320/272/320/270/320/271.xml" +0 -0
  36. {answer42-0.4.97 → answer42-0.4.99}/src/client_cf/Reports//320/237/320/241_/320/241/320/277/320/270/321/201/320/276/320/272/320/255/320/273/320/265/320/274/320/265/320/275/321/202/320/276/320/262/Ext/ManagerModule.bsl" +0 -0
  37. {answer42-0.4.97 → answer42-0.4.99}/src/client_cf/Reports//320/237/320/241_/320/241/320/277/320/270/321/201/320/276/320/272/320/255/320/273/320/265/320/274/320/265/320/275/321/202/320/276/320/262/Ext/ObjectModule.bsl" +0 -0
  38. {answer42-0.4.97 → answer42-0.4.99}/src/client_cf/Reports//320/237/320/241_/320/241/320/277/320/270/321/201/320/276/320/272/320/255/320/273/320/265/320/274/320/265/320/275/321/202/320/276/320/262/Templates//320/236/321/201/320/275/320/276/320/262/320/275/320/260/321/217/320/241/321/205/320/265/320/274/320/260/320/232/320/276/320/274/320/277/320/276/320/275/320/276/320/262/320/272/320/270/320/224/320/260/320/275/320/275/321/213/321/205/Ext/Template.xml" +0 -0
  39. {answer42-0.4.97 → answer42-0.4.99}/src/client_cf/Reports//320/237/320/241_/320/241/320/277/320/270/321/201/320/276/320/272/320/255/320/273/320/265/320/274/320/265/320/275/321/202/320/276/320/262/Templates//320/236/321/201/320/275/320/276/320/262/320/275/320/260/321/217/320/241/321/205/320/265/320/274/320/260/320/232/320/276/320/274/320/277/320/276/320/275/320/276/320/262/320/272/320/270/320/224/320/260/320/275/320/275/321/213/321/205.xml" +0 -0
  40. {answer42-0.4.97 → answer42-0.4.99}/src/client_cf/Reports//320/237/320/241_/320/241/320/277/320/270/321/201/320/276/320/272/320/255/320/273/320/265/320/274/320/265/320/275/321/202/320/276/320/262.xml" +0 -0
  41. {answer42-0.4.97 → answer42-0.4.99}/src/mcp_1c/__init__.py +0 -0
  42. {answer42-0.4.97 → answer42-0.4.99}/src/mcp_1c/assets/__init__.py +0 -0
  43. {answer42-0.4.97 → answer42-0.4.99}/src/mcp_1c/assets/skills/answer42/SKILL.md +0 -0
  44. {answer42-0.4.97 → answer42-0.4.99}/src/mcp_1c/assets/skills/answer42-rag/SKILL.md +0 -0
  45. {answer42-0.4.97 → answer42-0.4.99}/src/mcp_1c/assets/skills/answer42-universal-report/SKILL.md +0 -0
  46. {answer42-0.4.97 → answer42-0.4.99}/src/mcp_1c/auth.py +0 -0
  47. {answer42-0.4.97 → answer42-0.4.99}/src/mcp_1c/bridge.py +0 -0
  48. {answer42-0.4.97 → answer42-0.4.99}/src/mcp_1c/credentials.py +0 -0
  49. {answer42-0.4.97 → answer42-0.4.99}/src/mcp_1c/os_support.py +0 -0
  50. {answer42-0.4.97 → answer42-0.4.99}/src/mcp_1c/platform.py +0 -0
  51. {answer42-0.4.97 → answer42-0.4.99}/src/mcp_1c/protocol.py +0 -0
  52. {answer42-0.4.97 → answer42-0.4.99}/src/mcp_1c/rag/__init__.py +0 -0
  53. {answer42-0.4.97 → answer42-0.4.99}/src/mcp_1c/rag/detect.py +0 -0
  54. {answer42-0.4.97 → answer42-0.4.99}/src/mcp_1c/rag/dump.py +0 -0
  55. {answer42-0.4.97 → answer42-0.4.99}/src/mcp_1c/rag/model.py +0 -0
  56. {answer42-0.4.97 → answer42-0.4.99}/src/mcp_1c/rag/parsers.py +0 -0
  57. {answer42-0.4.97 → answer42-0.4.99}/src/mcp_1c/rag/service.py +0 -0
  58. {answer42-0.4.97 → answer42-0.4.99}/src/mcp_1c/rag/store.py +0 -0
  59. {answer42-0.4.97 → answer42-0.4.99}/src/mcp_1c/rag/summary_backend.py +0 -0
  60. {answer42-0.4.97 → answer42-0.4.99}/src/mcp_1c/rag/summary_vector.py +0 -0
  61. {answer42-0.4.97 → answer42-0.4.99}/src/mcp_1c/recorder.py +0 -0
  62. {answer42-0.4.97 → answer42-0.4.99}/src/mcp_1c/release_helper.py +0 -0
  63. {answer42-0.4.97 → answer42-0.4.99}/src/mcp_1c/skill_installer.py +0 -0
  64. {answer42-0.4.97 → answer42-0.4.99}/src/mcp_1c/window_control.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: answer42
3
- Version: 0.4.97
3
+ Version: 0.4.99
4
4
  Summary: Answer42 — The Answer to Life, Universe, and 1C — UI Driver. MCP-powered 1C:Enterprise UI automation: click, fill, navigate, test, and introspect managed forms through the test-client API
5
5
  Author: Marvin (AI Assistant), 42Clouds, and contributors
6
6
  Author-email: "Kosolapov Stanislav (proDOOMman)" <prodoomman@gmail.com>
@@ -162,6 +162,29 @@ answer42 install-skills --dry-run --agent all
162
162
  4. зарегистрируйте MCP-сервер в агентском клиенте;
163
163
  5. проверьте установку через `credentials_check`, затем smoke-сессию `start_session` → `active_window` → `stop_session`.
164
164
 
165
+ ## Linux display prerequisite
166
+
167
+ На Linux `start_session` использует живую X11-сессию, если процесс Answer42 унаследовал непустой `DISPLAY`: test manager и test client запускаются на этом дисплее, поэтому их окна и скриншоты видны на desktop.
168
+
169
+ Если `DISPLAY` не задан (headless service, SSH без X11 forwarding и т. п.), Answer42 поднимает два собственных изолированных Xvfb-дисплея: один для test manager и второй для test client. В этом режиме пакет `xvfb` обязателен:
170
+
171
+ ```bash
172
+ # Debian / Ubuntu
173
+ sudo apt install xvfb
174
+
175
+ # Fedora / RHEL
176
+ sudo dnf install xorg-x11-server-Xvfb
177
+ ```
178
+
179
+ Если не задан ни доступный `DISPLAY`, ни Xvfb, `start_session` завершается до создания ресурсов с понятной подсказкой по установке. Чтобы desktop-сервис видел живой X11, его launcher/service должен передать корректный `DISPLAY` и права доступа к X server (обычно через `XAUTHORITY`).
180
+
181
+ ## Скриншоты: stdio и StreamableHTTP
182
+
183
+ `screenshot` сохраняет PNG на MCP-хосте и больше не помещает его base64-представление в результат tool-call.
184
+
185
+ - В **stdio** возвращаются только `path`, размер и диагностические поля. Агентский хост при необходимости прикладывает файл по пути.
186
+ - В **StreamableHTTP** добавляется непрозрачная одноразовая ссылка `url` на PNG. Она действует один час по умолчанию (`ONEC_MCP_SCREENSHOT_URL_TTL_SECONDS`), исчезает после первого скачивания и существует не дольше процесса сервера. Для внешнего reverse proxy укажите публичный base URL через `ONEC_MCP_SCREENSHOT_URL_BASE`.
187
+
165
188
  ## Транспорты: stdio и StreamableHTTP
166
189
 
167
190
  По умолчанию Answer42 использует stdio MCP transport (запускается MCP-хостом как subprocess). Для удалённого deployment доступен **StreamableHTTP** режим с MCP auth:
@@ -128,6 +128,29 @@ answer42 install-skills --dry-run --agent all
128
128
  4. зарегистрируйте MCP-сервер в агентском клиенте;
129
129
  5. проверьте установку через `credentials_check`, затем smoke-сессию `start_session` → `active_window` → `stop_session`.
130
130
 
131
+ ## Linux display prerequisite
132
+
133
+ На Linux `start_session` использует живую X11-сессию, если процесс Answer42 унаследовал непустой `DISPLAY`: test manager и test client запускаются на этом дисплее, поэтому их окна и скриншоты видны на desktop.
134
+
135
+ Если `DISPLAY` не задан (headless service, SSH без X11 forwarding и т. п.), Answer42 поднимает два собственных изолированных Xvfb-дисплея: один для test manager и второй для test client. В этом режиме пакет `xvfb` обязателен:
136
+
137
+ ```bash
138
+ # Debian / Ubuntu
139
+ sudo apt install xvfb
140
+
141
+ # Fedora / RHEL
142
+ sudo dnf install xorg-x11-server-Xvfb
143
+ ```
144
+
145
+ Если не задан ни доступный `DISPLAY`, ни Xvfb, `start_session` завершается до создания ресурсов с понятной подсказкой по установке. Чтобы desktop-сервис видел живой X11, его launcher/service должен передать корректный `DISPLAY` и права доступа к X server (обычно через `XAUTHORITY`).
146
+
147
+ ## Скриншоты: stdio и StreamableHTTP
148
+
149
+ `screenshot` сохраняет PNG на MCP-хосте и больше не помещает его base64-представление в результат tool-call.
150
+
151
+ - В **stdio** возвращаются только `path`, размер и диагностические поля. Агентский хост при необходимости прикладывает файл по пути.
152
+ - В **StreamableHTTP** добавляется непрозрачная одноразовая ссылка `url` на PNG. Она действует один час по умолчанию (`ONEC_MCP_SCREENSHOT_URL_TTL_SECONDS`), исчезает после первого скачивания и существует не дольше процесса сервера. Для внешнего reverse proxy укажите публичный base URL через `ONEC_MCP_SCREENSHOT_URL_BASE`.
153
+
131
154
  ## Транспорты: stdio и StreamableHTTP
132
155
 
133
156
  По умолчанию Answer42 использует stdio MCP transport (запускается MCP-хостом как subprocess). Для удалённого deployment доступен **StreamableHTTP** режим с MCP auth:
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "answer42"
3
- version = "0.4.97"
3
+ version = "0.4.99"
4
4
  description = "Answer42 — The Answer to Life, Universe, and 1C — UI Driver. MCP-powered 1C:Enterprise UI automation: click, fill, navigate, test, and introspect managed forms through the test-client API"
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.11"
@@ -118,13 +118,56 @@ def find_free_port(start: int = DEFAULT_WS_PORT, end: int | None = None) -> int:
118
118
  raise RuntimeError(f"No free port found in range {start}..{end}")
119
119
 
120
120
 
121
- def start_xvfb(display: str, resolution: str = DEFAULT_DISPLAY_RES) -> subprocess.Popen:
122
- """Start Xvfb for the given display and return the Popen handle."""
121
+ def inherited_x11_display() -> str:
122
+ """Return the caller's live X11 display, if one was intentionally inherited."""
123
+ if os_support.IS_WINDOWS:
124
+ return ""
125
+ return os.getenv("DISPLAY", "").strip()
126
+
127
+
128
+ def require_xvfb() -> str:
129
+ """Return the Xvfb executable or raise an actionable headless prerequisite error.
130
+
131
+ Xvfb is needed only when Answer42 did not inherit a live X11 ``DISPLAY``.
132
+ """
123
133
  if os_support.IS_WINDOWS:
124
134
  raise RuntimeError("Xvfb is not available on Windows")
125
135
  xvfb_bin = shutil.which("Xvfb") or "/usr/bin/Xvfb"
126
- if not Path(xvfb_bin).exists():
127
- raise FileNotFoundError("Xvfb not found; install it to run headless sessions")
136
+ if not Path(xvfb_bin).is_file():
137
+ raise RuntimeError(
138
+ "Xvfb is required to start an Answer42 session on Linux when DISPLAY is not set. "
139
+ "Set DISPLAY to an accessible live X11 session, or install Xvfb for headless operation. "
140
+ "Install the 'xvfb' package (Debian/Ubuntu: sudo apt install xvfb; Fedora/RHEL: sudo dnf install xorg-x11-server-Xvfb) "
141
+ "and retry start_session."
142
+ )
143
+ return xvfb_bin
144
+
145
+
146
+ def session_displays(display_offset: int) -> tuple[str, str, bool]:
147
+ """Choose inherited desktop display or two Answer42-owned Xvfb displays.
148
+
149
+ Returns ``(manager_display, client_display, starts_xvfb)``. A live X11
150
+ display is deliberately shared by manager and client: it is the caller's
151
+ explicit request to run visibly on that desktop. Otherwise the historical
152
+ isolated two-Xvfb setup is retained for headless operation.
153
+ """
154
+ desktop_display = inherited_x11_display()
155
+ if desktop_display:
156
+ return desktop_display, desktop_display, False
157
+ if os_support.IS_WINDOWS:
158
+ return "", "", False
159
+ require_xvfb()
160
+ manager_display = find_free_display(DEFAULT_DISPLAY_START + display_offset)
161
+ # The first X socket does not exist until start_xvfb() is called, so derive
162
+ # the second search position from the selected number instead of asking the
163
+ # same free-display scan twice.
164
+ client_display = find_free_display(int(manager_display.removeprefix(":")) + 1)
165
+ return manager_display, client_display, True
166
+
167
+
168
+ def start_xvfb(display: str, resolution: str = DEFAULT_DISPLAY_RES) -> subprocess.Popen:
169
+ """Start Xvfb for the given display and return the Popen handle."""
170
+ xvfb_bin = require_xvfb()
128
171
  proc = subprocess.Popen(
129
172
  [xvfb_bin, display, "-screen", "0", resolution, "-ac", "+extension", "GLX", "+render", "-noreset"],
130
173
  stdout=subprocess.DEVNULL,
@@ -2,7 +2,6 @@ from __future__ import annotations
2
2
 
3
3
  import argparse
4
4
  import asyncio
5
- import base64
6
5
  import copy
7
6
  import hashlib
8
7
  import importlib.resources
@@ -29,6 +28,7 @@ from mcp.server import MCPServer
29
28
  from mcp.server.lowlevel import NotificationOptions
30
29
  from mcp.server.mcpserver.resources import FileResource
31
30
  from mcp.server.subscriptions import ToolsListChanged
31
+ from starlette.responses import FileResponse, PlainTextResponse
32
32
 
33
33
  from . import __version__, os_support, release_helper, runtime, skill_installer
34
34
  from . import platform as _platform
@@ -84,6 +84,11 @@ FORM_HINTS_LOCK = os_support.runtime_path("ONEC_MCP_FORM_HINTS_LOCK", "form-hint
84
84
  UI_TREE_RESOURCE_AUTO_THRESHOLD_TOKENS = int(os.getenv("ONEC_MCP_UI_TREE_RESOURCE_AUTO_THRESHOLD_TOKENS", "50000"))
85
85
  UI_TREE_RESOURCE_AUTO_THRESHOLD_BYTES = int(os.getenv("ONEC_MCP_UI_TREE_RESOURCE_AUTO_THRESHOLD_BYTES", "0")) # deprecated fallback; 0 disables
86
86
  NOTIFY_TOOLS_CHANGED_ON_STARTUP = os.getenv("ONEC_MCP_NOTIFY_TOOLS_CHANGED_ON_STARTUP", "1").strip().lower() not in {"0", "false", "no", "off"}
87
+ SCREENSHOT_URL_TTL_SECONDS = max(1, int(os.getenv("ONEC_MCP_SCREENSHOT_URL_TTL_SECONDS", "3600")))
88
+ # Set only in StreamableHTTP mode. stdio deliberately returns filesystem paths
89
+ # and never serializes screenshot bytes into an MCP tool response.
90
+ _SCREENSHOT_URL_BASE = ""
91
+ _SCREENSHOT_URLS: dict[str, tuple[Path, float]] = {}
87
92
 
88
93
 
89
94
  def _float_env(names: str | tuple[str, ...], default: float) -> float:
@@ -5210,11 +5215,18 @@ async def start_session(
5210
5215
  if sid in _SESSIONS:
5211
5216
  raise ValueError(f"Session {sid!r} already exists.")
5212
5217
 
5218
+ # A caller that inherited DISPLAY explicitly requests visible automation
5219
+ # on that live X11 session. In a headless process, retain two private Xvfb
5220
+ # displays and validate the prerequisite before allocating session state.
5221
+ inherited_display = runtime.inherited_x11_display()
5222
+ if not os_support.IS_WINDOWS and not inherited_display:
5223
+ runtime.require_xvfb()
5224
+
5213
5225
  data_dir = os_support.default_data_dir() / "sessions" / sid
5214
5226
  with _port_allocation_lock():
5215
5227
  port_offset = _stable_session_port_offset(sid)
5216
5228
  display_offset = port_offset // _PORT_BAND_SIZE
5217
- display = "" if os_support.IS_WINDOWS else runtime.find_free_display(runtime.DEFAULT_DISPLAY_START + display_offset)
5229
+ display, client_display, starts_xvfb = runtime.session_displays(display_offset)
5218
5230
  ws_port = _find_session_port(runtime.DEFAULT_WS_PORT, port_offset, sub_offset=0)
5219
5231
  ibsrv_port = _find_session_port(runtime.DEFAULT_IBSRV_HTTP_PORT, port_offset, sub_offset=0)
5220
5232
  ibsrv_direct_regport = _find_session_port(runtime.DEFAULT_IBSRV_DIRECT_PORT, port_offset, sub_offset=0)
@@ -5244,13 +5256,10 @@ async def start_session(
5244
5256
  bound_snapshot = _snapshot_for_base_url(base_url)
5245
5257
  if bound_snapshot:
5246
5258
  sess.rag_snapshot = bound_snapshot
5247
- if display:
5259
+ sess.client_display = client_display
5260
+ if starts_xvfb:
5248
5261
  sess.xvfb_process = runtime.start_xvfb(display)
5249
5262
  sess.xvfb_pid = sess.xvfb_process.pid
5250
- # Separate display for the test client so screenshots capture only the client window
5251
- client_display = "" if os_support.IS_WINDOWS else runtime.find_free_display(runtime.DEFAULT_DISPLAY_START + display_offset + 1)
5252
- sess.client_display = client_display
5253
- if client_display:
5254
5263
  sess.client_xvfb_process = runtime.start_xvfb(client_display)
5255
5264
  sess.client_xvfb_pid = sess.client_xvfb_process.pid
5256
5265
  await sess.bridge.start()
@@ -10972,6 +10981,42 @@ async def _dev_eval(
10972
10981
  return await _bridge_call_recorded(session_id, "dev_eval", {"code": code})
10973
10982
 
10974
10983
 
10984
+ def _prune_expired_screenshot_urls(now: float | None = None) -> None:
10985
+ now = time.time() if now is None else now
10986
+ for token, (_, expires_at) in list(_SCREENSHOT_URLS.items()):
10987
+ if expires_at <= now:
10988
+ _SCREENSHOT_URLS.pop(token, None)
10989
+
10990
+
10991
+ @mcp.custom_route("/_answer42/screenshots/{token}", methods=["GET"], include_in_schema=False)
10992
+ async def _download_screenshot(request: Any) -> Any:
10993
+ """Serve a single-use opaque StreamableHTTP screenshot link."""
10994
+ token = str(request.path_params.get("token") or "")
10995
+ _prune_expired_screenshot_urls()
10996
+ entry = _SCREENSHOT_URLS.pop(token, None)
10997
+ if entry is None:
10998
+ return PlainTextResponse("Screenshot link is expired or unavailable.", status_code=404)
10999
+ image_path, expires_at = entry
11000
+ if expires_at <= time.time() or not image_path.is_file():
11001
+ return PlainTextResponse("Screenshot link is expired or unavailable.", status_code=404)
11002
+ return FileResponse(image_path, media_type="image/png", filename=image_path.name)
11003
+
11004
+
11005
+ def _register_screenshot_url(path: Path) -> dict[str, Any] | None:
11006
+ """Expose an opaque, time-limited screenshot URL in StreamableHTTP mode."""
11007
+ if not _SCREENSHOT_URL_BASE:
11008
+ return None
11009
+ _prune_expired_screenshot_urls()
11010
+ token = uuid.uuid4().hex + uuid.uuid4().hex
11011
+ expires_at = time.time() + SCREENSHOT_URL_TTL_SECONDS
11012
+ _SCREENSHOT_URLS[token] = (path.resolve(), expires_at)
11013
+ return {
11014
+ "url": f"{_SCREENSHOT_URL_BASE}/_answer42/screenshots/{token}",
11015
+ "url_expires_at": datetime.fromtimestamp(expires_at, UTC).isoformat(),
11016
+ "url_ttl_seconds": SCREENSHOT_URL_TTL_SECONDS,
11017
+ }
11018
+
11019
+
10975
11020
  @mcp.tool()
10976
11021
  def screenshot(
10977
11022
  path: Annotated[str, "Output image path"] = "build/screenshots/screenshot.png",
@@ -10982,6 +11027,9 @@ def screenshot(
10982
11027
  ) -> dict[str, Any]:
10983
11028
  """Capture a screenshot from the MCP host. Defaults to the active visible 1C window.
10984
11029
 
11030
+ The response always contains the saved path, never image base64. In
11031
+ StreamableHTTP mode it additionally contains an opaque download URL that
11032
+ expires after one hour by default (``ONEC_MCP_SCREENSHOT_URL_TTL_SECONDS``).
10985
11033
  Screenshots are for user evidence and UI diagnostics only. Do not use them
10986
11034
  to analyze report/table/tabular-document/list data; use structured tools
10987
11035
  such as tabular_document_text, tabular_documents, table_rows,
@@ -11040,7 +11088,8 @@ def screenshot(
11040
11088
  ) from exc
11041
11089
 
11042
11090
  data = output_path.read_bytes()
11043
- result = {"path": str(output_path), "bytes": len(data), "base64": base64.b64encode(data).decode("ascii")}
11091
+ result: dict[str, Any] = {"path": str(output_path), "bytes": len(data)}
11092
+ result.update(_register_screenshot_url(output_path) or {})
11044
11093
  if window:
11045
11094
  result["window_geometry"] = geometry
11046
11095
  if fallback:
@@ -11103,6 +11152,13 @@ async def _run_http(args: argparse.Namespace) -> None:
11103
11152
  stateless_http=args.http_stateless,
11104
11153
  host=args.http_host,
11105
11154
  )
11155
+
11156
+ # Image data is not placed into MCP tool results. The custom route is
11157
+ # registered at module load because the MCP SDK builds the Starlette app
11158
+ # from its custom-route list. The opaque token makes the URL unguessable;
11159
+ # the short lifetime and single use bound access.
11160
+ global _SCREENSHOT_URL_BASE
11161
+ _SCREENSHOT_URL_BASE = (args.http_public_base_url or f"http://{args.http_host}:{args.http_port}").rstrip("/")
11106
11162
  config_uvicorn = uvicorn.Config(
11107
11163
  app,
11108
11164
  host=args.http_host,
@@ -11114,6 +11170,8 @@ async def _run_http(args: argparse.Namespace) -> None:
11114
11170
  try:
11115
11171
  await server.serve()
11116
11172
  finally:
11173
+ _SCREENSHOT_URLS.clear()
11174
+ _SCREENSHOT_URL_BASE = ""
11117
11175
  if tools_changed_task is not None:
11118
11176
  tools_changed_task.cancel()
11119
11177
  with suppress(asyncio.CancelledError):
@@ -11329,6 +11387,11 @@ def main(argv: list[str] | None = None) -> None:
11329
11387
  default=int(os.getenv("ONEC_MCP_HTTP_PORT", "8080")),
11330
11388
  help="port to bind the StreamableHTTP server to",
11331
11389
  )
11390
+ parser.add_argument(
11391
+ "--http-public-base-url",
11392
+ default=os.getenv("ONEC_MCP_SCREENSHOT_URL_BASE", ""),
11393
+ help="public base URL used in temporary StreamableHTTP screenshot links; useful behind a reverse proxy",
11394
+ )
11332
11395
  parser.add_argument(
11333
11396
  "--http-token",
11334
11397
  default=os.getenv("ONEC_MCP_HTTP_TOKEN", ""),
File without changes
File without changes
File without changes
File without changes
File without changes