Phantom Deploy commited on
Commit
fe44bf5
·
1 Parent(s): f6952ba

deploy: Browser MCP — Playwright Chromium, 28 tools

Browse files
Files changed (4) hide show
  1. Dockerfile +18 -0
  2. README.md +17 -4
  3. app.py +434 -0
  4. requirements.txt +4 -0
Dockerfile ADDED
@@ -0,0 +1,18 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ FROM python:3.11-slim
2
+
3
+ ENV PYTHONDONTWRITEBYTECODE=1 \
4
+ PYTHONUNBUFFERED=1
5
+
6
+ RUN apt-get update && apt-get install -y --no-install-recommends \
7
+ wget ca-certificates curl \
8
+ && rm -rf /var/lib/apt/lists/*
9
+
10
+ COPY requirements.txt .
11
+ RUN pip install --no-cache-dir -r requirements.txt
12
+ RUN playwright install --with-deps chromium
13
+
14
+ WORKDIR /app
15
+ COPY app.py .
16
+
17
+ EXPOSE 7860
18
+ CMD ["python3", "app.py"]
README.md CHANGED
@@ -1,10 +1,23 @@
1
  ---
2
- title: Browser Mcp
3
- emoji: 👁
4
  colorFrom: blue
5
- colorTo: red
6
  sdk: docker
 
7
  pinned: false
8
  ---
9
 
10
- Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  ---
2
+ title: Browser MCP
3
+ emoji: 🌐
4
  colorFrom: blue
5
+ colorTo: green
6
  sdk: docker
7
+ app_port: 7860
8
  pinned: false
9
  ---
10
 
11
+ # Browser MCP — Playwright Chromium
12
+
13
+ Full browser control via MCP SSE. Register in Claude:
14
+ ```
15
+ https://kali-mcp-gateway.alena-rejdova.workers.dev/browser/sse
16
+ ```
17
+
18
+ ## Tools
19
+ `navigate`, `screenshot`, `click`, `type_text`, `fill`, `select_option`, `hover`,
20
+ `evaluate` (JS), `wait_for`, `scroll`, `get_text`, `get_html`, `get_links`,
21
+ `find_elements`, `press_key`, `go_back`, `go_forward`, `reload`,
22
+ `get_url`, `get_title`, `new_tab`, `switch_tab`, `close_tab`, `list_tabs`,
23
+ `get_cookies`, `set_cookie`, `clear_cookies`
app.py ADDED
@@ -0,0 +1,434 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ """Browser MCP — Playwright Chromium controlled via MCP SSE."""
3
+ import asyncio, base64, json, os
4
+ from typing import Optional
5
+
6
+ import mcp.types as types
7
+ from mcp.server import Server
8
+ from mcp.server.sse import SseServerTransport
9
+ from starlette.applications import Starlette
10
+ from starlette.requests import Request
11
+ from starlette.responses import Response
12
+ from starlette.routing import Mount, Route
13
+ import uvicorn
14
+ from playwright.async_api import async_playwright, Browser, BrowserContext, Page
15
+
16
+ mcp_server = Server("browser-mcp")
17
+ sse = SseServerTransport("/messages/")
18
+
19
+
20
+ class BrowserManager:
21
+ def __init__(self):
22
+ self.pw = None
23
+ self.browser: Optional[Browser] = None
24
+ self.context: Optional[BrowserContext] = None
25
+ self.tabs: list[Page] = []
26
+ self.current: int = 0
27
+ self.console_msgs: list[str] = []
28
+
29
+ async def init(self):
30
+ self.pw = await async_playwright().start()
31
+ self.browser = await self.pw.chromium.launch(
32
+ headless=True,
33
+ args=["--no-sandbox", "--disable-dev-shm-usage", "--disable-gpu",
34
+ "--disable-setuid-sandbox", "--no-zygote"],
35
+ )
36
+ self.context = await self.browser.new_context(
37
+ viewport={"width": 1280, "height": 800},
38
+ user_agent=(
39
+ "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 "
40
+ "(KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36"
41
+ ),
42
+ )
43
+ page = await self.context.new_page()
44
+ page.on("console", lambda m: self.console_msgs.append(f"[{m.type}] {m.text}"))
45
+ self.tabs = [page]
46
+ self.current = 0
47
+
48
+ async def page(self) -> Page:
49
+ if not self.tabs:
50
+ await self.init()
51
+ return self.tabs[self.current]
52
+
53
+ async def new_tab(self, url: str = "about:blank") -> Page:
54
+ p = await self.context.new_page()
55
+ p.on("console", lambda m: self.console_msgs.append(f"[{m.type}] {m.text}"))
56
+ if url and url != "about:blank":
57
+ await p.goto(url, timeout=30000)
58
+ self.tabs.append(p)
59
+ self.current = len(self.tabs) - 1
60
+ return p
61
+
62
+
63
+ bm = BrowserManager()
64
+
65
+
66
+ def ok(t):
67
+ return [types.TextContent(type="text", text=str(t))]
68
+
69
+
70
+ def img(data: bytes):
71
+ return [types.ImageContent(type="image", data=base64.b64encode(data).decode(), mimeType="image/png")]
72
+
73
+
74
+ @mcp_server.list_tools()
75
+ async def list_tools():
76
+ T = types.Tool
77
+ return [
78
+ T(name="navigate",
79
+ description="Navigate to a URL and wait for page to load.",
80
+ inputSchema={"type": "object", "properties": {
81
+ "url": {"type": "string"},
82
+ "wait_until": {"type": "string", "default": "load",
83
+ "enum": ["load", "domcontentloaded", "networkidle"]}
84
+ }, "required": ["url"]}),
85
+ T(name="screenshot",
86
+ description="Take a screenshot of the current page or a specific element. Returns an image.",
87
+ inputSchema={"type": "object", "properties": {
88
+ "full_page": {"type": "boolean", "default": False},
89
+ "selector": {"type": "string", "description": "CSS selector to screenshot just that element"}
90
+ }, "required": []}),
91
+ T(name="get_text",
92
+ description="Get the visible text content of the page or a specific element.",
93
+ inputSchema={"type": "object", "properties": {
94
+ "selector": {"type": "string", "default": "body"}
95
+ }, "required": []}),
96
+ T(name="get_html",
97
+ description="Get the HTML source of the page or element.",
98
+ inputSchema={"type": "object", "properties": {
99
+ "selector": {"type": "string", "default": "html"},
100
+ "outer": {"type": "boolean", "default": True}
101
+ }, "required": []}),
102
+ T(name="click",
103
+ description="Click on an element identified by CSS selector.",
104
+ inputSchema={"type": "object", "properties": {
105
+ "selector": {"type": "string"},
106
+ "button": {"type": "string", "default": "left", "enum": ["left", "right", "middle"]},
107
+ "count": {"type": "integer", "default": 1}
108
+ }, "required": ["selector"]}),
109
+ T(name="type_text",
110
+ description="Type text into an input element (appends by default).",
111
+ inputSchema={"type": "object", "properties": {
112
+ "selector": {"type": "string"},
113
+ "text": {"type": "string"},
114
+ "clear": {"type": "boolean", "default": False, "description": "Clear field first"}
115
+ }, "required": ["selector", "text"]}),
116
+ T(name="fill",
117
+ description="Fill a form field (clears existing value first then sets new one).",
118
+ inputSchema={"type": "object", "properties": {
119
+ "selector": {"type": "string"},
120
+ "value": {"type": "string"}
121
+ }, "required": ["selector", "value"]}),
122
+ T(name="select_option",
123
+ description="Select an option in a <select> dropdown element.",
124
+ inputSchema={"type": "object", "properties": {
125
+ "selector": {"type": "string"},
126
+ "value": {"type": "string", "description": "Option value attribute"},
127
+ "label": {"type": "string", "description": "Option visible text"}
128
+ }, "required": ["selector"]}),
129
+ T(name="hover",
130
+ description="Move mouse over an element (triggers hover CSS/JS).",
131
+ inputSchema={"type": "object", "properties": {
132
+ "selector": {"type": "string"}
133
+ }, "required": ["selector"]}),
134
+ T(name="press_key",
135
+ description="Press a keyboard key (e.g. Enter, Tab, Escape, ArrowDown, F5).",
136
+ inputSchema={"type": "object", "properties": {
137
+ "key": {"type": "string"},
138
+ "selector": {"type": "string", "description": "Focus this element first (optional)"}
139
+ }, "required": ["key"]}),
140
+ T(name="evaluate",
141
+ description="Execute JavaScript in the browser page and return the result.",
142
+ inputSchema={"type": "object", "properties": {
143
+ "script": {"type": "string", "description": "JS expression or function body"},
144
+ "arg": {"type": "string", "description": "JSON argument passed as first param to function"}
145
+ }, "required": ["script"]}),
146
+ T(name="wait_for",
147
+ description="Wait for an element to appear or for a set time.",
148
+ inputSchema={"type": "object", "properties": {
149
+ "selector": {"type": "string", "description": "CSS selector to wait for"},
150
+ "ms": {"type": "integer", "description": "Milliseconds to wait"},
151
+ "state": {"type": "string", "default": "visible",
152
+ "enum": ["attached", "detached", "visible", "hidden"]}
153
+ }, "required": []}),
154
+ T(name="scroll",
155
+ description="Scroll the page or scroll a specific element into view.",
156
+ inputSchema={"type": "object", "properties": {
157
+ "x": {"type": "integer", "default": 0},
158
+ "y": {"type": "integer", "default": 500},
159
+ "selector": {"type": "string", "description": "Scroll this element into view"}
160
+ }, "required": []}),
161
+ T(name="go_back",
162
+ description="Navigate back in browser history.",
163
+ inputSchema={"type": "object", "properties": {}, "required": []}),
164
+ T(name="go_forward",
165
+ description="Navigate forward in browser history.",
166
+ inputSchema={"type": "object", "properties": {}, "required": []}),
167
+ T(name="reload",
168
+ description="Reload the current page.",
169
+ inputSchema={"type": "object", "properties": {}, "required": []}),
170
+ T(name="get_url",
171
+ description="Get the current page URL.",
172
+ inputSchema={"type": "object", "properties": {}, "required": []}),
173
+ T(name="get_title",
174
+ description="Get the current page title.",
175
+ inputSchema={"type": "object", "properties": {}, "required": []}),
176
+ T(name="get_links",
177
+ description="Get all links on the current page (href + text).",
178
+ inputSchema={"type": "object", "properties": {
179
+ "selector": {"type": "string", "default": "a"},
180
+ "limit": {"type": "integer", "default": 50}
181
+ }, "required": []}),
182
+ T(name="find_elements",
183
+ description="Find elements by CSS selector and return their text or a specific attribute.",
184
+ inputSchema={"type": "object", "properties": {
185
+ "selector": {"type": "string"},
186
+ "attribute": {"type": "string", "description": "Return this attribute (e.g. href, src, value)"}
187
+ }, "required": ["selector"]}),
188
+ T(name="new_tab",
189
+ description="Open a new browser tab, optionally navigating to a URL.",
190
+ inputSchema={"type": "object", "properties": {
191
+ "url": {"type": "string", "default": "about:blank"}
192
+ }, "required": []}),
193
+ T(name="switch_tab",
194
+ description="Switch to another open tab by its index number.",
195
+ inputSchema={"type": "object", "properties": {
196
+ "index": {"type": "integer"}
197
+ }, "required": ["index"]}),
198
+ T(name="close_tab",
199
+ description="Close the current tab. Cannot close the last remaining tab.",
200
+ inputSchema={"type": "object", "properties": {}, "required": []}),
201
+ T(name="list_tabs",
202
+ description="List all open browser tabs with their index, URL and title.",
203
+ inputSchema={"type": "object", "properties": {}, "required": []}),
204
+ T(name="get_cookies",
205
+ description="Get all cookies for the current browser context.",
206
+ inputSchema={"type": "object", "properties": {}, "required": []}),
207
+ T(name="set_cookie",
208
+ description="Set a cookie in the browser.",
209
+ inputSchema={"type": "object", "properties": {
210
+ "name": {"type": "string"},
211
+ "value": {"type": "string"},
212
+ "url": {"type": "string", "description": "URL for cookie scope (defaults to current page)"}
213
+ }, "required": ["name", "value"]}),
214
+ T(name="clear_cookies",
215
+ description="Clear all cookies from the browser context.",
216
+ inputSchema={"type": "object", "properties": {}, "required": []}),
217
+ T(name="get_console",
218
+ description="Get recent browser console log messages (errors, warnings, logs).",
219
+ inputSchema={"type": "object", "properties": {
220
+ "limit": {"type": "integer", "default": 50}
221
+ }, "required": []}),
222
+ ]
223
+
224
+
225
+ @mcp_server.call_tool()
226
+ async def call_tool(name: str, arguments: dict):
227
+ try:
228
+ p = await bm.page()
229
+
230
+ if name == "navigate":
231
+ resp = await p.goto(arguments["url"],
232
+ wait_until=arguments.get("wait_until", "load"),
233
+ timeout=30000)
234
+ status = resp.status if resp else "unknown"
235
+ return ok(f"Navigated → {p.url} (HTTP {status})")
236
+
237
+ if name == "screenshot":
238
+ sel = arguments.get("selector")
239
+ full = arguments.get("full_page", False)
240
+ if sel:
241
+ el = await p.query_selector(sel)
242
+ if not el:
243
+ return ok(f"Element not found: {sel}")
244
+ return img(await el.screenshot())
245
+ return img(await p.screenshot(full_page=full))
246
+
247
+ if name == "get_text":
248
+ sel = arguments.get("selector", "body")
249
+ el = await p.query_selector(sel)
250
+ return ok(await el.inner_text() if el else "Element not found")
251
+
252
+ if name == "get_html":
253
+ sel = arguments.get("selector", "html")
254
+ outer = arguments.get("outer", True)
255
+ el = await p.query_selector(sel)
256
+ if not el:
257
+ return ok("Element not found")
258
+ return ok(await el.outer_html() if outer else await el.inner_html())
259
+
260
+ if name == "click":
261
+ await p.click(arguments["selector"],
262
+ button=arguments.get("button", "left"),
263
+ click_count=arguments.get("count", 1))
264
+ return ok(f"Clicked {arguments['selector']}")
265
+
266
+ if name == "type_text":
267
+ if arguments.get("clear"):
268
+ await p.fill(arguments["selector"], "")
269
+ await p.locator(arguments["selector"]).type(arguments["text"])
270
+ return ok(f"Typed into {arguments['selector']}")
271
+
272
+ if name == "fill":
273
+ await p.fill(arguments["selector"], arguments["value"])
274
+ return ok(f"Filled {arguments['selector']}")
275
+
276
+ if name == "select_option":
277
+ sel = arguments["selector"]
278
+ if v := arguments.get("value"):
279
+ await p.select_option(sel, value=v)
280
+ elif l := arguments.get("label"):
281
+ await p.select_option(sel, label=l)
282
+ else:
283
+ return ok("Provide value or label")
284
+ return ok(f"Selected option in {sel}")
285
+
286
+ if name == "hover":
287
+ await p.hover(arguments["selector"])
288
+ return ok(f"Hovering over {arguments['selector']}")
289
+
290
+ if name == "press_key":
291
+ if sel := arguments.get("selector"):
292
+ el = await p.query_selector(sel)
293
+ if el:
294
+ await el.press(arguments["key"])
295
+ return ok(f"Pressed {arguments['key']} on {sel}")
296
+ await p.keyboard.press(arguments["key"])
297
+ return ok(f"Pressed {arguments['key']}")
298
+
299
+ if name == "evaluate":
300
+ arg_raw = arguments.get("arg")
301
+ arg = json.loads(arg_raw) if arg_raw else None
302
+ result = await p.evaluate(arguments["script"], arg)
303
+ return ok(json.dumps(result, ensure_ascii=False, default=str))
304
+
305
+ if name == "wait_for":
306
+ if sel := arguments.get("selector"):
307
+ await p.wait_for_selector(sel,
308
+ state=arguments.get("state", "visible"),
309
+ timeout=15000)
310
+ return ok(f"Element {sel} is {arguments.get('state', 'visible')}")
311
+ if ms := arguments.get("ms"):
312
+ await asyncio.sleep(ms / 1000)
313
+ return ok(f"Waited {ms} ms")
314
+ return ok("Provide selector or ms")
315
+
316
+ if name == "scroll":
317
+ if sel := arguments.get("selector"):
318
+ el = await p.query_selector(sel)
319
+ if el:
320
+ await el.scroll_into_view_if_needed()
321
+ return ok(f"Scrolled {sel} into view")
322
+ await p.evaluate(f"window.scrollBy({arguments.get('x', 0)}, {arguments.get('y', 500)})")
323
+ return ok(f"Scrolled by ({arguments.get('x',0)}, {arguments.get('y',500)})")
324
+
325
+ if name == "go_back":
326
+ await p.go_back(timeout=10000)
327
+ return ok(f"Back → {p.url}")
328
+
329
+ if name == "go_forward":
330
+ await p.go_forward(timeout=10000)
331
+ return ok(f"Forward → {p.url}")
332
+
333
+ if name == "reload":
334
+ await p.reload()
335
+ return ok(f"Reloaded → {p.url}")
336
+
337
+ if name == "get_url":
338
+ return ok(p.url)
339
+
340
+ if name == "get_title":
341
+ return ok(await p.title())
342
+
343
+ if name == "get_links":
344
+ limit = arguments.get("limit", 50)
345
+ sel = arguments.get("selector", "a")
346
+ links = await p.evaluate(f"""() => Array.from(document.querySelectorAll('{sel}')).slice(0,{limit}).map(a=>{{
347
+ return {{text: (a.innerText||a.textContent||'').trim().slice(0,100), href: a.href, title: a.title}}
348
+ }})""")
349
+ return ok(json.dumps(links, ensure_ascii=False))
350
+
351
+ if name == "find_elements":
352
+ elements = await p.query_selector_all(arguments["selector"])
353
+ attr = arguments.get("attribute")
354
+ results = []
355
+ for el in elements[:50]:
356
+ if attr:
357
+ results.append(await el.get_attribute(attr))
358
+ else:
359
+ results.append((await el.inner_text()).strip())
360
+ return ok(json.dumps(results, ensure_ascii=False))
361
+
362
+ if name == "new_tab":
363
+ new_page = await bm.new_tab(arguments.get("url", "about:blank"))
364
+ return ok(f"Opened tab {bm.current}: {new_page.url}")
365
+
366
+ if name == "switch_tab":
367
+ idx = arguments["index"]
368
+ if 0 <= idx < len(bm.tabs):
369
+ bm.current = idx
370
+ return ok(f"Switched to tab {idx}: {bm.tabs[idx].url}")
371
+ return ok(f"Tab {idx} doesn't exist (have {len(bm.tabs)} tabs)")
372
+
373
+ if name == "close_tab":
374
+ if len(bm.tabs) <= 1:
375
+ return ok("Cannot close the last tab")
376
+ await bm.tabs[bm.current].close()
377
+ bm.tabs.pop(bm.current)
378
+ bm.current = max(0, bm.current - 1)
379
+ return ok(f"Closed. Now on tab {bm.current}: {bm.tabs[bm.current].url}")
380
+
381
+ if name == "list_tabs":
382
+ result = []
383
+ for i, t in enumerate(bm.tabs):
384
+ result.append({"index": i, "url": t.url, "title": await t.title(), "active": i == bm.current})
385
+ return ok(json.dumps(result, ensure_ascii=False))
386
+
387
+ if name == "get_cookies":
388
+ cookies = await bm.context.cookies()
389
+ return ok(json.dumps(cookies, ensure_ascii=False, default=str))
390
+
391
+ if name == "set_cookie":
392
+ await bm.context.add_cookies([{
393
+ "name": arguments["name"],
394
+ "value": arguments["value"],
395
+ "url": arguments.get("url", p.url),
396
+ }])
397
+ return ok(f"Set cookie '{arguments['name']}'")
398
+
399
+ if name == "clear_cookies":
400
+ await bm.context.clear_cookies()
401
+ return ok("All cookies cleared")
402
+
403
+ if name == "get_console":
404
+ limit = arguments.get("limit", 50)
405
+ msgs = bm.console_msgs[-limit:]
406
+ return ok("\n".join(msgs) if msgs else "(no console messages yet)")
407
+
408
+ return ok(f"Unknown tool: {name}")
409
+
410
+ except Exception as e:
411
+ return ok(f"[ERROR] {type(e).__name__}: {e}")
412
+
413
+
414
+ async def handle_sse(request: Request):
415
+ async with sse.connect_sse(request.scope, request.receive, request._send) as streams:
416
+ await mcp_server.run(streams[0], streams[1], mcp_server.create_initialization_options())
417
+
418
+
419
+ async def handle_health(request: Request):
420
+ status = f"browser-mcp OK — tabs: {len(bm.tabs)}, current: {bm.current}"
421
+ return Response(status, media_type="text/plain")
422
+
423
+
424
+ app = Starlette(
425
+ routes=[
426
+ Route("/sse", endpoint=handle_sse),
427
+ Route("/health", endpoint=handle_health),
428
+ Mount("/messages/", app=sse.handle_post_message),
429
+ ],
430
+ on_startup=[bm.init],
431
+ )
432
+
433
+ if __name__ == "__main__":
434
+ uvicorn.run(app, host="0.0.0.0", port=7860)
requirements.txt ADDED
@@ -0,0 +1,4 @@
 
 
 
 
 
1
+ mcp>=1.0.0
2
+ playwright>=1.40.0
3
+ starlette>=0.40.0
4
+ uvicorn[standard]>=0.30.0