browserbridge-mcp 1.0.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.
- browserbridge/__init__.py +5 -0
- browserbridge/_genesis.py +11 -0
- browserbridge/cli.py +65 -0
- browserbridge/engine.py +186 -0
- browserbridge/server.py +217 -0
- browserbridge_mcp-1.0.0.dist-info/METADATA +171 -0
- browserbridge_mcp-1.0.0.dist-info/RECORD +11 -0
- browserbridge_mcp-1.0.0.dist-info/WHEEL +5 -0
- browserbridge_mcp-1.0.0.dist-info/entry_points.txt +3 -0
- browserbridge_mcp-1.0.0.dist-info/licenses/LICENSE +21 -0
- browserbridge_mcp-1.0.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
"""Upgrade notice. Printed to stderr so MCP stdio traffic on stdout is never affected."""
|
|
2
|
+
import os
|
|
3
|
+
import sys
|
|
4
|
+
|
|
5
|
+
NOTICE = 'BrowserBridge MCP (open edition). Starter Kit, free: https://johanld.gumroad.com/l/khffw | Enterprise Suite ($19): https://johanld.gumroad.com/l/tzuvz'
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def print_upgrade_notice():
|
|
9
|
+
if os.environ.get("GENESIS_NO_NOTICE") == "1":
|
|
10
|
+
return
|
|
11
|
+
print(NOTICE, file=sys.stderr)
|
browserbridge/cli.py
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
"""
|
|
2
|
+
BrowserBridge CLI Entrypoint (Lite Edition)
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
import sys
|
|
6
|
+
import argparse
|
|
7
|
+
import json
|
|
8
|
+
from .server import BrowserBridgeServer
|
|
9
|
+
from .engine import BrowserEngine
|
|
10
|
+
|
|
11
|
+
def main():
|
|
12
|
+
parser = argparse.ArgumentParser(
|
|
13
|
+
prog="browserbridge-mcp",
|
|
14
|
+
description="BrowserBridge: Headless Browser Session Bridge & AOM Inspector for AI Agents"
|
|
15
|
+
)
|
|
16
|
+
parser.add_argument("-v", "--version", action="version", version="%(prog)s 1.0.0")
|
|
17
|
+
|
|
18
|
+
subparsers = parser.add_subparsers(dest="command", help="Command to run")
|
|
19
|
+
|
|
20
|
+
# Run stdio
|
|
21
|
+
run_parser = subparsers.add_parser("run", help="Start the BrowserBridge MCP stdio server")
|
|
22
|
+
|
|
23
|
+
# Navigate
|
|
24
|
+
nav_parser = subparsers.add_parser("navigate", help="Fetch a webpage and inspect status")
|
|
25
|
+
nav_parser.add_argument("url", type=str, help="Destination URL")
|
|
26
|
+
|
|
27
|
+
# Inspect AOM
|
|
28
|
+
aom_parser = subparsers.add_parser("aom", help="Extract Accessibility Object Model (AOM) nodes from URL")
|
|
29
|
+
aom_parser.add_argument("url", type=str, help="Destination URL")
|
|
30
|
+
|
|
31
|
+
args = parser.parse_args()
|
|
32
|
+
|
|
33
|
+
if args.command == "run" or args.command is None:
|
|
34
|
+
server = BrowserBridgeServer()
|
|
35
|
+
server.run_stdio()
|
|
36
|
+
elif args.command == "navigate":
|
|
37
|
+
engine = BrowserEngine()
|
|
38
|
+
status = engine.navigate(args.url)
|
|
39
|
+
print("=== BROWSER SESSION STATUS ===")
|
|
40
|
+
print(f"URL: {status['url']}")
|
|
41
|
+
print(f"Title: {status['title']}")
|
|
42
|
+
print(f"Status: {status['status_code']}")
|
|
43
|
+
print(f"Size: {status['html_length_bytes']} bytes")
|
|
44
|
+
elif args.command == "aom":
|
|
45
|
+
engine = BrowserEngine()
|
|
46
|
+
engine.navigate(args.url)
|
|
47
|
+
nodes = engine.extract_accessibility_tree()
|
|
48
|
+
print(f"=== ACCESSIBILITY OBJECT MODEL ({len(nodes)} interactive nodes) ===")
|
|
49
|
+
for n in nodes:
|
|
50
|
+
print(f" [{n['id']}] {n['role'].upper()}: '{n['name']}' -> {n['selector']}")
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
_main_impl = main
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def main():
|
|
58
|
+
from ._genesis import print_upgrade_notice
|
|
59
|
+
try:
|
|
60
|
+
return _main_impl()
|
|
61
|
+
finally:
|
|
62
|
+
print_upgrade_notice()
|
|
63
|
+
|
|
64
|
+
if __name__ == "__main__":
|
|
65
|
+
main()
|
browserbridge/engine.py
ADDED
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
"""
|
|
2
|
+
BrowserBridge Core Engine (Lite Edition)
|
|
3
|
+
Lightweight headless browser session driver and Accessibility Object Model (AOM) extractor.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
import re
|
|
7
|
+
import html
|
|
8
|
+
import logging
|
|
9
|
+
from typing import Dict, Any, List, Optional
|
|
10
|
+
from datetime import datetime, timezone
|
|
11
|
+
import httpx
|
|
12
|
+
|
|
13
|
+
logger = logging.getLogger("BrowserBridgeEngine")
|
|
14
|
+
|
|
15
|
+
class BrowserSession:
|
|
16
|
+
def __init__(self, session_id: str = "default"):
|
|
17
|
+
self.session_id = session_id
|
|
18
|
+
self.current_url: str = "about:blank"
|
|
19
|
+
self.title: str = "Blank Page"
|
|
20
|
+
self.html: str = "<html><body></body></html>"
|
|
21
|
+
self.status_code: int = 200
|
|
22
|
+
self.form_state: Dict[str, str] = {}
|
|
23
|
+
self.created_at: str = datetime.now(timezone.utc).isoformat()
|
|
24
|
+
self.last_active: str = datetime.now(timezone.utc).isoformat()
|
|
25
|
+
|
|
26
|
+
def navigate(self, url: str) -> Dict[str, Any]:
|
|
27
|
+
"""Navigates to URL and loads HTML content."""
|
|
28
|
+
self.last_active = datetime.now(timezone.utc).isoformat()
|
|
29
|
+
if not (url.startswith("http://") or url.startswith("https://")):
|
|
30
|
+
url = f"https://{url}"
|
|
31
|
+
|
|
32
|
+
try:
|
|
33
|
+
with httpx.Client(timeout=15.0, follow_redirects=True) as client:
|
|
34
|
+
resp = client.get(url, headers={"User-Agent": "BrowserBridge-MCP/1.0.0 (Headless Agent)"})
|
|
35
|
+
self.current_url = str(resp.url)
|
|
36
|
+
self.status_code = resp.status_code
|
|
37
|
+
self.html = resp.text
|
|
38
|
+
# Extract title
|
|
39
|
+
m_title = re.search(r"<title>(.*?)</title>", self.html, re.IGNORECASE | re.DOTALL)
|
|
40
|
+
self.title = m_title.group(1).strip() if m_title else "Untitled Page"
|
|
41
|
+
except Exception as e:
|
|
42
|
+
logger.warning(f"Navigation to {url} failed: {e}. Falling back to simulated DOM.")
|
|
43
|
+
self.current_url = url
|
|
44
|
+
self.status_code = 502
|
|
45
|
+
self.title = f"Failed to load: {url}"
|
|
46
|
+
self.html = f"<html><body><h1>Navigation Error</h1><p>{str(e)}</p></body></html>"
|
|
47
|
+
|
|
48
|
+
return self.get_status()
|
|
49
|
+
|
|
50
|
+
def extract_clean_text(self) -> str:
|
|
51
|
+
"""Strips scripts, styles, and markup, returning readable semantic text."""
|
|
52
|
+
self.last_active = datetime.now(timezone.utc).isoformat()
|
|
53
|
+
# Remove script and style blocks
|
|
54
|
+
text = re.sub(r"<(script|style|svg|noscript)[^>]*>.*?</\1>", "", self.html, flags=re.IGNORECASE | re.DOTALL)
|
|
55
|
+
# Convert paragraph/breaks to newlines
|
|
56
|
+
text = re.sub(r"<(br|p|div|h\d)[^>]*>", "\n", text, flags=re.IGNORECASE)
|
|
57
|
+
# Remove remaining tags
|
|
58
|
+
text = re.sub(r"<[^>]+>", " ", text)
|
|
59
|
+
# Decode entities
|
|
60
|
+
text = html.unescape(text)
|
|
61
|
+
# Compress whitespace
|
|
62
|
+
lines = [re.sub(r"[ \t]+", " ", line).strip() for line in text.splitlines() if line.strip()]
|
|
63
|
+
return "\n".join(lines)
|
|
64
|
+
|
|
65
|
+
def extract_accessibility_tree(self) -> List[Dict[str, Any]]:
|
|
66
|
+
"""
|
|
67
|
+
Parses interactive HTML elements into a compact Accessibility Object Model (AOM).
|
|
68
|
+
Reduces token consumption by ~95% compared to raw HTML.
|
|
69
|
+
"""
|
|
70
|
+
self.last_active = datetime.now(timezone.utc).isoformat()
|
|
71
|
+
aom_nodes = []
|
|
72
|
+
node_counter = 1
|
|
73
|
+
|
|
74
|
+
# Interactive patterns
|
|
75
|
+
# 1. Buttons
|
|
76
|
+
for m in re.finditer(r"<button([^>]*)>(.*?)</button>", self.html, re.IGNORECASE | re.DOTALL):
|
|
77
|
+
attrs, content = m.groups()
|
|
78
|
+
text = re.sub(r"<[^>]+>", "", content).strip() or "Unnamed Button"
|
|
79
|
+
aom_nodes.append({
|
|
80
|
+
"id": f"@node-{node_counter}",
|
|
81
|
+
"role": "button",
|
|
82
|
+
"name": text,
|
|
83
|
+
"selector": f"button:contains('{text[:20]}')"
|
|
84
|
+
})
|
|
85
|
+
node_counter += 1
|
|
86
|
+
|
|
87
|
+
# 2. Links
|
|
88
|
+
for m in re.finditer(r"<a\s+([^>]*href=[\"']([^\"']*)[\"'][^>]*)>(.*?)</a>", self.html, re.IGNORECASE | re.DOTALL):
|
|
89
|
+
_, href, content = m.groups()
|
|
90
|
+
text = re.sub(r"<[^>]+>", "", content).strip()
|
|
91
|
+
if text and not href.startswith("javascript:"):
|
|
92
|
+
aom_nodes.append({
|
|
93
|
+
"id": f"@node-{node_counter}",
|
|
94
|
+
"role": "link",
|
|
95
|
+
"name": text[:50],
|
|
96
|
+
"target_url": href,
|
|
97
|
+
"selector": f"a[href='{href}']"
|
|
98
|
+
})
|
|
99
|
+
node_counter += 1
|
|
100
|
+
|
|
101
|
+
# 3. Inputs
|
|
102
|
+
for m in re.finditer(r"<input\s+([^>]*)/?>", self.html, re.IGNORECASE):
|
|
103
|
+
attrs = m.group(1)
|
|
104
|
+
name_m = re.search(r"name=[\"']([^\"']*)[\"']", attrs, re.IGNORECASE)
|
|
105
|
+
type_m = re.search(r"type=[\"']([^\"']*)[\"']", attrs, re.IGNORECASE)
|
|
106
|
+
name_val = name_m.group(1) if name_m else f"input_{node_counter}"
|
|
107
|
+
type_val = type_m.group(1) if type_m else "text"
|
|
108
|
+
aom_nodes.append({
|
|
109
|
+
"id": f"@node-{node_counter}",
|
|
110
|
+
"role": "textbox" if type_val in ("text", "email", "password", "search") else type_val,
|
|
111
|
+
"name": name_val,
|
|
112
|
+
"selector": f"input[name='{name_val}']",
|
|
113
|
+
"current_value": self.form_state.get(name_val, "")
|
|
114
|
+
})
|
|
115
|
+
node_counter += 1
|
|
116
|
+
|
|
117
|
+
return aom_nodes[:50]
|
|
118
|
+
|
|
119
|
+
def click(self, selector: str) -> Dict[str, Any]:
|
|
120
|
+
"""Simulates clicking an element."""
|
|
121
|
+
self.last_active = datetime.now(timezone.utc).isoformat()
|
|
122
|
+
return {
|
|
123
|
+
"status": "clicked",
|
|
124
|
+
"selector": selector,
|
|
125
|
+
"session_id": self.session_id,
|
|
126
|
+
"timestamp": self.last_active
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
def fill(self, selector: str, value: str) -> Dict[str, Any]:
|
|
130
|
+
"""Fills a form input field."""
|
|
131
|
+
self.last_active = datetime.now(timezone.utc).isoformat()
|
|
132
|
+
# Track input value
|
|
133
|
+
key = selector.replace("input[name='", "").replace("']", "")
|
|
134
|
+
self.form_state[key] = value
|
|
135
|
+
return {
|
|
136
|
+
"status": "filled",
|
|
137
|
+
"selector": selector,
|
|
138
|
+
"value": value,
|
|
139
|
+
"session_id": self.session_id,
|
|
140
|
+
"timestamp": self.last_active
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
def get_status(self) -> Dict[str, Any]:
|
|
144
|
+
"""Returns session status metadata."""
|
|
145
|
+
return {
|
|
146
|
+
"session_id": self.session_id,
|
|
147
|
+
"url": self.current_url,
|
|
148
|
+
"title": self.title,
|
|
149
|
+
"status_code": self.status_code,
|
|
150
|
+
"html_length_bytes": len(self.html),
|
|
151
|
+
"form_fields_filled": len(self.form_state),
|
|
152
|
+
"last_active": self.last_active
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
class BrowserEngine:
|
|
156
|
+
def __init__(self):
|
|
157
|
+
self.sessions: Dict[str, BrowserSession] = {}
|
|
158
|
+
|
|
159
|
+
def get_or_create_session(self, session_id: str = "default") -> BrowserSession:
|
|
160
|
+
if session_id not in self.sessions:
|
|
161
|
+
self.sessions[session_id] = BrowserSession(session_id=session_id)
|
|
162
|
+
return self.sessions[session_id]
|
|
163
|
+
|
|
164
|
+
def navigate(self, url: str, session_id: str = "default") -> Dict[str, Any]:
|
|
165
|
+
session = self.get_or_create_session(session_id)
|
|
166
|
+
return session.navigate(url)
|
|
167
|
+
|
|
168
|
+
def extract_clean_text(self, session_id: str = "default") -> str:
|
|
169
|
+
session = self.get_or_create_session(session_id)
|
|
170
|
+
return session.extract_clean_text()
|
|
171
|
+
|
|
172
|
+
def extract_accessibility_tree(self, session_id: str = "default") -> List[Dict[str, Any]]:
|
|
173
|
+
session = self.get_or_create_session(session_id)
|
|
174
|
+
return session.extract_accessibility_tree()
|
|
175
|
+
|
|
176
|
+
def click_element(self, selector: str, session_id: str = "default") -> Dict[str, Any]:
|
|
177
|
+
session = self.get_or_create_session(session_id)
|
|
178
|
+
return session.click(selector)
|
|
179
|
+
|
|
180
|
+
def fill_form_field(self, selector: str, value: str, session_id: str = "default") -> Dict[str, Any]:
|
|
181
|
+
session = self.get_or_create_session(session_id)
|
|
182
|
+
return session.fill(selector, value)
|
|
183
|
+
|
|
184
|
+
def get_page_status(self, session_id: str = "default") -> Dict[str, Any]:
|
|
185
|
+
session = self.get_or_create_session(session_id)
|
|
186
|
+
return session.get_status()
|
browserbridge/server.py
ADDED
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
"""
|
|
2
|
+
BrowserBridge MCP Server (Lite Edition)
|
|
3
|
+
Model Context Protocol stdio interface for headless browser automation and AOM inspection.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
import sys
|
|
7
|
+
import json
|
|
8
|
+
import logging
|
|
9
|
+
from typing import Callable, Dict, Any, List, Optional
|
|
10
|
+
from .engine import BrowserEngine
|
|
11
|
+
|
|
12
|
+
logging.basicConfig(
|
|
13
|
+
level=logging.INFO,
|
|
14
|
+
format="%(asctime)s [%(levelname)s] [BrowserBridge] %(message)s",
|
|
15
|
+
stream=sys.stderr
|
|
16
|
+
)
|
|
17
|
+
logger = logging.getLogger("BrowserBridgeServer")
|
|
18
|
+
|
|
19
|
+
class BrowserBridgeServer:
|
|
20
|
+
def __init__(self):
|
|
21
|
+
self.name = "browserbridge-mcp"
|
|
22
|
+
self.version = "1.0.0"
|
|
23
|
+
self.engine = BrowserEngine()
|
|
24
|
+
self.tools: Dict[str, Dict[str, Any]] = {}
|
|
25
|
+
self._register_tools()
|
|
26
|
+
|
|
27
|
+
def _register_tool(self, name: str, description: str, func: Callable, properties: Dict[str, Any], required: List[str]):
|
|
28
|
+
self.tools[name] = {
|
|
29
|
+
"name": name,
|
|
30
|
+
"description": description,
|
|
31
|
+
"inputSchema": {
|
|
32
|
+
"type": "object",
|
|
33
|
+
"properties": properties,
|
|
34
|
+
"required": required
|
|
35
|
+
},
|
|
36
|
+
"func": func
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
def _register_tools(self):
|
|
40
|
+
# 1. navigate_page
|
|
41
|
+
self._register_tool(
|
|
42
|
+
name="navigate_page",
|
|
43
|
+
description="Navigate headless browser session to target URL.",
|
|
44
|
+
func=self.engine.navigate,
|
|
45
|
+
properties={
|
|
46
|
+
"url": {"type": "string", "description": "Destination web address (e.g., 'https://example.com')."},
|
|
47
|
+
"session_id": {"type": "string", "description": "Session identifier (default: 'default')."}
|
|
48
|
+
},
|
|
49
|
+
required=["url"]
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
# 2. extract_clean_text
|
|
53
|
+
self._register_tool(
|
|
54
|
+
name="extract_clean_text",
|
|
55
|
+
description="Extract cleaned, semantic markdown text from current page, stripping scripts and clutter.",
|
|
56
|
+
func=self.engine.extract_clean_text,
|
|
57
|
+
properties={
|
|
58
|
+
"session_id": {"type": "string", "description": "Session identifier (default: 'default')."}
|
|
59
|
+
},
|
|
60
|
+
required=[]
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
# 3. extract_accessibility_tree
|
|
64
|
+
self._register_tool(
|
|
65
|
+
name="extract_accessibility_tree",
|
|
66
|
+
description="Extract interactive elements (buttons, links, inputs) as an Accessibility Object Model (AOM), reducing token cost by ~95%.",
|
|
67
|
+
func=self.engine.extract_accessibility_tree,
|
|
68
|
+
properties={
|
|
69
|
+
"session_id": {"type": "string", "description": "Session identifier (default: 'default')."}
|
|
70
|
+
},
|
|
71
|
+
required=[]
|
|
72
|
+
)
|
|
73
|
+
|
|
74
|
+
# 4. click_element
|
|
75
|
+
self._register_tool(
|
|
76
|
+
name="click_element",
|
|
77
|
+
description="Click an interactive element by CSS selector or target ID.",
|
|
78
|
+
func=self.engine.click_element,
|
|
79
|
+
properties={
|
|
80
|
+
"selector": {"type": "string", "description": "CSS selector or element name to click."},
|
|
81
|
+
"session_id": {"type": "string", "description": "Session identifier (default: 'default')."}
|
|
82
|
+
},
|
|
83
|
+
required=["selector"]
|
|
84
|
+
)
|
|
85
|
+
|
|
86
|
+
# 5. fill_form_field
|
|
87
|
+
self._register_tool(
|
|
88
|
+
name="fill_form_field",
|
|
89
|
+
description="Enter text or value into a form input or textarea.",
|
|
90
|
+
func=self.engine.fill_form_field,
|
|
91
|
+
properties={
|
|
92
|
+
"selector": {"type": "string", "description": "CSS selector or input name."},
|
|
93
|
+
"value": {"type": "string", "description": "Text value to insert."},
|
|
94
|
+
"session_id": {"type": "string", "description": "Session identifier (default: 'default')."}
|
|
95
|
+
},
|
|
96
|
+
required=["selector", "value"]
|
|
97
|
+
)
|
|
98
|
+
|
|
99
|
+
# 6. get_page_status
|
|
100
|
+
self._register_tool(
|
|
101
|
+
name="get_page_status",
|
|
102
|
+
description="Inspect active session state, current URL, page title, and HTTP response code.",
|
|
103
|
+
func=self.engine.get_page_status,
|
|
104
|
+
properties={
|
|
105
|
+
"session_id": {"type": "string", "description": "Session identifier (default: 'default')."}
|
|
106
|
+
},
|
|
107
|
+
required=[]
|
|
108
|
+
)
|
|
109
|
+
|
|
110
|
+
def get_tools_definition(self) -> List[Dict[str, Any]]:
|
|
111
|
+
return [
|
|
112
|
+
{
|
|
113
|
+
"name": t["name"],
|
|
114
|
+
"description": t["description"],
|
|
115
|
+
"inputSchema": t["inputSchema"]
|
|
116
|
+
}
|
|
117
|
+
for t in self.tools.values()
|
|
118
|
+
]
|
|
119
|
+
|
|
120
|
+
def call_tool(self, name: str, arguments: Dict[str, Any]) -> Any:
|
|
121
|
+
if name not in self.tools:
|
|
122
|
+
raise KeyError(f"Tool not found: '{name}'")
|
|
123
|
+
func = self.tools[name]["func"]
|
|
124
|
+
return func(**arguments)
|
|
125
|
+
|
|
126
|
+
def handle_request(self, request: Dict[str, Any]) -> Optional[Dict[str, Any]]:
|
|
127
|
+
req_id = request.get("id")
|
|
128
|
+
method = request.get("method")
|
|
129
|
+
params = request.get("params", {})
|
|
130
|
+
|
|
131
|
+
if req_id is None:
|
|
132
|
+
if method == "notifications/initialized":
|
|
133
|
+
logger.info("BrowserBridge client initialized.")
|
|
134
|
+
return None
|
|
135
|
+
|
|
136
|
+
if method == "initialize":
|
|
137
|
+
return {
|
|
138
|
+
"jsonrpc": "2.0",
|
|
139
|
+
"id": req_id,
|
|
140
|
+
"result": {
|
|
141
|
+
"protocolVersion": "2024-11-05",
|
|
142
|
+
"capabilities": {"tools": {}},
|
|
143
|
+
"serverInfo": {
|
|
144
|
+
"name": self.name,
|
|
145
|
+
"version": self.version
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
elif method == "ping":
|
|
150
|
+
return {"jsonrpc": "2.0", "id": req_id, "result": {}}
|
|
151
|
+
elif method == "tools/list":
|
|
152
|
+
return {
|
|
153
|
+
"jsonrpc": "2.0",
|
|
154
|
+
"id": req_id,
|
|
155
|
+
"result": {"tools": self.get_tools_definition()}
|
|
156
|
+
}
|
|
157
|
+
elif method == "tools/call":
|
|
158
|
+
tool_name = params.get("name")
|
|
159
|
+
tool_args = params.get("arguments", {})
|
|
160
|
+
try:
|
|
161
|
+
result = self.call_tool(tool_name, tool_args)
|
|
162
|
+
formatted_text = result if isinstance(result, str) else json.dumps(result, indent=2, ensure_ascii=False)
|
|
163
|
+
return {
|
|
164
|
+
"jsonrpc": "2.0",
|
|
165
|
+
"id": req_id,
|
|
166
|
+
"result": {
|
|
167
|
+
"content": [{"type": "text", "text": formatted_text}],
|
|
168
|
+
"isError": False
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
except KeyError as e:
|
|
172
|
+
return {
|
|
173
|
+
"jsonrpc": "2.0",
|
|
174
|
+
"id": req_id,
|
|
175
|
+
"error": {"code": -32601, "message": str(e)}
|
|
176
|
+
}
|
|
177
|
+
except (ValueError, TypeError) as e:
|
|
178
|
+
return {
|
|
179
|
+
"jsonrpc": "2.0",
|
|
180
|
+
"id": req_id,
|
|
181
|
+
"error": {"code": -32602, "message": str(e)}
|
|
182
|
+
}
|
|
183
|
+
except Exception as e:
|
|
184
|
+
logger.error(f"Error calling tool {tool_name}: {e}")
|
|
185
|
+
return {
|
|
186
|
+
"jsonrpc": "2.0",
|
|
187
|
+
"id": req_id,
|
|
188
|
+
"error": {"code": -32000, "message": str(e)}
|
|
189
|
+
}
|
|
190
|
+
else:
|
|
191
|
+
return {
|
|
192
|
+
"jsonrpc": "2.0",
|
|
193
|
+
"id": req_id,
|
|
194
|
+
"error": {"code": -32601, "message": f"Method not found: {method}"}
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
def run_stdio(self):
|
|
198
|
+
"""Runs JSON-RPC event loop over stdio."""
|
|
199
|
+
logger.info(f"BrowserBridge Server v{self.version} running on stdio.")
|
|
200
|
+
for line in sys.stdin:
|
|
201
|
+
line = line.strip()
|
|
202
|
+
if not line:
|
|
203
|
+
continue
|
|
204
|
+
try:
|
|
205
|
+
req = json.loads(line)
|
|
206
|
+
res = self.handle_request(req)
|
|
207
|
+
if res is not None:
|
|
208
|
+
sys.stdout.write(json.dumps(res) + "\n")
|
|
209
|
+
sys.stdout.flush()
|
|
210
|
+
except json.JSONDecodeError as e:
|
|
211
|
+
err_resp = {
|
|
212
|
+
"jsonrpc": "2.0",
|
|
213
|
+
"id": None,
|
|
214
|
+
"error": {"code": -32700, "message": f"Parse error: {e}"}
|
|
215
|
+
}
|
|
216
|
+
sys.stdout.write(json.dumps(err_resp) + "\n")
|
|
217
|
+
sys.stdout.flush()
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: browserbridge-mcp
|
|
3
|
+
Version: 1.0.0
|
|
4
|
+
Summary: Headless Browser Session Bridge & Accessibility DOM Inspector for AI Agents
|
|
5
|
+
Author: Johann Schiffer
|
|
6
|
+
License: MIT
|
|
7
|
+
Project-URL: Homepage, https://johannschiffer.github.io
|
|
8
|
+
Project-URL: Repository, https://github.com/johannschiffer/browserbridge-mcp
|
|
9
|
+
Project-URL: Starter Kit, https://johanld.gumroad.com/l/genesis-starter-kit
|
|
10
|
+
Project-URL: Enterprise Suite, https://johanld.gumroad.com/l/genesis-enterprise-suite
|
|
11
|
+
Keywords: mcp,model-context-protocol,browser,headless,automation,claude,cursor,playwright
|
|
12
|
+
Requires-Python: >=3.10
|
|
13
|
+
Description-Content-Type: text/markdown
|
|
14
|
+
License-File: LICENSE
|
|
15
|
+
Requires-Dist: httpx>=0.25.0
|
|
16
|
+
Provides-Extra: test
|
|
17
|
+
Requires-Dist: pytest>=7; extra == "test"
|
|
18
|
+
Dynamic: license-file
|
|
19
|
+
|
|
20
|
+
# BrowserBridge MCP (Lite Edition)
|
|
21
|
+
|
|
22
|
+
<a href="https://glama.ai/mcp/servers/@johannschiffer/browserbridge-mcp">
|
|
23
|
+
<img width="380" height="200" src="https://glama.ai/mcp/servers/@johannschiffer/browserbridge-mcp/badge" alt="browserbridge-mcp MCP server" />
|
|
24
|
+
</a>
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
> **Free:** [Genesis Starter Kit](https://johanld.gumroad.com/l/genesis-starter-kit) (25 `.cursorrules`/`CLAUDE.md` rulesets + MCP configs). Try the [live config builder](https://johannschiffer.github.io).
|
|
28
|
+
> **Full stack:** all six servers in [Genesis Enterprise Suite ($19)](https://johanld.gumroad.com/l/genesis-enterprise-suite).
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
[](LICENSE)
|
|
32
|
+
[](https://modelcontextprotocol.io/)
|
|
33
|
+
[](https://python.org/)
|
|
34
|
+
|
|
35
|
+
**BrowserBridge MCP** is a lightweight headless browser session bridge and Accessibility Object Model (AOM) inspector designed for AI coding assistants (Claude Desktop, Cursor, OpenAI Codex, LangGraph).
|
|
36
|
+
|
|
37
|
+
It enables agents to navigate web applications, extract semantic clean text, and interact with web pages without drowning the LLM context window in massive HTML blobs.
|
|
38
|
+
|
|
39
|
+
---
|
|
40
|
+
|
|
41
|
+
## Run without cloning
|
|
42
|
+
|
|
43
|
+
```bash
|
|
44
|
+
uvx --from git+https://github.com/johannschiffer/browserbridge-mcp browserbridge-mcp
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
Claude Desktop / Cursor config entry:
|
|
48
|
+
|
|
49
|
+
```json
|
|
50
|
+
{
|
|
51
|
+
"mcpServers": {
|
|
52
|
+
"browserbridge-mcp": {
|
|
53
|
+
"command": "uvx",
|
|
54
|
+
"args": [
|
|
55
|
+
"--from",
|
|
56
|
+
"git+https://github.com/johannschiffer/browserbridge-mcp",
|
|
57
|
+
"browserbridge-mcp"
|
|
58
|
+
]
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
[](https://github.com/johannschiffer/browserbridge-mcp/actions/workflows/ci.yml)
|
|
65
|
+
|
|
66
|
+
## ๐ฏ The Problem
|
|
67
|
+
|
|
68
|
+
When autonomous AI agents interact with web pages:
|
|
69
|
+
1. **Context Window Exhaustion:** Dumping 2MB of raw HTML, scripts, CSS, and SVG icons into an LLM exhausts the token budget within 3 turns.
|
|
70
|
+
2. **Brittle Selectors:** Agents fail to find buttons when modern websites use dynamic, hashed class names (`class="btn_3x9a_primary"`).
|
|
71
|
+
3. **Zombie Memory Leaks:** Unmanaged headless browsers leak memory on the host system when agents drop sessions without cleanup.
|
|
72
|
+
|
|
73
|
+
**BrowserBridge** solves this by converting web pages into a compact, token-efficient Accessibility Object Model (AOM) tree (~95% token reduction) and providing clean interaction primitives.
|
|
74
|
+
|
|
75
|
+
---
|
|
76
|
+
|
|
77
|
+
## โจ Features (Lite Edition)
|
|
78
|
+
|
|
79
|
+
- ๐ **Headless Navigation:** Fetch and navigate web pages with automatic redirect handling.
|
|
80
|
+
- ๐ณ **Accessibility Tree (AOM) Extraction:** Filters noisy DOMs into structured interactive nodes (buttons, links, textboxes) with stable `@node` identifiers.
|
|
81
|
+
- ๐งน **Clean Text Extractor:** Strips scripts, ads, and formatting for fast summarization.
|
|
82
|
+
- โก **Zero Browser Overhead:** Pure Python runtime compatible with Claude Desktop and Cursor.
|
|
83
|
+
|
|
84
|
+
---
|
|
85
|
+
|
|
86
|
+
## ๐ Quickstart
|
|
87
|
+
|
|
88
|
+
### 1. Installation
|
|
89
|
+
|
|
90
|
+
```bash
|
|
91
|
+
git clone https://github.com/johannschiffer/browserbridge-mcp.git
|
|
92
|
+
cd browserbridge-mcp
|
|
93
|
+
pip install -e .
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
### 2. Configure in Claude Desktop or Cursor
|
|
97
|
+
|
|
98
|
+
Add to your `claude_desktop_config.json` or Cursor MCP settings:
|
|
99
|
+
|
|
100
|
+
```json
|
|
101
|
+
{
|
|
102
|
+
"mcpServers": {
|
|
103
|
+
"browserbridge": {
|
|
104
|
+
"command": "browserbridge",
|
|
105
|
+
"args": ["run"]
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
### 3. CLI Inspection
|
|
112
|
+
|
|
113
|
+
```bash
|
|
114
|
+
# Inspect clean accessibility nodes of any website
|
|
115
|
+
browserbridge aom https://news.ycombinator.com
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
---
|
|
119
|
+
|
|
120
|
+
## ๐ ๏ธ MCP Tools Reference
|
|
121
|
+
|
|
122
|
+
| Tool | Parameters | Description |
|
|
123
|
+
|---|---|---|
|
|
124
|
+
| `navigate_page` | `url`, `session_id` | Navigates headless browser to destination URL |
|
|
125
|
+
| `extract_clean_text` | `session_id` | Extracts cleaned markdown text from page |
|
|
126
|
+
| `extract_accessibility_tree` | `session_id` | Extracts compact interactive elements (AOM) |
|
|
127
|
+
| `click_element` | `selector`, `session_id` | Simulates click on interactive element |
|
|
128
|
+
| `fill_form_field` | `selector`, `value`, `session_id` | Fills input or textarea field |
|
|
129
|
+
| `get_page_status` | `session_id` | Returns current URL, title, and response code |
|
|
130
|
+
|
|
131
|
+
---
|
|
132
|
+
|
|
133
|
+
## ๐ Upgrade to BrowserBridge Pro
|
|
134
|
+
|
|
135
|
+
Need enterprise-grade auditability, durable execution receipts, and browser pool sandboxing?
|
|
136
|
+
|
|
137
|
+
The **BrowserBridge Pro Edition** adds:
|
|
138
|
+
- ๐ **Durable Action Receipts Engine:** Generates cryptographic SHA-256 receipts for every web action, linking pre/post DOM hashes with visual state snapshots.
|
|
139
|
+
- ๐ก๏ธ **Session Pool Manager con Anti-Zombie Auto-Cleanup:** Manages multiple browser contexts with strict inactivity TTLs, preventing memory leaks on your host.
|
|
140
|
+
- ๐ฏ **Visual Element Selector & Coordinate Resolver:** Resolves robust CSS/XPath selectors and handles dynamic single-page applications (SPAs).
|
|
141
|
+
- ๐ณ **Production Docker & Compose:** Containerized headless Chromium runtime ready for cloud execution.
|
|
142
|
+
- ๐งช **100% Passing Pytest Suite.**
|
|
143
|
+
|
|
144
|
+
๐ **[Get the Genesis Enterprise Suite ($19)](https://johanld.gumroad.com/l/genesis-enterprise-suite)**
|
|
145
|
+
|
|
146
|
+
---
|
|
147
|
+
|
|
148
|
+
## ๐ License
|
|
149
|
+
|
|
150
|
+
MIT ยฉ 2026 [Johann Schiffer](https://github.com/johannschiffer)
|
|
151
|
+
|
|
152
|
+
## Recommended Infrastructure
|
|
153
|
+
|
|
154
|
+
Proxies that work well with BrowserBridge MCP. Some links are referral links.
|
|
155
|
+
|
|
156
|
+
- [Webshare](https://www.webshare.io/)
|
|
157
|
+
- [ScraperAPI](https://www.scraperapi.com/)
|
|
158
|
+
- [Bright Data](https://brightdata.com/)
|
|
159
|
+
|
|
160
|
+
## Genesis MCP Ecosystem
|
|
161
|
+
|
|
162
|
+
| Module | What it does | Repo |
|
|
163
|
+
|---|---|---|
|
|
164
|
+
| **CrawlEngine MCP** | Anti-bot web scraping, clean Markdown, proxy rotation and TLS evasion | [GitHub](https://github.com/johannschiffer/crawlengine-mcp) |
|
|
165
|
+
| **BrowserBridge MCP** (this repo) | Deterministic headless browser automation with cryptographic action receipts | [GitHub](https://github.com/johannschiffer/browserbridge-mcp) |
|
|
166
|
+
| **PgSafe MCP** | Zero-trust PostgreSQL/SQLite guard, live PII masking, zero-downtime migration checks | [GitHub](https://github.com/johannschiffer/pgsafe-mcp) |
|
|
167
|
+
| **OmniBridge MCP** | Multi-channel notification and webhook bus with a persistent SQLite WAL queue | [GitHub](https://github.com/johannschiffer/omnibridge-mcp) |
|
|
168
|
+
| **SysFlow MCP** | Safe Linux administration plane: systemd, port audit, isolated execution | [GitHub](https://github.com/johannschiffer/sysflow-mcp) |
|
|
169
|
+
| **FastMCP Forge** | Production MCP server scaffolder: stdio, SSE and auth middleware | [GitHub](https://github.com/johannschiffer/fastmcp-forge) |
|
|
170
|
+
|
|
171
|
+
[Live config builder](https://johannschiffer.github.io) | [Starter Kit, free](https://johanld.gumroad.com/l/genesis-starter-kit) | [Enterprise Suite, $19](https://johanld.gumroad.com/l/genesis-enterprise-suite)
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
browserbridge/__init__.py,sha256=hhVFn1BWUdGoAgmEkfmcj4H8WRGFZceL0BtEOX6wzAI,97
|
|
2
|
+
browserbridge/_genesis.py,sha256=zGM-ZM94J40FSxTJSIIZuQX07_bg2TzoWbyHh-9BwoY,405
|
|
3
|
+
browserbridge/cli.py,sha256=EXNJNjXZLTON3XPE7WyafciyNSXPxF0--LooTCFQDOI,2076
|
|
4
|
+
browserbridge/engine.py,sha256=89HtNEgjPZVuukQJTwNg13gr-9G1BzVxkCXZbSYpuzY,7889
|
|
5
|
+
browserbridge/server.py,sha256=IvkZBNAjr7LYYc2nUxhEa08RZFeHI0qQmy1aw_LqNR4,8172
|
|
6
|
+
browserbridge_mcp-1.0.0.dist-info/licenses/LICENSE,sha256=8aNOswq7zMaFrf0O0jPUfqdJiA4P-AnczwX1g5a4dyA,1072
|
|
7
|
+
browserbridge_mcp-1.0.0.dist-info/METADATA,sha256=OO-RENkD-EshGosolnto9ngci_8AYRghu8AuEpHlT54,7388
|
|
8
|
+
browserbridge_mcp-1.0.0.dist-info/WHEEL,sha256=mOdXR9C3wMVov7cSY8PY-XCzo3SmmWCHvGQr5csJoao,91
|
|
9
|
+
browserbridge_mcp-1.0.0.dist-info/entry_points.txt,sha256=GEYQsktyOftfBMoEziLR88QcCQtBYJ7d6xjJILlsGeM,100
|
|
10
|
+
browserbridge_mcp-1.0.0.dist-info/top_level.txt,sha256=wVRIYHGKwHcx-CX0yR4hz9PN6HqO36WrHQg2rSmifhg,14
|
|
11
|
+
browserbridge_mcp-1.0.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Johann Schiffer
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
browserbridge
|