browsercontrol 0.1.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.
@@ -0,0 +1,135 @@
1
+ """Content extraction tools for browser control."""
2
+
3
+ import logging
4
+ from fastmcp import FastMCP
5
+ from fastmcp.utilities.types import Image
6
+
7
+ from browsercontrol.browser import browser, get_element_map
8
+
9
+ logger = logging.getLogger(__name__)
10
+
11
+
12
+ async def _get_screenshot_with_summary() -> tuple[Image, str]:
13
+ """Helper to get annotated screenshot with element summary."""
14
+ screenshot_bytes, elem_map = await browser.screenshot_with_som()
15
+ image = Image(data=screenshot_bytes, format="png")
16
+
17
+ summary_lines = [f"Found {len(elem_map)} interactive elements:"]
18
+ for eid, elem in list(elem_map.items())[:30]:
19
+ desc = elem["text"][:40] if elem["text"] else elem["tag"]
20
+ summary_lines.append(f" [{eid}] {elem['tag']} - {desc}")
21
+
22
+ if len(elem_map) > 30:
23
+ summary_lines.append(f" ... and {len(elem_map) - 30} more")
24
+
25
+ return image, "\n".join(summary_lines)
26
+
27
+
28
+ def register_content_tools(mcp: FastMCP) -> None:
29
+ """Register content extraction tools with the MCP server."""
30
+
31
+ @mcp.tool()
32
+ async def get_page_content() -> tuple[str, Image]:
33
+ """Get the page content as markdown text."""
34
+ try:
35
+ await browser.ensure_started()
36
+ from markdownify import markdownify
37
+
38
+ html = await browser.page.content()
39
+ markdown = markdownify(html, heading_style="ATX", strip=["script", "style"])
40
+
41
+ if len(markdown) > 30000:
42
+ markdown = markdown[:30000] + "\n\n... [content truncated]"
43
+
44
+ image, summary = await _get_screenshot_with_summary()
45
+ return f"{markdown}\n\n---\n{summary}", image
46
+
47
+ except Exception as e:
48
+ logger.error(f"Get page content failed: {e}")
49
+ raise RuntimeError(f"Get page content failed: {e}")
50
+
51
+ @mcp.tool()
52
+ async def get_text(element_id: int) -> tuple[str, Image]:
53
+ """
54
+ Get the text content of an element by its ID.
55
+
56
+ Args:
57
+ element_id: The number label of the element
58
+ """
59
+ try:
60
+ await browser.ensure_started()
61
+ elem_map = get_element_map()
62
+
63
+ if element_id not in elem_map:
64
+ image, summary = await _get_screenshot_with_summary()
65
+ return f"Error: Element {element_id} not found.\n\n{summary}", image
66
+
67
+ elem = elem_map[element_id]
68
+ text = elem.get("text", "")
69
+
70
+ image, summary = await _get_screenshot_with_summary()
71
+ return f"Element {element_id} text: {text}\n\n{summary}", image
72
+
73
+ except Exception as e:
74
+ logger.error(f"Get text failed: {e}")
75
+ raise RuntimeError(f"Get text failed: {e}")
76
+
77
+ @mcp.tool()
78
+ async def get_page_info() -> tuple[str, Image]:
79
+ """Get current page URL and title."""
80
+ try:
81
+ await browser.ensure_started()
82
+ url = browser.page.url
83
+ title = await browser.page.title()
84
+
85
+ image, summary = await _get_screenshot_with_summary()
86
+ return f"Title: {title}\nURL: {url}\n\n{summary}", image
87
+
88
+ except Exception as e:
89
+ logger.error(f"Get page info failed: {e}")
90
+ raise RuntimeError(f"Get page info failed: {e}")
91
+
92
+ @mcp.tool()
93
+ async def run_javascript(script: str) -> tuple[str, Image]:
94
+ """
95
+ Execute JavaScript and return the result.
96
+
97
+ Args:
98
+ script: JavaScript code to execute
99
+ """
100
+ try:
101
+ await browser.ensure_started()
102
+ logger.info(f"Executing JavaScript: {script[:50]}...")
103
+ result = await browser.page.evaluate(script)
104
+ image, summary = await _get_screenshot_with_summary()
105
+ return f"Result: {result}\n\n{summary}", image
106
+
107
+ except Exception as e:
108
+ logger.error(f"Run JavaScript failed: {e}")
109
+ raise RuntimeError(f"Run JavaScript failed: {e}")
110
+
111
+ @mcp.tool()
112
+ async def screenshot(annotate: bool = True, full_page: bool = False) -> tuple[str, Image]:
113
+ """
114
+ Take a screenshot of the page.
115
+
116
+ Args:
117
+ annotate: If True, overlay numbered element markers (default). If False, clean screenshot.
118
+ full_page: If True, capture the full scrollable page.
119
+ """
120
+ try:
121
+ await browser.ensure_started()
122
+
123
+ if annotate and not full_page:
124
+ image, summary = await _get_screenshot_with_summary()
125
+ return f"Screenshot captured (annotated)\n\n{summary}", image
126
+ else:
127
+ screenshot_bytes = await browser.page.screenshot(type="png", full_page=full_page)
128
+ image = Image(data=screenshot_bytes, format="png")
129
+ return "Screenshot captured (clean)", image
130
+
131
+ except Exception as e:
132
+ logger.error(f"Screenshot failed: {e}")
133
+ raise RuntimeError(f"Screenshot failed: {e}")
134
+
135
+ logger.debug("Registered content tools")
@@ -0,0 +1,355 @@
1
+ """Developer tools for browser control - console, network, errors."""
2
+
3
+ import logging
4
+ from fastmcp import FastMCP
5
+ from fastmcp.utilities.types import Image
6
+
7
+ from browsercontrol.browser import browser
8
+
9
+ logger = logging.getLogger(__name__)
10
+
11
+
12
+ async def _get_screenshot_with_summary() -> tuple[Image, str]:
13
+ """Helper to get annotated screenshot with element summary."""
14
+ screenshot_bytes, elem_map = await browser.screenshot_with_som()
15
+ image = Image(data=screenshot_bytes, format="png")
16
+
17
+ summary_lines = [f"Found {len(elem_map)} interactive elements:"]
18
+ for eid, elem in list(elem_map.items())[:30]:
19
+ desc = elem["text"][:40] if elem["text"] else elem["tag"]
20
+ summary_lines.append(f" [{eid}] {elem['tag']} - {desc}")
21
+
22
+ if len(elem_map) > 30:
23
+ summary_lines.append(f" ... and {len(elem_map) - 30} more")
24
+
25
+ return image, "\n".join(summary_lines)
26
+
27
+
28
+ def register_devtools(mcp: FastMCP) -> None:
29
+ """Register developer tools with the MCP server."""
30
+
31
+ @mcp.tool()
32
+ async def get_console_logs(clear: bool = False) -> tuple[str, Image]:
33
+ """
34
+ Get browser console logs (errors, warnings, info, log messages).
35
+
36
+ Args:
37
+ clear: If True, clear the captured logs after returning them
38
+
39
+ Returns:
40
+ Console logs and screenshot
41
+ """
42
+ try:
43
+ await browser.ensure_started()
44
+
45
+ # Get console messages from our captured logs
46
+ logs = browser.get_console_logs()
47
+
48
+ if not logs:
49
+ log_text = "No console logs captured."
50
+ else:
51
+ log_lines = []
52
+ for log in logs[-50:]: # Last 50 logs
53
+ level = log.get("level", "log").upper()
54
+ text = log.get("text", "")
55
+ location = log.get("location", "")
56
+ if location:
57
+ log_lines.append(f"[{level}] {text} ({location})")
58
+ else:
59
+ log_lines.append(f"[{level}] {text}")
60
+ log_text = "\n".join(log_lines)
61
+
62
+ if clear:
63
+ browser.clear_console_logs()
64
+
65
+ image, summary = await _get_screenshot_with_summary()
66
+ return f"Console Logs:\n{log_text}\n\n{summary}", image
67
+
68
+ except Exception as e:
69
+ logger.error(f"Get console logs failed: {e}")
70
+ raise RuntimeError(f"Get console logs failed: {e}")
71
+
72
+ @mcp.tool()
73
+ async def get_network_requests(clear: bool = False) -> tuple[str, Image]:
74
+ """
75
+ Get captured network requests (API calls, resources, etc.).
76
+
77
+ Args:
78
+ clear: If True, clear the captured requests after returning them
79
+
80
+ Returns:
81
+ Network requests and screenshot
82
+ """
83
+ try:
84
+ await browser.ensure_started()
85
+
86
+ requests = browser.get_network_requests()
87
+
88
+ if not requests:
89
+ request_text = "No network requests captured."
90
+ else:
91
+ request_lines = []
92
+ for req in requests[-30:]: # Last 30 requests
93
+ method = req.get("method", "GET")
94
+ url = req.get("url", "")
95
+ status = req.get("status", "pending")
96
+ duration = req.get("duration", "")
97
+
98
+ # Truncate long URLs
99
+ if len(url) > 80:
100
+ url = url[:77] + "..."
101
+
102
+ line = f"{method} {url} -> {status}"
103
+ if duration:
104
+ line += f" ({duration}ms)"
105
+ request_lines.append(line)
106
+
107
+ request_text = "\n".join(request_lines)
108
+
109
+ if clear:
110
+ browser.clear_network_requests()
111
+
112
+ image, summary = await _get_screenshot_with_summary()
113
+ return f"Network Requests:\n{request_text}\n\n{summary}", image
114
+
115
+ except Exception as e:
116
+ logger.error(f"Get network requests failed: {e}")
117
+ raise RuntimeError(f"Get network requests failed: {e}")
118
+
119
+ @mcp.tool()
120
+ async def get_page_errors() -> tuple[str, Image]:
121
+ """
122
+ Get JavaScript errors that occurred on the page.
123
+
124
+ Returns:
125
+ Page errors and screenshot
126
+ """
127
+ try:
128
+ await browser.ensure_started()
129
+
130
+ errors = browser.get_page_errors()
131
+
132
+ if not errors:
133
+ error_text = "No JavaScript errors detected."
134
+ else:
135
+ error_lines = []
136
+ for err in errors[-20:]:
137
+ message = err.get("message", "Unknown error")
138
+ stack = err.get("stack", "")
139
+ if stack:
140
+ # Just first line of stack
141
+ stack_first = stack.split("\n")[0] if "\n" in stack else stack
142
+ error_lines.append(f" {message}\n {stack_first}")
143
+ else:
144
+ error_lines.append(f" {message}")
145
+
146
+ error_text = "\n".join(error_lines)
147
+
148
+ image, summary = await _get_screenshot_with_summary()
149
+ return f"Page Errors:\n{error_text}\n\n{summary}", image
150
+
151
+ except Exception as e:
152
+ logger.error(f"Get page errors failed: {e}")
153
+ raise RuntimeError(f"Get page errors failed: {e}")
154
+
155
+ @mcp.tool()
156
+ async def run_in_console(code: str) -> tuple[str, Image]:
157
+ """
158
+ Execute JavaScript code in the browser console and return the result.
159
+ Useful for debugging, inspecting variables, or manipulating the page.
160
+
161
+ Args:
162
+ code: JavaScript code to execute in the console
163
+
164
+ Returns:
165
+ Result of the code execution and screenshot
166
+ """
167
+ try:
168
+ await browser.ensure_started()
169
+ logger.info(f"Executing in console: {code[:100]}...")
170
+
171
+ # Wrap in try-catch to capture errors nicely
172
+ wrapped_code = f"""
173
+ (() => {{
174
+ try {{
175
+ const result = eval({repr(code)});
176
+ if (result === undefined) return 'undefined';
177
+ if (result === null) return 'null';
178
+ if (typeof result === 'object') {{
179
+ try {{
180
+ return JSON.stringify(result, null, 2);
181
+ }} catch (e) {{
182
+ return String(result);
183
+ }}
184
+ }}
185
+ return String(result);
186
+ }} catch (error) {{
187
+ return 'Error: ' + error.message;
188
+ }}
189
+ }})()
190
+ """
191
+
192
+ result = await browser.page.evaluate(wrapped_code)
193
+
194
+ image, summary = await _get_screenshot_with_summary()
195
+ return f"Console Result:\n{result}\n\n{summary}", image
196
+
197
+ except Exception as e:
198
+ logger.error(f"Run in console failed: {e}")
199
+ try:
200
+ image, summary = await _get_screenshot_with_summary()
201
+ return f"Error executing code: {e}\n\n{summary}", image
202
+ except Exception:
203
+ raise RuntimeError(f"Run in console failed: {e}")
204
+
205
+ @mcp.tool()
206
+ async def inspect_element(element_id: int) -> tuple[str, Image]:
207
+ """
208
+ Inspect an element to get its computed styles, dimensions, and properties.
209
+
210
+ Args:
211
+ element_id: The number label of the element to inspect
212
+
213
+ Returns:
214
+ Element details and screenshot
215
+ """
216
+ try:
217
+ await browser.ensure_started()
218
+ from browsercontrol.browser import get_element_map
219
+
220
+ elem_map = get_element_map()
221
+ if element_id not in elem_map:
222
+ image, summary = await _get_screenshot_with_summary()
223
+ return f"Error: Element {element_id} not found.\n\n{summary}", image
224
+
225
+ elem = elem_map[element_id]
226
+
227
+ # Get detailed info about the element
228
+ inspect_code = f"""
229
+ (() => {{
230
+ const el = document.elementFromPoint({elem['centerX']}, {elem['centerY']});
231
+ if (!el) return {{ error: 'Element not found at coordinates' }};
232
+
233
+ const rect = el.getBoundingClientRect();
234
+ const styles = window.getComputedStyle(el);
235
+
236
+ return {{
237
+ tag: el.tagName.toLowerCase(),
238
+ id: el.id || null,
239
+ classes: Array.from(el.classList),
240
+ text: el.innerText?.substring(0, 200) || '',
241
+ href: el.href || null,
242
+ src: el.src || null,
243
+ value: el.value || null,
244
+ dimensions: {{
245
+ width: Math.round(rect.width),
246
+ height: Math.round(rect.height),
247
+ top: Math.round(rect.top),
248
+ left: Math.round(rect.left)
249
+ }},
250
+ styles: {{
251
+ color: styles.color,
252
+ backgroundColor: styles.backgroundColor,
253
+ fontSize: styles.fontSize,
254
+ fontFamily: styles.fontFamily,
255
+ display: styles.display,
256
+ position: styles.position,
257
+ zIndex: styles.zIndex
258
+ }},
259
+ attributes: Array.from(el.attributes).map(a => ({{ name: a.name, value: a.value }})).slice(0, 10)
260
+ }};
261
+ }})()
262
+ """
263
+
264
+ info = await browser.page.evaluate(inspect_code)
265
+
266
+ # Format the info nicely
267
+ lines = [f"Element {element_id} Inspection:"]
268
+ lines.append(f" Tag: <{info.get('tag', 'unknown')}>")
269
+ if info.get('id'):
270
+ lines.append(f" ID: #{info['id']}")
271
+ if info.get('classes'):
272
+ lines.append(f" Classes: .{', .'.join(info['classes'])}")
273
+ if info.get('text'):
274
+ lines.append(f" Text: {info['text'][:100]}...")
275
+ if info.get('href'):
276
+ lines.append(f" Href: {info['href']}")
277
+
278
+ dims = info.get('dimensions', {})
279
+ lines.append(f" Size: {dims.get('width', '?')}x{dims.get('height', '?')}px")
280
+ lines.append(f" Position: ({dims.get('left', '?')}, {dims.get('top', '?')})")
281
+
282
+ styles = info.get('styles', {})
283
+ lines.append(f" Styles:")
284
+ lines.append(f" color: {styles.get('color', '?')}")
285
+ lines.append(f" background: {styles.get('backgroundColor', '?')}")
286
+ lines.append(f" font: {styles.get('fontSize', '?')} {styles.get('fontFamily', '?')[:30]}")
287
+
288
+ image, summary = await _get_screenshot_with_summary()
289
+ return "\n".join(lines) + f"\n\n{summary}", image
290
+
291
+ except Exception as e:
292
+ logger.error(f"Inspect element failed: {e}")
293
+ raise RuntimeError(f"Inspect element failed: {e}")
294
+
295
+ @mcp.tool()
296
+ async def get_page_performance() -> tuple[str, Image]:
297
+ """
298
+ Get page performance metrics (load time, Core Web Vitals).
299
+
300
+ Returns:
301
+ Performance metrics and screenshot
302
+ """
303
+ try:
304
+ await browser.ensure_started()
305
+
306
+ perf_code = """
307
+ (() => {
308
+ const perf = performance.getEntriesByType('navigation')[0] || {};
309
+ const paint = performance.getEntriesByType('paint');
310
+ const fcp = paint.find(p => p.name === 'first-contentful-paint');
311
+
312
+ return {
313
+ // Navigation timing
314
+ domContentLoaded: Math.round(perf.domContentLoadedEventEnd - perf.startTime),
315
+ loadComplete: Math.round(perf.loadEventEnd - perf.startTime),
316
+ ttfb: Math.round(perf.responseStart - perf.startTime),
317
+
318
+ // Paint timing
319
+ firstPaint: paint.find(p => p.name === 'first-paint')?.startTime?.toFixed(0) || null,
320
+ firstContentfulPaint: fcp?.startTime?.toFixed(0) || null,
321
+
322
+ // Resource count
323
+ resourceCount: performance.getEntriesByType('resource').length,
324
+
325
+ // Memory (if available)
326
+ memory: performance.memory ? {
327
+ usedJSHeapSize: Math.round(performance.memory.usedJSHeapSize / 1024 / 1024),
328
+ totalJSHeapSize: Math.round(performance.memory.totalJSHeapSize / 1024 / 1024)
329
+ } : null
330
+ };
331
+ })()
332
+ """
333
+
334
+ metrics = await browser.page.evaluate(perf_code)
335
+
336
+ lines = ["Page Performance:"]
337
+ lines.append(f" Time to First Byte: {metrics.get('ttfb', '?')}ms")
338
+ if metrics.get('firstContentfulPaint'):
339
+ lines.append(f" First Contentful Paint: {metrics['firstContentfulPaint']}ms")
340
+ lines.append(f" DOM Content Loaded: {metrics.get('domContentLoaded', '?')}ms")
341
+ lines.append(f" Load Complete: {metrics.get('loadComplete', '?')}ms")
342
+ lines.append(f" Resources Loaded: {metrics.get('resourceCount', '?')}")
343
+
344
+ if metrics.get('memory'):
345
+ mem = metrics['memory']
346
+ lines.append(f" JS Heap: {mem['usedJSHeapSize']}MB / {mem['totalJSHeapSize']}MB")
347
+
348
+ image, summary = await _get_screenshot_with_summary()
349
+ return "\n".join(lines) + f"\n\n{summary}", image
350
+
351
+ except Exception as e:
352
+ logger.error(f"Get performance failed: {e}")
353
+ raise RuntimeError(f"Get performance failed: {e}")
354
+
355
+ logger.debug("Registered developer tools")
@@ -0,0 +1,96 @@
1
+ """Form handling tools for browser control."""
2
+
3
+ import logging
4
+ from fastmcp import FastMCP
5
+ from fastmcp.utilities.types import Image
6
+
7
+ from browsercontrol.browser import browser, get_element_map
8
+
9
+ logger = logging.getLogger(__name__)
10
+
11
+
12
+ async def _get_screenshot_with_summary() -> tuple[Image, str]:
13
+ """Helper to get annotated screenshot with element summary."""
14
+ screenshot_bytes, elem_map = await browser.screenshot_with_som()
15
+ image = Image(data=screenshot_bytes, format="png")
16
+
17
+ summary_lines = [f"Found {len(elem_map)} interactive elements:"]
18
+ for eid, elem in list(elem_map.items())[:30]:
19
+ desc = elem["text"][:40] if elem["text"] else elem["tag"]
20
+ summary_lines.append(f" [{eid}] {elem['tag']} - {desc}")
21
+
22
+ if len(elem_map) > 30:
23
+ summary_lines.append(f" ... and {len(elem_map) - 30} more")
24
+
25
+ return image, "\n".join(summary_lines)
26
+
27
+
28
+ def register_form_tools(mcp: FastMCP) -> None:
29
+ """Register form tools with the MCP server."""
30
+
31
+ @mcp.tool()
32
+ async def select_option(element_id: int, option: str) -> tuple[str, Image]:
33
+ """
34
+ Select an option from a dropdown by element ID.
35
+
36
+ Args:
37
+ element_id: The number label of the select element
38
+ option: The value or visible text of the option to select
39
+ """
40
+ try:
41
+ await browser.ensure_started()
42
+ elem_map = get_element_map()
43
+
44
+ if element_id not in elem_map:
45
+ image, summary = await _get_screenshot_with_summary()
46
+ return f"Error: Element {element_id} not found.\n\n{summary}", image
47
+
48
+ elem = elem_map[element_id]
49
+ logger.info(f"Selecting option '{option}' from element {element_id}")
50
+
51
+ await browser.page.mouse.click(elem["centerX"], elem["centerY"])
52
+ await browser.page.wait_for_timeout(200)
53
+
54
+ try:
55
+ await browser.page.get_by_text(option).click(timeout=3000)
56
+ except Exception:
57
+ await browser.page.keyboard.type(option)
58
+ await browser.page.keyboard.press("Enter")
59
+
60
+ image, summary = await _get_screenshot_with_summary()
61
+ return f"Selected '{option}' from element {element_id}\n\n{summary}", image
62
+
63
+ except Exception as e:
64
+ logger.error(f"Select option failed: {e}")
65
+ raise RuntimeError(f"Select option failed: {e}")
66
+
67
+ @mcp.tool()
68
+ async def check_checkbox(element_id: int, check: bool = True) -> tuple[str, Image]:
69
+ """
70
+ Check or uncheck a checkbox by element ID.
71
+
72
+ Args:
73
+ element_id: The number label of the checkbox
74
+ check: True to check, False to uncheck
75
+ """
76
+ try:
77
+ await browser.ensure_started()
78
+ elem_map = get_element_map()
79
+
80
+ if element_id not in elem_map:
81
+ image, summary = await _get_screenshot_with_summary()
82
+ return f"Error: Element {element_id} not found.\n\n{summary}", image
83
+
84
+ elem = elem_map[element_id]
85
+ logger.info(f"{'Checking' if check else 'Unchecking'} element {element_id}")
86
+ await browser.page.mouse.click(elem["centerX"], elem["centerY"])
87
+
88
+ image, summary = await _get_screenshot_with_summary()
89
+ action = "Checked" if check else "Toggled"
90
+ return f"{action} element {element_id}\n\n{summary}", image
91
+
92
+ except Exception as e:
93
+ logger.error(f"Check checkbox failed: {e}")
94
+ raise RuntimeError(f"Check checkbox failed: {e}")
95
+
96
+ logger.debug("Registered form tools")