Skip to content

Commit 55f9d1c

Browse files
authored
Merge pull request #89 from Integration-Automation/dev
dev -> main: MCP browser tools, action ecosystem expansion, static-analysis cleanup
2 parents 1e43679 + 02d1605 commit 55f9d1c

6 files changed

Lines changed: 272 additions & 1 deletion

File tree

examples/rescue_me.json

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
[
2+
["WR_set_driver", {
3+
"webdriver_name": "chrome",
4+
"options": [
5+
"--autoplay-policy=no-user-gesture-required",
6+
"--disable-blink-features=AutomationControlled",
7+
"--mute-audio=false"
8+
]
9+
}],
10+
["WR_to_url", {"url": "https://music.youtube.com/watch?v=jajHOxvEbXk"}],
11+
["WR_sleep", {"seconds": 5}],
12+
["WR_execute_script", {"script": "const v=document.querySelector('video');if(!v)return 'no-video';v.muted=false;v.volume=1.0;const p=v.play();if(p&&p.catch)p.catch(()=>{});return v.paused?'paused':'playing';"}],
13+
["WR_sleep", {"seconds": 90}],
14+
["WR_quit_all"]
15+
]

examples/rescue_me.py

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
"""
2+
Demo: open YouTube Music and play OneRepublic — Rescue Me at 100% volume.
3+
4+
Run from the repo root:
5+
6+
python examples/rescue_me.py
7+
8+
The script launches Chrome with ``--autoplay-policy=no-user-gesture-required``
9+
(YouTube Music blocks ``video.play()`` without a real user gesture otherwise),
10+
navigates to the song's watch URL on music.youtube.com, then forces the
11+
HTMLMediaElement's ``volume`` to 1.0 and calls ``play()`` from JS.
12+
"""
13+
from __future__ import annotations
14+
15+
import sys
16+
import time
17+
18+
from je_web_runner import webdriver_wrapper_instance
19+
20+
21+
RESCUE_ME_URL = "https://music.youtube.com/watch?v=jajHOxvEbXk"
22+
LISTEN_SECONDS = 90
23+
24+
25+
_FORCE_PLAY_JS = """
26+
(() => {
27+
const video = document.querySelector('video');
28+
if (!video) { return 'no-video'; }
29+
video.muted = false;
30+
video.volume = 1.0;
31+
const promise = video.play();
32+
if (promise && typeof promise.catch === 'function') {
33+
promise.catch(() => {});
34+
}
35+
return video.paused ? 'paused' : 'playing';
36+
})()
37+
"""
38+
39+
40+
def _force_play(driver) -> None:
41+
"""Loop the force-play script until the video reports ``playing``."""
42+
for _ in range(8):
43+
if driver.execute_script(_FORCE_PLAY_JS) == "playing":
44+
return
45+
time.sleep(1)
46+
47+
48+
def main() -> int:
49+
chrome_args = [
50+
"--autoplay-policy=no-user-gesture-required",
51+
"--disable-blink-features=AutomationControlled",
52+
"--mute-audio=false",
53+
]
54+
try:
55+
webdriver_wrapper_instance.set_driver("chrome", options=chrome_args)
56+
except Exception as error: # pylint: disable=broad-except
57+
print(f"rescue_me: cannot start chrome ({error!r})", file=sys.stderr)
58+
return 1
59+
60+
driver = webdriver_wrapper_instance.current_webdriver
61+
try:
62+
webdriver_wrapper_instance.to_url(RESCUE_ME_URL)
63+
time.sleep(5)
64+
_force_play(driver)
65+
time.sleep(LISTEN_SECONDS)
66+
except Exception as error: # pylint: disable=broad-except
67+
print(f"rescue_me: playback failed ({error!r})", file=sys.stderr)
68+
return 1
69+
finally:
70+
try:
71+
webdriver_wrapper_instance.quit()
72+
except Exception: # pylint: disable=broad-except # nosec B110 — best-effort cleanup; quit failures aren't actionable here
73+
pass
74+
return 0
75+
76+
77+
if __name__ == "__main__":
78+
sys.exit(main())
Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,13 @@
11
"""WebRunner MCP server: expose WR_* actions over the Model Context Protocol."""
2+
from je_web_runner.mcp_server.browser_tools import build_browser_tools
23
from je_web_runner.mcp_server.server import (
34
McpServer,
45
McpServerError,
56
build_default_tools,
67
serve_stdio,
78
)
89

9-
__all__ = ["McpServer", "McpServerError", "build_default_tools", "serve_stdio"]
10+
__all__ = [
11+
"McpServer", "McpServerError",
12+
"build_default_tools", "build_browser_tools", "serve_stdio",
13+
]
Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,119 @@
1+
"""
2+
MCP tools that drive a real browser via the WebRunner executor.
3+
4+
The executor already maps ~200 ``WR_*`` strings to callables (Selenium,
5+
Playwright, reporting, …); these tools simply hand a JSON-RPC ``arguments``
6+
payload through to ``execute_action`` / ``execute_files``.
7+
8+
Two hazards are handled here so the rest of the protocol stays clean:
9+
10+
* ``execute_action`` prints each record to stdout. The MCP server speaks
11+
JSON-RPC over stdout, so stray prints corrupt the wire. We redirect stdout
12+
into a buffer for the duration of the call and surface it as ``stdout`` in
13+
the result.
14+
* Action return values may contain WebDriver / WebElement instances that
15+
``json.dumps`` cannot serialise. ``_serialize_value`` reduces those to
16+
``repr()`` strings before the server's encoder sees them.
17+
"""
18+
from __future__ import annotations
19+
20+
import io
21+
from contextlib import redirect_stdout
22+
from typing import Any, Dict, List
23+
24+
from je_web_runner.mcp_server.server import McpServerError, Tool
25+
26+
27+
def _serialize_value(value: Any) -> Any:
28+
if value is None or isinstance(value, (bool, int, float, str)):
29+
return value
30+
if isinstance(value, (list, tuple)):
31+
return [_serialize_value(item) for item in value]
32+
if isinstance(value, dict):
33+
return {str(key): _serialize_value(item) for key, item in value.items()}
34+
return repr(value)
35+
36+
37+
def _serialize_record(record: Dict[Any, Any]) -> Dict[str, Any]:
38+
return {str(key): _serialize_value(value) for key, value in record.items()}
39+
40+
41+
def _tool_run_actions(arguments: Dict[str, Any]) -> Any:
42+
from je_web_runner.utils.executor.action_executor import execute_action
43+
actions = arguments.get("actions")
44+
if not isinstance(actions, list):
45+
raise McpServerError("'actions' must be a list of [name, params] entries")
46+
buffer = io.StringIO()
47+
with redirect_stdout(buffer):
48+
record = execute_action(actions)
49+
return {"stdout": buffer.getvalue(), "record": _serialize_record(record)}
50+
51+
52+
def _tool_run_action_files(arguments: Dict[str, Any]) -> Any:
53+
from je_web_runner.utils.executor.action_executor import execute_files
54+
files = arguments.get("files")
55+
if not isinstance(files, list):
56+
raise McpServerError("'files' must be a list of file paths")
57+
if not all(isinstance(path, str) for path in files):
58+
raise McpServerError("each entry in 'files' must be a string path")
59+
buffer = io.StringIO()
60+
with redirect_stdout(buffer):
61+
results = execute_files(files)
62+
return {
63+
"stdout": buffer.getvalue(),
64+
"records": [_serialize_record(record) for record in results],
65+
}
66+
67+
68+
def _tool_list_commands(_arguments: Dict[str, Any]) -> Any:
69+
from je_web_runner.utils.executor.action_executor import executor
70+
return sorted(name for name in executor.event_dict if name.startswith("WR_"))
71+
72+
73+
def build_browser_tools() -> List[Tool]:
74+
"""Return the browser-execution MCP tools."""
75+
return [
76+
Tool(
77+
name="webrunner_run_actions",
78+
description=(
79+
"Execute a WebRunner action list against a real browser. Each"
80+
" entry is [command_name, params] where params is a dict of"
81+
" kwargs or a list of positional args. Common commands:"
82+
" WR_get_webdriver_manager, WR_to_url, WR_send_keys,"
83+
" WR_click_element, WR_pw_launch, WR_pw_to_url, WR_quit."
84+
" Returns {'stdout': str, 'record': {action_repr: result}}."
85+
),
86+
input_schema={
87+
"type": "object",
88+
"properties": {"actions": {"type": "array"}},
89+
"required": ["actions"],
90+
},
91+
handler=_tool_run_actions,
92+
),
93+
Tool(
94+
name="webrunner_run_action_files",
95+
description=(
96+
"Read one or more JSON action files from disk and execute"
97+
" them sequentially against a real browser. Returns"
98+
" {'stdout': str, 'records': [<per-file record>]}."
99+
),
100+
input_schema={
101+
"type": "object",
102+
"properties": {
103+
"files": {"type": "array", "items": {"type": "string"}},
104+
},
105+
"required": ["files"],
106+
},
107+
handler=_tool_run_action_files,
108+
),
109+
Tool(
110+
name="webrunner_list_commands",
111+
description=(
112+
"Return every WR_* command currently registered in the"
113+
" executor, so a caller can discover the action surface"
114+
" before composing webrunner_run_actions payloads."
115+
),
116+
input_schema={"type": "object", "properties": {}},
117+
handler=_tool_list_commands,
118+
),
119+
]

je_web_runner/mcp_server/server.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -618,9 +618,15 @@ def build_default_tools() -> List[Tool]:
618618

619619

620620
def make_default_server() -> McpServer:
621+
# Imported lazily so ``server`` can be imported without dragging the
622+
# full executor (and therefore Selenium / Playwright) into modules that
623+
# only need the protocol skeleton.
624+
from je_web_runner.mcp_server.browser_tools import build_browser_tools
621625
server = McpServer()
622626
for tool in build_default_tools():
623627
server.register(tool)
628+
for tool in build_browser_tools():
629+
server.register(tool)
624630
return server
625631

626632

test/unit_test/test_mcp_server.py

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -144,6 +144,17 @@ def test_full_default_tool_surface(self):
144144
"webrunner_score_action_locators",
145145
})
146146

147+
def test_browser_tools_registered_in_default_server(self):
148+
# Browser execution tools are merged into the default server so MCP
149+
# clients can drive Selenium / Playwright through WR_* actions.
150+
server = make_default_server()
151+
for name in (
152+
"webrunner_run_actions",
153+
"webrunner_run_action_files",
154+
"webrunner_list_commands",
155+
):
156+
self.assertIn(name, server.tools)
157+
147158

148159
class TestNewTools(unittest.TestCase):
149160

@@ -234,5 +245,43 @@ def test_score_action_locators(self):
234245
self.assertIn("score", body)
235246

236247

248+
class TestBrowserTools(unittest.TestCase):
249+
"""Tools that call the executor — covered without launching a browser."""
250+
251+
def setUp(self):
252+
self.server = make_default_server()
253+
254+
def _call(self, name, arguments):
255+
return self.server.handle({"id": 1, "method": "tools/call", "params": {
256+
"name": name, "arguments": arguments,
257+
}})
258+
259+
def test_run_actions_rejects_non_list(self):
260+
result = self._call("webrunner_run_actions", {"actions": "nope"})
261+
self.assertTrue(result["result"]["isError"])
262+
263+
def test_run_action_files_rejects_non_string_paths(self):
264+
result = self._call("webrunner_run_action_files", {"files": [123]})
265+
self.assertTrue(result["result"]["isError"])
266+
267+
def test_list_commands_returns_wr_surface(self):
268+
result = self._call("webrunner_list_commands", {})
269+
body = result["result"]["content"][0]["text"]
270+
self.assertIn("WR_to_url", body)
271+
self.assertIn("WR_quit", body)
272+
273+
def test_run_actions_captures_stdout_and_executes_safe_command(self):
274+
# WR_sleep with 0 seconds is a side-effect-free executor call that
275+
# returns a numeric value — perfect for checking the wiring without
276+
# launching a browser.
277+
result = self._call("webrunner_run_actions",
278+
{"actions": [["WR_sleep", {"seconds": 0}]]})
279+
self.assertFalse(result["result"]["isError"])
280+
body = result["result"]["content"][0]["text"]
281+
self.assertIn('"stdout"', body)
282+
self.assertIn('"record"', body)
283+
self.assertIn("WR_sleep", body)
284+
285+
237286
if __name__ == "__main__":
238287
unittest.main()

0 commit comments

Comments
 (0)