Skip to content

Commit 97e7075

Browse files
committed
Auto-screenshot on tool error
When JE_AUTOCONTROL_MCP_ERROR_SHOTS is set to a directory, every failed tools/call triggers a debug screenshot saved as <tool>_<ts>.png under that path. The artifact path is appended to both the error message returned to the model and the audit JSONL record, giving a one-step forensic trail for flaky automations. Disabled by default — costs nothing when the env var is unset.
1 parent fed9eb5 commit 97e7075

3 files changed

Lines changed: 107 additions & 3 deletions

File tree

je_auto_control/utils/mcp_server/audit.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,8 @@ def enabled(self) -> bool:
3535

3636
def record(self, *, tool: str, arguments: Dict[str, Any],
3737
status: str, duration_seconds: float,
38-
error_text: Optional[str] = None) -> None:
38+
error_text: Optional[str] = None,
39+
artifact_path: Optional[str] = None) -> None:
3940
"""Append one audit entry. No-ops when no path is configured."""
4041
if self._path is None:
4142
return
@@ -48,6 +49,8 @@ def record(self, *, tool: str, arguments: Dict[str, Any],
4849
}
4950
if error_text is not None:
5051
entry["error"] = error_text
52+
if artifact_path is not None:
53+
entry["artifact_path"] = artifact_path
5154
line = json.dumps(entry, ensure_ascii=False, default=str)
5255
with self._lock:
5356
with open(self._path, "a", encoding="utf-8") as handle:

je_auto_control/utils/mcp_server/server.py

Lines changed: 34 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
"""
99
import itertools
1010
import json
11+
import os
1112
import sys
1213
import threading
1314
import time
@@ -479,14 +480,18 @@ def _handle_tools_call(self, msg_id: Any,
479480
except (OSError, RuntimeError, ValueError, TypeError,
480481
AttributeError, KeyError, NotImplementedError) as error:
481482
autocontrol_logger.warning("MCP tool %s failed: %r", name, error)
483+
artifact = _capture_error_screenshot(name)
482484
self._audit.record(
483485
tool=name, arguments=arguments, status="error",
484486
duration_seconds=time.monotonic() - started_at,
485487
error_text=f"{type(error).__name__}: {error}",
488+
artifact_path=artifact,
486489
)
490+
error_text = f"{type(error).__name__}: {error}"
491+
if artifact is not None:
492+
error_text += f"\n(error screenshot saved to {artifact})"
487493
return {
488-
"content": [{"type": "text",
489-
"text": f"{type(error).__name__}: {error}"}],
494+
"content": [{"type": "text", "text": error_text}],
490495
"isError": True,
491496
}
492497
finally:
@@ -581,6 +586,33 @@ def _stringify_result(value: Any) -> str:
581586
return repr(value)
582587

583588

589+
def _capture_error_screenshot(tool_name: str) -> Optional[str]:
590+
"""Save a debug screenshot when JE_AUTOCONTROL_MCP_ERROR_SHOTS is set."""
591+
debug_dir = os.environ.get("JE_AUTOCONTROL_MCP_ERROR_SHOTS")
592+
if not debug_dir:
593+
return None
594+
target_dir = os.path.realpath(os.fspath(debug_dir))
595+
try:
596+
os.makedirs(target_dir, exist_ok=True)
597+
except OSError as error:
598+
autocontrol_logger.info(
599+
"MCP error-screenshot dir unavailable: %r", error,
600+
)
601+
return None
602+
filename = f"{tool_name}_{int(time.time() * 1000)}.png"
603+
path = os.path.join(target_dir, filename)
604+
try:
605+
from je_auto_control.utils.cv2_utils.screenshot import pil_screenshot
606+
pil_screenshot(file_path=path)
607+
except (OSError, RuntimeError, ValueError, AttributeError,
608+
ImportError) as error:
609+
autocontrol_logger.info(
610+
"MCP failed to capture error screenshot: %r", error,
611+
)
612+
return None
613+
return path
614+
615+
584616
def _file_uri_to_path(uri: str) -> Optional[str]:
585617
"""Convert a ``file://`` URI to a local filesystem path; ``None`` otherwise."""
586618
if not isinstance(uri, str) or not uri.startswith("file://"):

test/unit_test/headless/test_mcp_server.py

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1404,6 +1404,75 @@ def test_launch_process_rejects_empty_argv():
14041404
raise AssertionError("expected ValueError for empty argv")
14051405

14061406

1407+
def test_auto_screenshot_on_error_skipped_when_env_unset(monkeypatch, tmp_path):
1408+
monkeypatch.delenv("JE_AUTOCONTROL_MCP_ERROR_SHOTS", raising=False)
1409+
from je_auto_control.utils.mcp_server.audit import AuditLogger
1410+
audit = AuditLogger(path=str(tmp_path / "audit.jsonl"))
1411+
1412+
def boom(x):
1413+
raise RuntimeError("nope")
1414+
1415+
tool = MCPTool(
1416+
name="boom", description="boom",
1417+
input_schema={"type": "object", "properties": {
1418+
"x": {"type": "integer"}}, "required": ["x"]},
1419+
handler=boom,
1420+
)
1421+
server = MCPServer(tools=[tool], audit_logger=audit)
1422+
server.handle_line(_request("tools/call", params={
1423+
"name": "boom", "arguments": {"x": 1},
1424+
}))
1425+
record = json.loads(open(audit.path, encoding="utf-8").readline())
1426+
assert "artifact_path" not in record
1427+
1428+
1429+
def test_auto_screenshot_on_error_writes_file_when_env_set(
1430+
monkeypatch, tmp_path):
1431+
"""When the env var is set we capture a screenshot via pil_screenshot."""
1432+
debug_dir = tmp_path / "shots"
1433+
monkeypatch.setenv("JE_AUTOCONTROL_MCP_ERROR_SHOTS", str(debug_dir))
1434+
1435+
saved_paths = []
1436+
1437+
def fake_screenshot(file_path=None, screen_region=None):
1438+
saved_paths.append(file_path)
1439+
# Touch the file so the audit record's path actually exists.
1440+
if file_path is not None:
1441+
open(file_path, "wb").close()
1442+
1443+
class _Stub:
1444+
def save(self, *_args, **_kwargs):
1445+
return None
1446+
1447+
size = (1, 1)
1448+
return _Stub()
1449+
1450+
import je_auto_control.utils.cv2_utils.screenshot as screenshot_module
1451+
monkeypatch.setattr(screenshot_module, "pil_screenshot", fake_screenshot)
1452+
1453+
from je_auto_control.utils.mcp_server.audit import AuditLogger
1454+
audit = AuditLogger(path=str(tmp_path / "audit.jsonl"))
1455+
1456+
def boom(x):
1457+
raise RuntimeError("nope")
1458+
1459+
tool = MCPTool(
1460+
name="boom2", description="boom2",
1461+
input_schema={"type": "object", "properties": {
1462+
"x": {"type": "integer"}}, "required": ["x"]},
1463+
handler=boom,
1464+
)
1465+
server = MCPServer(tools=[tool], audit_logger=audit)
1466+
response = _decode(server.handle_line(_request("tools/call", params={
1467+
"name": "boom2", "arguments": {"x": 1},
1468+
})))
1469+
assert response["result"]["isError"] is True
1470+
assert "error screenshot saved to" in response["result"]["content"][0]["text"]
1471+
record = json.loads(open(audit.path, encoding="utf-8").readline())
1472+
assert record["artifact_path"]
1473+
assert saved_paths
1474+
1475+
14071476
def test_default_registry_lists_core_automation_tools():
14081477
names = {tool.name for tool in build_default_tool_registry()}
14091478
expected = {

0 commit comments

Comments
 (0)