Claude commited on
Commit
213fb38
Β·
unverified Β·
1 Parent(s): 4c31c50

perf: persistent browser, 15 chrome flags, TTL cache, resource blocking, domcontentloaded default

Browse files
Files changed (2) hide show
  1. app.py +239 -135
  2. requirements.txt +1 -0
app.py CHANGED
@@ -1,12 +1,18 @@
1
  #!/usr/bin/env python3
2
- """Browser MCP β€” Playwright Chromium + script management via MCP SSE."""
3
- import asyncio, base64, json, os, subprocess, shutil
4
  from pathlib import Path
5
  from typing import Optional
6
 
 
 
7
  SCRIPTS_DIR = Path("/scripts")
8
  SCRIPTS_DIR.mkdir(exist_ok=True)
9
 
 
 
 
 
10
  import mcp.types as types
11
  from mcp.server import Server
12
  from mcp.server.sse import SseServerTransport
@@ -20,6 +26,29 @@ from playwright.async_api import async_playwright, Browser, BrowserContext, Page
20
  mcp_server = Server("browser-mcp")
21
  sse = SseServerTransport("/browser/messages/")
22
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
23
 
24
  class BrowserManager:
25
  def __init__(self):
@@ -29,25 +58,36 @@ class BrowserManager:
29
  self.tabs: list[Page] = []
30
  self.current: int = 0
31
  self.console_msgs: list[str] = []
 
 
32
 
33
  async def init(self):
34
  self.pw = await async_playwright().start()
35
  self.browser = await self.pw.chromium.launch(
36
  headless=True,
37
- args=["--no-sandbox", "--disable-dev-shm-usage", "--disable-gpu",
38
- "--disable-setuid-sandbox", "--no-zygote"],
39
  )
40
  self.context = await self.browser.new_context(
41
  viewport={"width": 1280, "height": 800},
42
  user_agent=(
43
  "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 "
44
- "(KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36"
45
  ),
46
  )
 
47
  page = await self.context.new_page()
48
  page.on("console", lambda m: self.console_msgs.append(f"[{m.type}] {m.text}"))
49
  self.tabs = [page]
50
  self.current = 0
 
 
 
 
 
 
 
 
 
51
 
52
  async def page(self) -> Page:
53
  if not self.tabs:
@@ -58,7 +98,7 @@ class BrowserManager:
58
  p = await self.context.new_page()
59
  p.on("console", lambda m: self.console_msgs.append(f"[{m.type}] {m.text}"))
60
  if url and url != "about:blank":
61
- await p.goto(url, timeout=30000)
62
  self.tabs.append(p)
63
  self.current = len(self.tabs) - 1
64
  return p
@@ -80,245 +120,321 @@ async def list_tools():
80
  T = types.Tool
81
  return [
82
  T(name="navigate",
83
- description="Navigate to a URL and wait for page to load.",
 
 
 
 
84
  inputSchema={"type": "object", "properties": {
85
  "url": {"type": "string"},
86
- "wait_until": {"type": "string", "default": "load",
87
- "enum": ["load", "domcontentloaded", "networkidle"]}
 
 
88
  }, "required": ["url"]}),
 
89
  T(name="screenshot",
90
- description="Take a screenshot of the current page or a specific element. Returns an image.",
91
  inputSchema={"type": "object", "properties": {
92
  "full_page": {"type": "boolean", "default": False},
93
  "selector": {"type": "string", "description": "CSS selector to screenshot just that element"}
94
  }, "required": []}),
 
95
  T(name="get_text",
96
- description="Get the visible text content of the page or a specific element.",
97
  inputSchema={"type": "object", "properties": {
98
- "selector": {"type": "string", "default": "body"}
 
99
  }, "required": []}),
 
100
  T(name="get_html",
101
- description="Get the HTML source of the page or element.",
102
  inputSchema={"type": "object", "properties": {
103
  "selector": {"type": "string", "default": "html"},
104
- "outer": {"type": "boolean", "default": True}
 
105
  }, "required": []}),
 
106
  T(name="click",
107
- description="Click on an element identified by CSS selector.",
108
  inputSchema={"type": "object", "properties": {
109
  "selector": {"type": "string"},
110
  "button": {"type": "string", "default": "left", "enum": ["left", "right", "middle"]},
111
  "count": {"type": "integer", "default": 1}
112
  }, "required": ["selector"]}),
 
113
  T(name="type_text",
114
- description="Type text into an input element (appends by default).",
115
  inputSchema={"type": "object", "properties": {
116
  "selector": {"type": "string"},
117
  "text": {"type": "string"},
118
- "clear": {"type": "boolean", "default": False, "description": "Clear field first"}
119
  }, "required": ["selector", "text"]}),
 
120
  T(name="fill",
121
- description="Fill a form field (clears existing value first then sets new one).",
122
  inputSchema={"type": "object", "properties": {
123
  "selector": {"type": "string"},
124
  "value": {"type": "string"}
125
  }, "required": ["selector", "value"]}),
 
126
  T(name="select_option",
127
- description="Select an option in a <select> dropdown element.",
128
  inputSchema={"type": "object", "properties": {
129
  "selector": {"type": "string"},
130
- "value": {"type": "string", "description": "Option value attribute"},
131
- "label": {"type": "string", "description": "Option visible text"}
132
  }, "required": ["selector"]}),
 
133
  T(name="hover",
134
- description="Move mouse over an element (triggers hover CSS/JS).",
135
  inputSchema={"type": "object", "properties": {
136
  "selector": {"type": "string"}
137
  }, "required": ["selector"]}),
 
138
  T(name="press_key",
139
- description="Press a keyboard key (e.g. Enter, Tab, Escape, ArrowDown, F5).",
140
  inputSchema={"type": "object", "properties": {
141
  "key": {"type": "string"},
142
  "selector": {"type": "string", "description": "Focus this element first (optional)"}
143
  }, "required": ["key"]}),
 
144
  T(name="evaluate",
145
- description="Execute JavaScript in the browser page and return the result.",
146
  inputSchema={"type": "object", "properties": {
147
- "script": {"type": "string", "description": "JS expression or function body"},
148
- "arg": {"type": "string", "description": "JSON argument passed as first param to function"}
149
  }, "required": ["script"]}),
 
150
  T(name="wait_for",
151
- description="Wait for an element to appear or for a set time.",
152
  inputSchema={"type": "object", "properties": {
153
- "selector": {"type": "string", "description": "CSS selector to wait for"},
154
- "ms": {"type": "integer", "description": "Milliseconds to wait"},
155
  "state": {"type": "string", "default": "visible",
156
  "enum": ["attached", "detached", "visible", "hidden"]}
157
  }, "required": []}),
 
158
  T(name="scroll",
159
- description="Scroll the page or scroll a specific element into view.",
160
  inputSchema={"type": "object", "properties": {
161
  "x": {"type": "integer", "default": 0},
162
  "y": {"type": "integer", "default": 500},
163
- "selector": {"type": "string", "description": "Scroll this element into view"}
164
  }, "required": []}),
165
- T(name="go_back",
166
- description="Navigate back in browser history.",
167
  inputSchema={"type": "object", "properties": {}, "required": []}),
168
- T(name="go_forward",
169
- description="Navigate forward in browser history.",
170
  inputSchema={"type": "object", "properties": {}, "required": []}),
171
- T(name="reload",
172
- description="Reload the current page.",
173
  inputSchema={"type": "object", "properties": {}, "required": []}),
174
- T(name="get_url",
175
- description="Get the current page URL.",
176
  inputSchema={"type": "object", "properties": {}, "required": []}),
177
- T(name="get_title",
178
- description="Get the current page title.",
179
  inputSchema={"type": "object", "properties": {}, "required": []}),
 
180
  T(name="get_links",
181
- description="Get all links on the current page (href + text).",
182
  inputSchema={"type": "object", "properties": {
183
  "selector": {"type": "string", "default": "a"},
184
- "limit": {"type": "integer", "default": 50}
 
185
  }, "required": []}),
 
186
  T(name="find_elements",
187
- description="Find elements by CSS selector and return their text or a specific attribute.",
188
  inputSchema={"type": "object", "properties": {
189
  "selector": {"type": "string"},
190
- "attribute": {"type": "string", "description": "Return this attribute (e.g. href, src, value)"}
191
  }, "required": ["selector"]}),
 
192
  T(name="new_tab",
193
- description="Open a new browser tab, optionally navigating to a URL.",
194
  inputSchema={"type": "object", "properties": {
195
  "url": {"type": "string", "default": "about:blank"}
196
  }, "required": []}),
197
  T(name="switch_tab",
198
- description="Switch to another open tab by its index number.",
199
- inputSchema={"type": "object", "properties": {
200
- "index": {"type": "integer"}
201
- }, "required": ["index"]}),
202
  T(name="close_tab",
203
- description="Close the current tab. Cannot close the last remaining tab.",
204
  inputSchema={"type": "object", "properties": {}, "required": []}),
205
  T(name="list_tabs",
206
- description="List all open browser tabs with their index, URL and title.",
207
  inputSchema={"type": "object", "properties": {}, "required": []}),
208
- T(name="get_cookies",
209
- description="Get all cookies for the current browser context.",
210
  inputSchema={"type": "object", "properties": {}, "required": []}),
211
  T(name="set_cookie",
212
- description="Set a cookie in the browser.",
213
  inputSchema={"type": "object", "properties": {
214
- "name": {"type": "string"},
215
- "value": {"type": "string"},
216
- "url": {"type": "string", "description": "URL for cookie scope (defaults to current page)"}
217
  }, "required": ["name", "value"]}),
218
- T(name="clear_cookies",
219
- description="Clear all cookies from the browser context.",
220
  inputSchema={"type": "object", "properties": {}, "required": []}),
 
 
 
 
221
  T(name="get_console",
222
- description="Get recent browser console log messages (errors, warnings, logs).",
223
  inputSchema={"type": "object", "properties": {
224
  "limit": {"type": "integer", "default": 50}
225
  }, "required": []}),
226
 
227
- # ── Script management ──────────────────────────────────────────
 
 
 
 
 
 
228
  T(name="script_save",
229
- description="Save a reusable automation script (Python or JS). Scripts persist for the session.",
230
  inputSchema={"type": "object", "properties": {
231
- "name": {"type": "string", "description": "Script filename, e.g. login.py or scrape.js"},
232
- "content": {"type": "string", "description": "Script source code"},
233
  }, "required": ["name", "content"]}),
234
- T(name="script_list",
235
- description="List all saved automation scripts.",
236
  inputSchema={"type": "object", "properties": {}, "required": []}),
237
  T(name="script_read",
238
- description="Read the source code of a saved script.",
239
- inputSchema={"type": "object", "properties": {
240
- "name": {"type": "string"}
241
- }, "required": ["name"]}),
242
  T(name="script_delete",
243
  description="Delete a saved script.",
244
- inputSchema={"type": "object", "properties": {
245
- "name": {"type": "string"}
246
- }, "required": ["name"]}),
247
  T(name="script_run",
248
  description=(
249
- "Run a saved Python script inside the container. "
250
- "The script can use 'playwright' and receives the current page URL as argv[1]. "
251
- "stdout/stderr is returned."
252
  ),
253
  inputSchema={"type": "object", "properties": {
254
- "name": {"type": "string", "description": "Script filename to run"},
255
- "args": {"type": "string", "default": "", "description": "Extra CLI args passed to the script"},
256
  "timeout": {"type": "integer", "default": 30},
257
  }, "required": ["name"]}),
258
  T(name="script_run_inline",
259
- description=(
260
- "Write and immediately run a one-off Python script (not saved). "
261
- "Great for complex multi-step automation. "
262
- "Use 'from playwright.sync_api import sync_playwright' if you need browser control."
263
- ),
264
  inputSchema={"type": "object", "properties": {
265
- "code": {"type": "string", "description": "Python code to execute"},
266
- "timeout": {"type": "integer", "default": 60},
267
  }, "required": ["code"]}),
268
  ]
269
 
270
 
 
 
 
 
271
  @mcp_server.call_tool()
272
  async def call_tool(name: str, arguments: dict):
273
  try:
274
  p = await bm.page()
275
 
 
276
  if name == "navigate":
277
- resp = await p.goto(arguments["url"],
278
- wait_until=arguments.get("wait_until", "load"),
279
- timeout=30000)
 
 
 
 
280
  status = resp.status if resp else "unknown"
281
- return ok(f"Navigated β†’ {p.url} (HTTP {status})")
 
282
 
 
283
  if name == "screenshot":
284
  sel = arguments.get("selector")
285
- full = arguments.get("full_page", False)
286
  if sel:
287
  el = await p.query_selector(sel)
288
  if not el:
289
  return ok(f"Element not found: {sel}")
290
  return img(await el.screenshot())
291
- return img(await p.screenshot(full_page=full))
292
 
 
293
  if name == "get_text":
294
  sel = arguments.get("selector", "body")
 
 
 
 
 
295
  el = await p.query_selector(sel)
296
- return ok(await el.inner_text() if el else "Element not found")
 
 
 
297
 
 
298
  if name == "get_html":
299
  sel = arguments.get("selector", "html")
300
  outer = arguments.get("outer", True)
 
 
 
 
 
301
  el = await p.query_selector(sel)
302
  if not el:
303
  return ok("Element not found")
304
- return ok(await el.outer_html() if outer else await el.inner_html())
 
 
 
305
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
306
  if name == "click":
307
  await p.click(arguments["selector"],
308
  button=arguments.get("button", "left"),
309
  click_count=arguments.get("count", 1))
310
  return ok(f"Clicked {arguments['selector']}")
311
 
 
312
  if name == "type_text":
313
  if arguments.get("clear"):
314
  await p.fill(arguments["selector"], "")
315
  await p.locator(arguments["selector"]).type(arguments["text"])
316
  return ok(f"Typed into {arguments['selector']}")
317
 
 
318
  if name == "fill":
319
  await p.fill(arguments["selector"], arguments["value"])
320
  return ok(f"Filled {arguments['selector']}")
321
 
 
322
  if name == "select_option":
323
  sel = arguments["selector"]
324
  if v := arguments.get("value"):
@@ -329,10 +445,12 @@ async def call_tool(name: str, arguments: dict):
329
  return ok("Provide value or label")
330
  return ok(f"Selected option in {sel}")
331
 
 
332
  if name == "hover":
333
  await p.hover(arguments["selector"])
334
  return ok(f"Hovering over {arguments['selector']}")
335
 
 
336
  if name == "press_key":
337
  if sel := arguments.get("selector"):
338
  el = await p.query_selector(sel)
@@ -342,23 +460,24 @@ async def call_tool(name: str, arguments: dict):
342
  await p.keyboard.press(arguments["key"])
343
  return ok(f"Pressed {arguments['key']}")
344
 
 
345
  if name == "evaluate":
346
  arg_raw = arguments.get("arg")
347
  arg = json.loads(arg_raw) if arg_raw else None
348
  result = await p.evaluate(arguments["script"], arg)
349
  return ok(json.dumps(result, ensure_ascii=False, default=str))
350
 
 
351
  if name == "wait_for":
352
  if sel := arguments.get("selector"):
353
- await p.wait_for_selector(sel,
354
- state=arguments.get("state", "visible"),
355
- timeout=15000)
356
  return ok(f"Element {sel} is {arguments.get('state', 'visible')}")
357
  if ms := arguments.get("ms"):
358
  await asyncio.sleep(ms / 1000)
359
  return ok(f"Waited {ms} ms")
360
  return ok("Provide selector or ms")
361
 
 
362
  if name == "scroll":
363
  if sel := arguments.get("selector"):
364
  el = await p.query_selector(sel)
@@ -377,34 +496,23 @@ async def call_tool(name: str, arguments: dict):
377
  return ok(f"Forward β†’ {p.url}")
378
 
379
  if name == "reload":
 
380
  await p.reload()
381
  return ok(f"Reloaded β†’ {p.url}")
382
 
383
- if name == "get_url":
384
- return ok(p.url)
385
-
386
- if name == "get_title":
387
- return ok(await p.title())
388
-
389
- if name == "get_links":
390
- limit = arguments.get("limit", 50)
391
- sel = arguments.get("selector", "a")
392
- links = await p.evaluate(f"""() => Array.from(document.querySelectorAll('{sel}')).slice(0,{limit}).map(a=>{{
393
- return {{text: (a.innerText||a.textContent||'').trim().slice(0,100), href: a.href, title: a.title}}
394
- }})""")
395
- return ok(json.dumps(links, ensure_ascii=False))
396
 
 
397
  if name == "find_elements":
398
  elements = await p.query_selector_all(arguments["selector"])
399
  attr = arguments.get("attribute")
400
  results = []
401
  for el in elements[:50]:
402
- if attr:
403
- results.append(await el.get_attribute(attr))
404
- else:
405
- results.append((await el.inner_text()).strip())
406
  return ok(json.dumps(results, ensure_ascii=False))
407
 
 
408
  if name == "new_tab":
409
  new_page = await bm.new_tab(arguments.get("url", "about:blank"))
410
  return ok(f"Opened tab {bm.current}: {new_page.url}")
@@ -425,19 +533,17 @@ async def call_tool(name: str, arguments: dict):
425
  return ok(f"Closed. Now on tab {bm.current}: {bm.tabs[bm.current].url}")
426
 
427
  if name == "list_tabs":
428
- result = []
429
- for i, t in enumerate(bm.tabs):
430
- result.append({"index": i, "url": t.url, "title": await t.title(), "active": i == bm.current})
431
  return ok(json.dumps(result, ensure_ascii=False))
432
 
 
433
  if name == "get_cookies":
434
- cookies = await bm.context.cookies()
435
- return ok(json.dumps(cookies, ensure_ascii=False, default=str))
436
 
437
  if name == "set_cookie":
438
  await bm.context.add_cookies([{
439
- "name": arguments["name"],
440
- "value": arguments["value"],
441
  "url": arguments.get("url", p.url),
442
  }])
443
  return ok(f"Set cookie '{arguments['name']}'")
@@ -446,12 +552,13 @@ async def call_tool(name: str, arguments: dict):
446
  await bm.context.clear_cookies()
447
  return ok("All cookies cleared")
448
 
 
449
  if name == "get_console":
450
  limit = arguments.get("limit", 50)
451
  msgs = bm.console_msgs[-limit:]
452
  return ok("\n".join(msgs) if msgs else "(no console messages yet)")
453
 
454
- # ── Script management ─────────────────────────────────────────
455
  if name == "script_save":
456
  path = SCRIPTS_DIR / arguments["name"]
457
  path.write_text(arguments["content"])
@@ -461,14 +568,11 @@ async def call_tool(name: str, arguments: dict):
461
  files = sorted(SCRIPTS_DIR.iterdir())
462
  if not files:
463
  return ok("No scripts saved yet.")
464
- lines = [f"{f.name} ({f.stat().st_size} bytes)" for f in files]
465
- return ok("\n".join(lines))
466
 
467
  if name == "script_read":
468
  path = SCRIPTS_DIR / arguments["name"]
469
- if not path.exists():
470
- return ok(f"Script not found: {arguments['name']}")
471
- return ok(path.read_text())
472
 
473
  if name == "script_delete":
474
  path = SCRIPTS_DIR / arguments["name"]
@@ -495,10 +599,9 @@ async def call_tool(name: str, arguments: dict):
495
  return ok("[TIMEOUT]")
496
 
497
  if name == "script_run_inline":
498
- code = arguments["code"]
499
  timeout = min(int(arguments.get("timeout", 60)), 300)
500
  tmp = Path("/tmp/_inline_script.py")
501
- tmp.write_text(code)
502
  try:
503
  r = subprocess.run(["python3", str(tmp)], capture_output=True, text=True, timeout=timeout)
504
  out = []
@@ -521,17 +624,18 @@ async def handle_sse(request: Request):
521
 
522
 
523
  async def handle_health(request: Request):
524
- status = f"browser-mcp OK β€” tabs: {len(bm.tabs)}, current: {bm.current}"
 
 
525
  return Response(status, media_type="text/plain")
526
 
527
 
528
  app = Starlette(routes=[
529
- Route("/sse", endpoint=handle_sse),
530
- Route("/health", endpoint=handle_health),
531
- Mount("/messages/", app=sse.handle_post_message),
532
- ])
533
 
534
  if __name__ == "__main__":
535
- import asyncio, threading
536
  threading.Thread(target=lambda: asyncio.run(bm.init()), daemon=True).start()
537
  uvicorn.run(app, host="0.0.0.0", port=7860)
 
1
  #!/usr/bin/env python3
2
+ """Browser MCP v2 β€” Playwright Chromium + content cache + resource blocking."""
3
+ import asyncio, base64, json, os, subprocess, shutil, threading
4
  from pathlib import Path
5
  from typing import Optional
6
 
7
+ from cachetools import TTLCache
8
+
9
  SCRIPTS_DIR = Path("/scripts")
10
  SCRIPTS_DIR.mkdir(exist_ok=True)
11
 
12
+ # Content cache: URL β†’ text/html (5-min TTL, max 128 pages)
13
+ _page_cache: TTLCache = TTLCache(maxsize=128, ttl=300)
14
+ _cache_lock = asyncio.Lock()
15
+
16
  import mcp.types as types
17
  from mcp.server import Server
18
  from mcp.server.sse import SseServerTransport
 
26
  mcp_server = Server("browser-mcp")
27
  sse = SseServerTransport("/browser/messages/")
28
 
29
+ # Optimized Chrome flags β€” sourced from Playwright Docker + headless benchmarks
30
+ _CHROME_FLAGS = [
31
+ "--headless=new",
32
+ "--no-sandbox",
33
+ "--disable-setuid-sandbox",
34
+ "--disable-dev-shm-usage", # critical in Docker: /dev/shm is only 64 MB
35
+ "--disable-gpu",
36
+ "--disable-software-rasterizer",
37
+ "--disable-extensions",
38
+ "--disable-background-networking", # stops background DNS/resource prefetch
39
+ "--disable-backgrounding-occluded-windows",
40
+ "--disable-renderer-backgrounding",
41
+ "--disable-background-timer-throttling",
42
+ "--no-first-run",
43
+ "--no-zygote",
44
+ "--mute-audio",
45
+ "--hide-scrollbars",
46
+ "--window-size=1280,720",
47
+ ]
48
+
49
+ # Resource types to block when fast_mode=True on navigate
50
+ _BLOCK_TYPES = {"image", "media", "font", "stylesheet"}
51
+
52
 
53
  class BrowserManager:
54
  def __init__(self):
 
58
  self.tabs: list[Page] = []
59
  self.current: int = 0
60
  self.console_msgs: list[str] = []
61
+ self._fast_mode: bool = False # block images/fonts/css
62
+ self._ready = threading.Event()
63
 
64
  async def init(self):
65
  self.pw = await async_playwright().start()
66
  self.browser = await self.pw.chromium.launch(
67
  headless=True,
68
+ args=_CHROME_FLAGS,
 
69
  )
70
  self.context = await self.browser.new_context(
71
  viewport={"width": 1280, "height": 800},
72
  user_agent=(
73
  "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 "
74
+ "(KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36"
75
  ),
76
  )
77
+ await self._setup_routing(self.context)
78
  page = await self.context.new_page()
79
  page.on("console", lambda m: self.console_msgs.append(f"[{m.type}] {m.text}"))
80
  self.tabs = [page]
81
  self.current = 0
82
+ self._ready.set()
83
+
84
+ async def _setup_routing(self, ctx: BrowserContext):
85
+ async def route_handler(route):
86
+ if self._fast_mode and route.request.resource_type in _BLOCK_TYPES:
87
+ await route.abort()
88
+ else:
89
+ await route.continue_()
90
+ await ctx.route("**/*", route_handler)
91
 
92
  async def page(self) -> Page:
93
  if not self.tabs:
 
98
  p = await self.context.new_page()
99
  p.on("console", lambda m: self.console_msgs.append(f"[{m.type}] {m.text}"))
100
  if url and url != "about:blank":
101
+ await p.goto(url, timeout=30000, wait_until="domcontentloaded")
102
  self.tabs.append(p)
103
  self.current = len(self.tabs) - 1
104
  return p
 
120
  T = types.Tool
121
  return [
122
  T(name="navigate",
123
+ description=(
124
+ "Navigate to a URL. Default wait: domcontentloaded (fast). "
125
+ "Use networkidle only if page needs JS to fully render. "
126
+ "fast_mode=true blocks images/fonts/CSS β€” 30-50% faster for text extraction."
127
+ ),
128
  inputSchema={"type": "object", "properties": {
129
  "url": {"type": "string"},
130
+ "wait_until": {"type": "string", "default": "domcontentloaded",
131
+ "enum": ["load", "domcontentloaded", "networkidle"]},
132
+ "fast_mode": {"type": "boolean", "default": False,
133
+ "description": "Block images/fonts/CSS to speed up text-only tasks"},
134
  }, "required": ["url"]}),
135
+
136
  T(name="screenshot",
137
+ description="Take a screenshot of the current page or a specific element. Returns a PNG image.",
138
  inputSchema={"type": "object", "properties": {
139
  "full_page": {"type": "boolean", "default": False},
140
  "selector": {"type": "string", "description": "CSS selector to screenshot just that element"}
141
  }, "required": []}),
142
+
143
  T(name="get_text",
144
+ description="Get visible text of the page or element. Cached 5 min per URL.",
145
  inputSchema={"type": "object", "properties": {
146
+ "selector": {"type": "string", "default": "body"},
147
+ "no_cache": {"type": "boolean", "default": False},
148
  }, "required": []}),
149
+
150
  T(name="get_html",
151
+ description="Get HTML source of the page or element. Cached 5 min per URL.",
152
  inputSchema={"type": "object", "properties": {
153
  "selector": {"type": "string", "default": "html"},
154
+ "outer": {"type": "boolean", "default": True},
155
+ "no_cache": {"type": "boolean", "default": False},
156
  }, "required": []}),
157
+
158
  T(name="click",
159
+ description="Click an element by CSS selector.",
160
  inputSchema={"type": "object", "properties": {
161
  "selector": {"type": "string"},
162
  "button": {"type": "string", "default": "left", "enum": ["left", "right", "middle"]},
163
  "count": {"type": "integer", "default": 1}
164
  }, "required": ["selector"]}),
165
+
166
  T(name="type_text",
167
+ description="Type text into an input element.",
168
  inputSchema={"type": "object", "properties": {
169
  "selector": {"type": "string"},
170
  "text": {"type": "string"},
171
+ "clear": {"type": "boolean", "default": False}
172
  }, "required": ["selector", "text"]}),
173
+
174
  T(name="fill",
175
+ description="Fill a form field (clears first then sets value).",
176
  inputSchema={"type": "object", "properties": {
177
  "selector": {"type": "string"},
178
  "value": {"type": "string"}
179
  }, "required": ["selector", "value"]}),
180
+
181
  T(name="select_option",
182
+ description="Select an option in a <select> dropdown.",
183
  inputSchema={"type": "object", "properties": {
184
  "selector": {"type": "string"},
185
+ "value": {"type": "string"},
186
+ "label": {"type": "string"}
187
  }, "required": ["selector"]}),
188
+
189
  T(name="hover",
190
+ description="Hover over an element (triggers hover CSS/JS).",
191
  inputSchema={"type": "object", "properties": {
192
  "selector": {"type": "string"}
193
  }, "required": ["selector"]}),
194
+
195
  T(name="press_key",
196
+ description="Press a keyboard key (Enter, Tab, Escape, ArrowDown, F5, etc.).",
197
  inputSchema={"type": "object", "properties": {
198
  "key": {"type": "string"},
199
  "selector": {"type": "string", "description": "Focus this element first (optional)"}
200
  }, "required": ["key"]}),
201
+
202
  T(name="evaluate",
203
+ description="Execute JavaScript in the page and return the result.",
204
  inputSchema={"type": "object", "properties": {
205
+ "script": {"type": "string"},
206
+ "arg": {"type": "string", "description": "JSON argument passed as first param"}
207
  }, "required": ["script"]}),
208
+
209
  T(name="wait_for",
210
+ description="Wait for an element to appear, or wait N milliseconds.",
211
  inputSchema={"type": "object", "properties": {
212
+ "selector": {"type": "string"},
213
+ "ms": {"type": "integer"},
214
  "state": {"type": "string", "default": "visible",
215
  "enum": ["attached", "detached", "visible", "hidden"]}
216
  }, "required": []}),
217
+
218
  T(name="scroll",
219
+ description="Scroll the page by pixels, or scroll an element into view.",
220
  inputSchema={"type": "object", "properties": {
221
  "x": {"type": "integer", "default": 0},
222
  "y": {"type": "integer", "default": 500},
223
+ "selector": {"type": "string"}
224
  }, "required": []}),
225
+
226
+ T(name="go_back", description="Navigate back in history.",
227
  inputSchema={"type": "object", "properties": {}, "required": []}),
228
+ T(name="go_forward", description="Navigate forward in history.",
 
229
  inputSchema={"type": "object", "properties": {}, "required": []}),
230
+ T(name="reload", description="Reload the current page.",
 
231
  inputSchema={"type": "object", "properties": {}, "required": []}),
232
+ T(name="get_url", description="Get current page URL.",
 
233
  inputSchema={"type": "object", "properties": {}, "required": []}),
234
+ T(name="get_title",description="Get current page title.",
 
235
  inputSchema={"type": "object", "properties": {}, "required": []}),
236
+
237
  T(name="get_links",
238
+ description="Get all links on the page (href + text). Cached 5 min.",
239
  inputSchema={"type": "object", "properties": {
240
  "selector": {"type": "string", "default": "a"},
241
+ "limit": {"type": "integer", "default": 50},
242
+ "no_cache": {"type": "boolean", "default": False},
243
  }, "required": []}),
244
+
245
  T(name="find_elements",
246
+ description="Find elements by CSS selector, return their text or an attribute.",
247
  inputSchema={"type": "object", "properties": {
248
  "selector": {"type": "string"},
249
+ "attribute": {"type": "string"}
250
  }, "required": ["selector"]}),
251
+
252
  T(name="new_tab",
253
+ description="Open a new browser tab.",
254
  inputSchema={"type": "object", "properties": {
255
  "url": {"type": "string", "default": "about:blank"}
256
  }, "required": []}),
257
  T(name="switch_tab",
258
+ description="Switch to a tab by index number.",
259
+ inputSchema={"type": "object", "properties": {"index": {"type": "integer"}}, "required": ["index"]}),
 
 
260
  T(name="close_tab",
261
+ description="Close the current tab (cannot close the last one).",
262
  inputSchema={"type": "object", "properties": {}, "required": []}),
263
  T(name="list_tabs",
264
+ description="List all open tabs with index, URL and title.",
265
  inputSchema={"type": "object", "properties": {}, "required": []}),
266
+
267
+ T(name="get_cookies", description="Get all cookies for the current context.",
268
  inputSchema={"type": "object", "properties": {}, "required": []}),
269
  T(name="set_cookie",
270
+ description="Set a browser cookie.",
271
  inputSchema={"type": "object", "properties": {
272
+ "name": {"type": "string"}, "value": {"type": "string"},
273
+ "url": {"type": "string"}
 
274
  }, "required": ["name", "value"]}),
275
+ T(name="clear_cookies", description="Clear all browser cookies.",
 
276
  inputSchema={"type": "object", "properties": {}, "required": []}),
277
+ T(name="clear_cache",
278
+ description="Clear the server-side page content cache (force fresh fetches).",
279
+ inputSchema={"type": "object", "properties": {}, "required": []}),
280
+
281
  T(name="get_console",
282
+ description="Get recent browser console log messages.",
283
  inputSchema={"type": "object", "properties": {
284
  "limit": {"type": "integer", "default": 50}
285
  }, "required": []}),
286
 
287
+ T(name="set_fast_mode",
288
+ description="Enable/disable fast mode: blocks images, fonts and CSS to speed up page loads.",
289
+ inputSchema={"type": "object", "properties": {
290
+ "enabled": {"type": "boolean"}
291
+ }, "required": ["enabled"]}),
292
+
293
+ # ── Script management ────────────────────────────────────────────
294
  T(name="script_save",
295
+ description="Save a reusable automation script (Python or JS).",
296
  inputSchema={"type": "object", "properties": {
297
+ "name": {"type": "string"}, "content": {"type": "string"},
 
298
  }, "required": ["name", "content"]}),
299
+ T(name="script_list", description="List all saved automation scripts.",
 
300
  inputSchema={"type": "object", "properties": {}, "required": []}),
301
  T(name="script_read",
302
+ description="Read source code of a saved script.",
303
+ inputSchema={"type": "object", "properties": {"name": {"type": "string"}}, "required": ["name"]}),
 
 
304
  T(name="script_delete",
305
  description="Delete a saved script.",
306
+ inputSchema={"type": "object", "properties": {"name": {"type": "string"}}, "required": ["name"]}),
 
 
307
  T(name="script_run",
308
  description=(
309
+ "Run a saved Python script. Receives current page URL as argv[1]. "
310
+ "Can use playwright. Returns stdout/stderr."
 
311
  ),
312
  inputSchema={"type": "object", "properties": {
313
+ "name": {"type": "string"}, "args": {"type": "string", "default": ""},
 
314
  "timeout": {"type": "integer", "default": 30},
315
  }, "required": ["name"]}),
316
  T(name="script_run_inline",
317
+ description="Write and run a one-off Python script immediately (not saved).",
 
 
 
 
318
  inputSchema={"type": "object", "properties": {
319
+ "code": {"type": "string"}, "timeout": {"type": "integer", "default": 60},
 
320
  }, "required": ["code"]}),
321
  ]
322
 
323
 
324
+ def _cache_key(url: str, kind: str) -> str:
325
+ return f"{kind}:{url}"
326
+
327
+
328
  @mcp_server.call_tool()
329
  async def call_tool(name: str, arguments: dict):
330
  try:
331
  p = await bm.page()
332
 
333
+ # ── navigate ────────────────────────────────────────────────────
334
  if name == "navigate":
335
+ fast = arguments.get("fast_mode", False)
336
+ bm._fast_mode = fast
337
+ resp = await p.goto(
338
+ arguments["url"],
339
+ wait_until=arguments.get("wait_until", "domcontentloaded"),
340
+ timeout=30000,
341
+ )
342
  status = resp.status if resp else "unknown"
343
+ mode = " [fast: images/css blocked]" if fast else ""
344
+ return ok(f"Navigated β†’ {p.url} (HTTP {status}){mode}")
345
 
346
+ # ── screenshot ──────────────────────────────────────────────────
347
  if name == "screenshot":
348
  sel = arguments.get("selector")
 
349
  if sel:
350
  el = await p.query_selector(sel)
351
  if not el:
352
  return ok(f"Element not found: {sel}")
353
  return img(await el.screenshot())
354
+ return img(await p.screenshot(full_page=arguments.get("full_page", False)))
355
 
356
+ # ── get_text (cached) ────────────────────────────────────────────
357
  if name == "get_text":
358
  sel = arguments.get("selector", "body")
359
+ url = p.url
360
+ ck = _cache_key(url, f"text:{sel}")
361
+ no_cache = arguments.get("no_cache", False)
362
+ if not no_cache and ck in _page_cache:
363
+ return ok(f"[cached] {_page_cache[ck]}")
364
  el = await p.query_selector(sel)
365
+ result = await el.inner_text() if el else "Element not found"
366
+ if not no_cache and url != "about:blank":
367
+ _page_cache[ck] = result
368
+ return ok(result)
369
 
370
+ # ── get_html (cached) ────────────────────────────────────────────
371
  if name == "get_html":
372
  sel = arguments.get("selector", "html")
373
  outer = arguments.get("outer", True)
374
+ url = p.url
375
+ ck = _cache_key(url, f"html:{sel}:{outer}")
376
+ no_cache = arguments.get("no_cache", False)
377
+ if not no_cache and ck in _page_cache:
378
+ return ok(f"[cached] {_page_cache[ck]}")
379
  el = await p.query_selector(sel)
380
  if not el:
381
  return ok("Element not found")
382
+ result = await el.outer_html() if outer else await el.inner_html()
383
+ if not no_cache and url != "about:blank":
384
+ _page_cache[ck] = result
385
+ return ok(result)
386
 
387
+ # ── get_links (cached) ───────────────────────────────────────────
388
+ if name == "get_links":
389
+ limit = arguments.get("limit", 50)
390
+ sel = arguments.get("selector", "a")
391
+ url = p.url
392
+ ck = _cache_key(url, f"links:{sel}:{limit}")
393
+ no_cache = arguments.get("no_cache", False)
394
+ if not no_cache and ck in _page_cache:
395
+ return ok(f"[cached] {_page_cache[ck]}")
396
+ links = await p.evaluate(
397
+ f"""() => Array.from(document.querySelectorAll('{sel}')).slice(0,{limit}).map(a=>{{
398
+ return {{text:(a.innerText||a.textContent||'').trim().slice(0,100),href:a.href,title:a.title}}
399
+ }})"""
400
+ )
401
+ result = json.dumps(links, ensure_ascii=False)
402
+ if not no_cache and url != "about:blank":
403
+ _page_cache[ck] = result
404
+ return ok(result)
405
+
406
+ # ── clear_cache ──────────────────────────────────────────────────
407
+ if name == "clear_cache":
408
+ n = len(_page_cache)
409
+ _page_cache.clear()
410
+ return ok(f"Cache cleared ({n} entries removed)")
411
+
412
+ # ── set_fast_mode ────────────────────────────────────────────────
413
+ if name == "set_fast_mode":
414
+ bm._fast_mode = arguments["enabled"]
415
+ state = "enabled" if bm._fast_mode else "disabled"
416
+ return ok(f"Fast mode {state} (images/fonts/CSS {'blocked' if bm._fast_mode else 'allowed'})")
417
+
418
+ # ── click ────────────────────────────────────────────────────────
419
  if name == "click":
420
  await p.click(arguments["selector"],
421
  button=arguments.get("button", "left"),
422
  click_count=arguments.get("count", 1))
423
  return ok(f"Clicked {arguments['selector']}")
424
 
425
+ # ── type_text ────────────────────────────────────────────────────
426
  if name == "type_text":
427
  if arguments.get("clear"):
428
  await p.fill(arguments["selector"], "")
429
  await p.locator(arguments["selector"]).type(arguments["text"])
430
  return ok(f"Typed into {arguments['selector']}")
431
 
432
+ # ── fill ─────────────────────────────────────────────────────────
433
  if name == "fill":
434
  await p.fill(arguments["selector"], arguments["value"])
435
  return ok(f"Filled {arguments['selector']}")
436
 
437
+ # ── select_option ─────────────────────────────────────────────────
438
  if name == "select_option":
439
  sel = arguments["selector"]
440
  if v := arguments.get("value"):
 
445
  return ok("Provide value or label")
446
  return ok(f"Selected option in {sel}")
447
 
448
+ # ── hover ─────────────────────────────────────────────────────────
449
  if name == "hover":
450
  await p.hover(arguments["selector"])
451
  return ok(f"Hovering over {arguments['selector']}")
452
 
453
+ # ── press_key ─────────────────────────────────────────────────────
454
  if name == "press_key":
455
  if sel := arguments.get("selector"):
456
  el = await p.query_selector(sel)
 
460
  await p.keyboard.press(arguments["key"])
461
  return ok(f"Pressed {arguments['key']}")
462
 
463
+ # ── evaluate ──────────────────────────────────────────────────────
464
  if name == "evaluate":
465
  arg_raw = arguments.get("arg")
466
  arg = json.loads(arg_raw) if arg_raw else None
467
  result = await p.evaluate(arguments["script"], arg)
468
  return ok(json.dumps(result, ensure_ascii=False, default=str))
469
 
470
+ # ── wait_for ──────────────────────────────────────────────────────
471
  if name == "wait_for":
472
  if sel := arguments.get("selector"):
473
+ await p.wait_for_selector(sel, state=arguments.get("state", "visible"), timeout=15000)
 
 
474
  return ok(f"Element {sel} is {arguments.get('state', 'visible')}")
475
  if ms := arguments.get("ms"):
476
  await asyncio.sleep(ms / 1000)
477
  return ok(f"Waited {ms} ms")
478
  return ok("Provide selector or ms")
479
 
480
+ # ── scroll ────────────────────────────────────────────────────────
481
  if name == "scroll":
482
  if sel := arguments.get("selector"):
483
  el = await p.query_selector(sel)
 
496
  return ok(f"Forward β†’ {p.url}")
497
 
498
  if name == "reload":
499
+ _page_cache.pop(_cache_key(p.url, "text:body"), None)
500
  await p.reload()
501
  return ok(f"Reloaded β†’ {p.url}")
502
 
503
+ if name == "get_url": return ok(p.url)
504
+ if name == "get_title": return ok(await p.title())
 
 
 
 
 
 
 
 
 
 
 
505
 
506
+ # ── find_elements ─────────────────────────────��───────────────────
507
  if name == "find_elements":
508
  elements = await p.query_selector_all(arguments["selector"])
509
  attr = arguments.get("attribute")
510
  results = []
511
  for el in elements[:50]:
512
+ results.append(await el.get_attribute(attr) if attr else (await el.inner_text()).strip())
 
 
 
513
  return ok(json.dumps(results, ensure_ascii=False))
514
 
515
+ # ── tabs ──────────────────────────────────────────────────────────
516
  if name == "new_tab":
517
  new_page = await bm.new_tab(arguments.get("url", "about:blank"))
518
  return ok(f"Opened tab {bm.current}: {new_page.url}")
 
533
  return ok(f"Closed. Now on tab {bm.current}: {bm.tabs[bm.current].url}")
534
 
535
  if name == "list_tabs":
536
+ result = [{"index": i, "url": t.url, "title": await t.title(), "active": i == bm.current}
537
+ for i, t in enumerate(bm.tabs)]
 
538
  return ok(json.dumps(result, ensure_ascii=False))
539
 
540
+ # ── cookies ───────────────────────────────────────────────────────
541
  if name == "get_cookies":
542
+ return ok(json.dumps(await bm.context.cookies(), ensure_ascii=False, default=str))
 
543
 
544
  if name == "set_cookie":
545
  await bm.context.add_cookies([{
546
+ "name": arguments["name"], "value": arguments["value"],
 
547
  "url": arguments.get("url", p.url),
548
  }])
549
  return ok(f"Set cookie '{arguments['name']}'")
 
552
  await bm.context.clear_cookies()
553
  return ok("All cookies cleared")
554
 
555
+ # ── console ───────────────────────────────────────────────────────
556
  if name == "get_console":
557
  limit = arguments.get("limit", 50)
558
  msgs = bm.console_msgs[-limit:]
559
  return ok("\n".join(msgs) if msgs else "(no console messages yet)")
560
 
561
+ # ── scripts ───────────────────────────────────────────────────────
562
  if name == "script_save":
563
  path = SCRIPTS_DIR / arguments["name"]
564
  path.write_text(arguments["content"])
 
568
  files = sorted(SCRIPTS_DIR.iterdir())
569
  if not files:
570
  return ok("No scripts saved yet.")
571
+ return ok("\n".join(f"{f.name} ({f.stat().st_size} bytes)" for f in files))
 
572
 
573
  if name == "script_read":
574
  path = SCRIPTS_DIR / arguments["name"]
575
+ return ok(path.read_text() if path.exists() else f"Script not found: {arguments['name']}")
 
 
576
 
577
  if name == "script_delete":
578
  path = SCRIPTS_DIR / arguments["name"]
 
599
  return ok("[TIMEOUT]")
600
 
601
  if name == "script_run_inline":
 
602
  timeout = min(int(arguments.get("timeout", 60)), 300)
603
  tmp = Path("/tmp/_inline_script.py")
604
+ tmp.write_text(arguments["code"])
605
  try:
606
  r = subprocess.run(["python3", str(tmp)], capture_output=True, text=True, timeout=timeout)
607
  out = []
 
624
 
625
 
626
  async def handle_health(request: Request):
627
+ cached = len(_page_cache)
628
+ fast = "on" if bm._fast_mode else "off"
629
+ status = f"browser-mcp v2 OK β€” tabs:{len(bm.tabs)} current:{bm.current} cache:{cached} fast_mode:{fast}"
630
  return Response(status, media_type="text/plain")
631
 
632
 
633
  app = Starlette(routes=[
634
+ Route("/sse", endpoint=handle_sse),
635
+ Route("/health", endpoint=handle_health),
636
+ Mount("/messages/", app=sse.handle_post_message),
637
+ ])
638
 
639
  if __name__ == "__main__":
 
640
  threading.Thread(target=lambda: asyncio.run(bm.init()), daemon=True).start()
641
  uvicorn.run(app, host="0.0.0.0", port=7860)
requirements.txt CHANGED
@@ -2,3 +2,4 @@ mcp>=1.0.0
2
  playwright>=1.40.0
3
  starlette>=0.40.0
4
  uvicorn[standard]>=0.30.0
 
 
2
  playwright>=1.40.0
3
  starlette>=0.40.0
4
  uvicorn[standard]>=0.30.0
5
+ cachetools>=5.3.0