Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
824 changes: 824 additions & 0 deletions fable_engine/browser.py

Large diffs are not rendered by default.

168 changes: 168 additions & 0 deletions fable_engine/schema.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,10 @@

from __future__ import annotations

from fable_engine.browser import (
DEFAULT_BROWSER_OPEN_TIMEOUT_SECONDS,
MAX_BROWSER_OPEN_TIMEOUT_SECONDS,
)
from fable_engine.session import PHASES

TOOL_SCHEMA = {
Expand Down Expand Up @@ -544,3 +548,167 @@
"required": ["action"]
}
}

BROWSER_TOOL_SCHEMAS = [
{
"name": "browser_open",
"description": "Opens a URL in the stealth agent browser using persistent local logins/profile (<=20 MB RAM ceiling). Supports local dev servers across all languages.",
"inputSchema": {
"type": "object",
"properties": {
"url": {"type": "string", "description": "Target URL or localhost address to open."},
"session_id": {"type": "string", "description": "Optional browser tab/session identifier."},
"timeout": {
"type": "number",
"minimum": 0,
"maximum": MAX_BROWSER_OPEN_TIMEOUT_SECONDS,
"default": DEFAULT_BROWSER_OPEN_TIMEOUT_SECONDS,
"description": "Timeout in seconds for page load (maximum 30 seconds).",
}
},
"required": ["url"]
}
},
{
"name": "browser_navigate",
"description": "Navigates the browser session to a new URL.",
"inputSchema": {
"type": "object",
"properties": {
"url": {"type": "string", "description": "Target URL to navigate to."},
"session_id": {"type": "string", "description": "Optional browser tab/session identifier."},
"timeout": {
"type": "number",
"minimum": 0,
"maximum": MAX_BROWSER_OPEN_TIMEOUT_SECONDS,
"default": DEFAULT_BROWSER_OPEN_TIMEOUT_SECONDS,
"description": "Timeout in seconds for page load (maximum 30 seconds).",
}
},
"required": ["url"]
}
},
{
"name": "browser_click",
"description": "Navigates to the href of a link element by its stable element ID. Elements without an href are unsupported.",
"inputSchema": {
"type": "object",
"properties": {
"element_id": {"type": "string", "description": "Stable element ID of an href-bearing link."},
"session_id": {"type": "string", "description": "Optional browser tab/session identifier."}
},
"required": ["element_id"]
}
},
{
"name": "browser_type",
"description": "Types text into an input field identified by its stable element ID.",
"inputSchema": {
"type": "object",
"properties": {
"element_id": {"type": "string", "description": "Stable element ID to type into."},
"text": {"type": "string", "description": "Text content to enter into element."},
"session_id": {"type": "string", "description": "Optional browser tab/session identifier."}
},
"required": ["element_id", "text"]
}
},
{
"name": "browser_scroll",
"description": "Scrolls the page vertically by delta pixels.",
"inputSchema": {
"type": "object",
"properties": {
"delta_y": {"type": "integer", "description": "Vertical pixel scroll offset."},
"session_id": {"type": "string", "description": "Optional browser tab/session identifier."}
},
"required": ["delta_y"]
}
},
{
"name": "browser_snapshot_layers",
"description": "Captures N sequential screen-sized viewport PNG snapshots (1 layer = 1 PC viewport height, 1280x800 px).",
"inputSchema": {
"type": "object",
"properties": {
"max_layers": {"type": "integer", "minimum": 1, "maximum": 10, "description": "Maximum number of screen-sized viewport layers to capture (default 3)."},
"session_id": {"type": "string", "description": "Optional browser tab/session identifier."}
}
}
},
{
"name": "browser_screenshot",
"description": "Captures a single viewport PNG snapshot of the current scroll position.",
"inputSchema": {
"type": "object",
"properties": {
"session_id": {"type": "string", "description": "Optional browser tab/session identifier."}
}
}
},
{
"name": "browser_close",
"description": "Closes an active browser tab session.",
"inputSchema": {
"type": "object",
"properties": {
"session_id": {"type": "string", "description": "Optional browser tab/session identifier."}
}
}
},
{
"name": "browser_back",
"description": "Navigates back in history for the browser session.",
"inputSchema": {
"type": "object",
"properties": {
"session_id": {"type": "string", "description": "Optional browser tab/session identifier."}
}
}
},
{
"name": "browser_forward",
"description": "Navigates forward in history for the browser session.",
"inputSchema": {
"type": "object",
"properties": {
"session_id": {"type": "string", "description": "Optional browser tab/session identifier."}
}
}
},
{
"name": "browser_wait",
"description": "Waits for a specified duration in seconds.",
"inputSchema": {
"type": "object",
"properties": {
"seconds": {"type": "number", "minimum": 0, "maximum": 10, "description": "Seconds to wait."},
"session_id": {"type": "string", "description": "Optional browser tab/session identifier."}
},
"required": ["seconds"]
}
},
{
"name": "browser_press",
"description": "Edits supported text controls, moves focus with Tab, or activates links with Enter.",
"inputSchema": {
"type": "object",
"properties": {
"key": {"type": "string", "description": "Keyboard key to press (e.g. 'Enter', 'Tab')."},
"element_id": {"type": "string", "description": "Optional element ID target."},
"session_id": {"type": "string", "description": "Optional browser tab/session identifier."}
},
"required": ["key"]
}
},
{
"name": "browser_reload",
"description": "Reloads the current page in the browser session.",
"inputSchema": {
"type": "object",
"properties": {
"session_id": {"type": "string", "description": "Optional browser tab/session identifier."}
}
}
}
]
106 changes: 103 additions & 3 deletions fable_engine/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,12 @@
EpistemicEvidenceValidator,
ModelVelocityProfiler,
)
from fable_engine.schema import TOOL_SCHEMA
from fable_engine.browser import (
DEFAULT_BROWSER_OPEN_TIMEOUT_SECONDS,
GLOBAL_BROWSER_ENGINE,
bound_browser_timeout,
)
from fable_engine.schema import BROWSER_TOOL_SCHEMAS, TOOL_SCHEMA
from fable_engine.session import (
ACTIVE_SESSIONS,
FORCE_UNLOCK_ENV,
Expand Down Expand Up @@ -110,6 +115,7 @@ def __getattr__(name: str) -> Any:

MAX_RPC_LINE_BYTES = 1 * 1024 * 1024
MAX_RPC_RESPONSE_BYTES = 2 * 1024 * 1024
BROWSER_TOOL_NAMES = frozenset(tool["name"] for tool in BROWSER_TOOL_SCHEMAS)


def send_response(response_dict: Dict[str, Any]):
Expand Down Expand Up @@ -172,7 +178,13 @@ def _bounded_lines(stream, limit: int):

def main():
logger.info("Starting Fable-Engine MCP Server on stdio...")
if AutoUpdater is not None:
if (
AutoUpdater is not None
and not os.environ.get("FABLE_DISABLE_AUTO_UPDATE")
and not os.environ.get("PYTEST_CURRENT_TEST")
and not os.environ.get("GITHUB_ACTIONS")
and not os.environ.get("CI")
):
try:
AutoUpdater().trigger_silent_background_update()
except Exception as e:
Expand Down Expand Up @@ -245,7 +257,7 @@ def main():
"jsonrpc": "2.0",
"id": msg_id,
"result": {
"tools": [TOOL_SCHEMA]
"tools": [TOOL_SCHEMA] + BROWSER_TOOL_SCHEMAS
}
})

Expand Down Expand Up @@ -288,6 +300,94 @@ def main():
"isError": True
}
})
elif tool_name in BROWSER_TOOL_NAMES:
try:
sid = arguments.get("session_id")
res: Any = None

if tool_name in ("browser_open", "browser_navigate"):
timeout = bound_browser_timeout(
arguments.get("timeout", DEFAULT_BROWSER_OPEN_TIMEOUT_SECONDS)
)

if tool_name == "browser_close":
res = GLOBAL_BROWSER_ENGINE.close_session(sid)
elif tool_name == "browser_wait":
sec = float(arguments.get("seconds", 1.0))
import time
bounded_sec = min(max(sec, 0), 10)
time.sleep(bounded_sec)
res = {"status": "waited", "seconds": bounded_sec}
else:
session = GLOBAL_BROWSER_ENGINE.get_or_create_session(sid)
Comment thread
coderabbitai[bot] marked this conversation as resolved.

if tool_name in ("browser_open", "browser_navigate"):
url = str(arguments.get("url", ""))
res = session.open(url, timeout=timeout)
Comment thread
coderabbitai[bot] marked this conversation as resolved.
elif tool_name == "browser_click":
elem_id = str(arguments.get("element_id", ""))
res = session.click(elem_id)
elif tool_name == "browser_type":
elem_id = str(arguments.get("element_id", ""))
txt = str(arguments.get("text", ""))
res = session.type_text(elem_id, txt)
elif tool_name == "browser_scroll":
delta_y = int(arguments.get("delta_y", 0))
res = session.scroll(delta_y)
elif tool_name == "browser_snapshot_layers":
max_l = int(arguments.get("max_layers", 3))
res = session.snapshot_layers(max_layers=max_l)
elif tool_name == "browser_screenshot":
res = session.snapshot_viewport()
elif tool_name == "browser_close":
pass
elif tool_name == "browser_back":
res = session.back()
elif tool_name == "browser_forward":
res = session.forward()
elif tool_name == "browser_reload":
res = session.reload()
elif tool_name == "browser_wait":
pass
elif tool_name == "browser_press":
key = str(arguments.get("key", ""))
elem_id = arguments.get("element_id")
res = session.press_key(key, elem_id)
else:
raise ValueError(f"Unknown browser tool action: {tool_name}")

is_error = (
isinstance(res, dict)
and (res.get("status") == "error" or "error" in res)
)
Comment thread
coderabbitai[bot] marked this conversation as resolved.
send_response({
"jsonrpc": "2.0",
"id": msg_id,
"result": {
"content": [
{
"type": "text",
"text": json.dumps(res, indent=2)
}
],
"isError": is_error
}
})
except Exception as ex:
logger.error(f"Error handling {tool_name}: {ex}", exc_info=True)
send_response({
"jsonrpc": "2.0",
"id": msg_id,
"result": {
"content": [
{
"type": "text",
"text": f"Stealth Browser Error: {str(ex)}"
}
],
"isError": True
}
})
else:
send_response({
"jsonrpc": "2.0",
Expand Down
2 changes: 2 additions & 0 deletions fable_engine/session.py
Original file line number Diff line number Diff line change
Expand Up @@ -275,6 +275,8 @@ def __init__(
AutoUpdater is not None
and not os.environ.get("FABLE_DISABLE_AUTO_UPDATE")
and not os.environ.get("PYTEST_CURRENT_TEST")
and not os.environ.get("GITHUB_ACTIONS")
and not os.environ.get("CI")
):
try:
AutoUpdater().trigger_silent_background_update()
Expand Down
6 changes: 4 additions & 2 deletions fable_engine/test_server.py
Original file line number Diff line number Diff line change
Expand Up @@ -780,8 +780,10 @@ def test_mcp_handshake_and_tool_call(self):
# 2. Tools List
resp = self._rpc_call("tools/list")
tools = resp["result"]["tools"]
self.assertEqual(len(tools), 1)
self.assertEqual(tools[0]["name"], "fable_session")
self.assertEqual(len(tools), 14)
tool_names = [t["name"] for t in tools]
self.assertIn("fable_session", tool_names)
self.assertIn("browser_open", tool_names)
self.assertIn("log_refinement_cycle", tools[0]["inputSchema"]["properties"]["action"]["enum"])

# 3. Call fable_session: create_session
Expand Down
1 change: 1 addition & 0 deletions fable_mode/manifest.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
"fable_mode/safety.py",
"fable_mode/resources.json",
"fable_engine/__init__.py",
"fable_engine/browser.py",
"fable_engine/actions/__init__.py",
"fable_engine/actions/cas.py",
"fable_engine/actions/deliberation.py",
Expand Down
1 change: 1 addition & 0 deletions fable_mode/resources.json
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
"fable_mode/safety.py",
"fable_mode/resources.json",
"fable_engine/__init__.py",
"fable_engine/browser.py",
"fable_engine/actions/__init__.py",
"fable_engine/actions/cas.py",
"fable_engine/actions/deliberation.py",
Expand Down
3 changes: 3 additions & 0 deletions run_tests.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,11 +7,14 @@

import argparse
import ast
import os
import sys
import subprocess
import time
from pathlib import Path

os.environ["FABLE_DISABLE_AUTO_UPDATE"] = "1"


# Explicitly documented allowlist for legitimate dynamic evaluation
ALLOWED_EVAL_FILES = {
Expand Down
1 change: 1 addition & 0 deletions tests/test_execution_broker.py
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@ def test_command_is_allowlisted_and_runs_after_authorization(self):
def test_inspect_files_is_implemented_and_bounded(self):
target = self.workspace / "input.txt"
target.write_text("hello world", encoding="utf-8")
target.chmod(0o600)
result = self.broker.handle({"action": "inspect_files", "path": "input.txt"})
self.assertEqual(result["content"], "hello world")
self.assertFalse(result["truncated"])
Expand Down
Loading
Loading