auto-browser-client 1.2.1__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.
- auto_browser_client/__init__.py +5 -0
- auto_browser_client/client.py +373 -0
- auto_browser_client/mcp_bridge.py +205 -0
- auto_browser_client-1.2.1.dist-info/METADATA +59 -0
- auto_browser_client-1.2.1.dist-info/RECORD +8 -0
- auto_browser_client-1.2.1.dist-info/WHEEL +5 -0
- auto_browser_client-1.2.1.dist-info/entry_points.txt +2 -0
- auto_browser_client-1.2.1.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,373 @@
|
|
|
1
|
+
"""
|
|
2
|
+
auto_browser_client.client — Sync/async Python client for the auto-browser REST API.
|
|
3
|
+
|
|
4
|
+
Usage (sync):
|
|
5
|
+
from auto_browser_client import AutoBrowserClient
|
|
6
|
+
|
|
7
|
+
client = AutoBrowserClient("http://localhost:8000", token="secret")
|
|
8
|
+
session = client.create_session(start_url="https://example.com")
|
|
9
|
+
obs = client.observe(session["id"], preset="fast")
|
|
10
|
+
client.navigate(session["id"], url="https://news.ycombinator.com")
|
|
11
|
+
client.close_session(session["id"])
|
|
12
|
+
|
|
13
|
+
Usage (async):
|
|
14
|
+
async with AutoBrowserClient("http://localhost:8000") as client:
|
|
15
|
+
session = await client.async_create_session(start_url="https://example.com")
|
|
16
|
+
obs = await client.async_observe(session["id"])
|
|
17
|
+
await client.async_close_session(session["id"])
|
|
18
|
+
"""
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
from typing import Any, Generator
|
|
22
|
+
|
|
23
|
+
import httpx
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class AutoBrowserError(Exception):
|
|
27
|
+
def __init__(self, status_code: int, detail: Any):
|
|
28
|
+
self.status_code = status_code
|
|
29
|
+
self.detail = detail
|
|
30
|
+
super().__init__(f"HTTP {status_code}: {detail}")
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class AutoBrowserClient:
|
|
34
|
+
"""Thin wrapper around the auto-browser REST API.
|
|
35
|
+
|
|
36
|
+
All methods have both a sync variant (e.g. ``create_session``) and an
|
|
37
|
+
async variant prefixed with ``async_`` (e.g. ``async_create_session``).
|
|
38
|
+
"""
|
|
39
|
+
|
|
40
|
+
def __init__(
|
|
41
|
+
self,
|
|
42
|
+
base_url: str = "http://localhost:8000",
|
|
43
|
+
token: str | None = None,
|
|
44
|
+
timeout: float = 60.0,
|
|
45
|
+
):
|
|
46
|
+
self.base_url = base_url.rstrip("/")
|
|
47
|
+
self._headers: dict[str, str] = {}
|
|
48
|
+
if token:
|
|
49
|
+
self._headers["Authorization"] = f"Bearer {token}"
|
|
50
|
+
self._sync_client: httpx.Client | None = None
|
|
51
|
+
self._async_client: httpx.AsyncClient | None = None
|
|
52
|
+
self._timeout = timeout
|
|
53
|
+
|
|
54
|
+
# ── Context managers ─────────────────────────────────────────────────────
|
|
55
|
+
|
|
56
|
+
def __enter__(self) -> "AutoBrowserClient":
|
|
57
|
+
self._sync_client = httpx.Client(base_url=self.base_url, headers=self._headers, timeout=self._timeout)
|
|
58
|
+
return self
|
|
59
|
+
|
|
60
|
+
def __exit__(self, *_) -> None:
|
|
61
|
+
if self._sync_client:
|
|
62
|
+
self._sync_client.close()
|
|
63
|
+
self._sync_client = None
|
|
64
|
+
|
|
65
|
+
async def __aenter__(self) -> "AutoBrowserClient":
|
|
66
|
+
self._async_client = httpx.AsyncClient(base_url=self.base_url, headers=self._headers, timeout=self._timeout)
|
|
67
|
+
return self
|
|
68
|
+
|
|
69
|
+
async def __aexit__(self, *_) -> None:
|
|
70
|
+
if self._async_client:
|
|
71
|
+
await self._async_client.aclose()
|
|
72
|
+
self._async_client = None
|
|
73
|
+
|
|
74
|
+
# ── Internal helpers ─────────────────────────────────────────────────────
|
|
75
|
+
|
|
76
|
+
def _client(self) -> httpx.Client:
|
|
77
|
+
if self._sync_client is None:
|
|
78
|
+
self._sync_client = httpx.Client(base_url=self.base_url, headers=self._headers, timeout=self._timeout)
|
|
79
|
+
return self._sync_client
|
|
80
|
+
|
|
81
|
+
def _aclient(self) -> httpx.AsyncClient:
|
|
82
|
+
if self._async_client is None:
|
|
83
|
+
self._async_client = httpx.AsyncClient(base_url=self.base_url, headers=self._headers, timeout=self._timeout)
|
|
84
|
+
return self._async_client
|
|
85
|
+
|
|
86
|
+
@staticmethod
|
|
87
|
+
def _raise(r: httpx.Response) -> None:
|
|
88
|
+
if r.status_code >= 400:
|
|
89
|
+
try:
|
|
90
|
+
detail = r.json()
|
|
91
|
+
except Exception:
|
|
92
|
+
detail = r.text
|
|
93
|
+
raise AutoBrowserError(r.status_code, detail)
|
|
94
|
+
|
|
95
|
+
def _get(self, path: str, **params) -> Any:
|
|
96
|
+
r = self._client().get(path, params=params)
|
|
97
|
+
self._raise(r)
|
|
98
|
+
return r.json()
|
|
99
|
+
|
|
100
|
+
def _post(self, path: str, body: dict | None = None) -> Any:
|
|
101
|
+
r = self._client().post(path, json=body or {})
|
|
102
|
+
self._raise(r)
|
|
103
|
+
return r.json()
|
|
104
|
+
|
|
105
|
+
def _delete(self, path: str) -> Any:
|
|
106
|
+
r = self._client().delete(path)
|
|
107
|
+
self._raise(r)
|
|
108
|
+
return r.json()
|
|
109
|
+
|
|
110
|
+
async def _aget(self, path: str, **params) -> Any:
|
|
111
|
+
r = await self._aclient().get(path, params=params)
|
|
112
|
+
self._raise(r)
|
|
113
|
+
return r.json()
|
|
114
|
+
|
|
115
|
+
async def _apost(self, path: str, body: dict | None = None) -> Any:
|
|
116
|
+
r = await self._aclient().post(path, json=body or {})
|
|
117
|
+
self._raise(r)
|
|
118
|
+
return r.json()
|
|
119
|
+
|
|
120
|
+
async def _adelete(self, path: str) -> Any:
|
|
121
|
+
r = await self._aclient().delete(path)
|
|
122
|
+
self._raise(r)
|
|
123
|
+
return r.json()
|
|
124
|
+
|
|
125
|
+
# ── Health ───────────────────────────────────────────────────────────────
|
|
126
|
+
|
|
127
|
+
def health(self) -> dict:
|
|
128
|
+
return self._get("/healthz")
|
|
129
|
+
|
|
130
|
+
async def async_health(self) -> dict:
|
|
131
|
+
return await self._aget("/healthz")
|
|
132
|
+
|
|
133
|
+
# ── Sessions ─────────────────────────────────────────────────────────────
|
|
134
|
+
|
|
135
|
+
def list_sessions(self) -> list[dict]:
|
|
136
|
+
return self._get("/sessions")
|
|
137
|
+
|
|
138
|
+
async def async_list_sessions(self) -> list[dict]:
|
|
139
|
+
return await self._aget("/sessions")
|
|
140
|
+
|
|
141
|
+
def create_session(
|
|
142
|
+
self,
|
|
143
|
+
*,
|
|
144
|
+
name: str | None = None,
|
|
145
|
+
start_url: str | None = None,
|
|
146
|
+
auth_profile: str | None = None,
|
|
147
|
+
) -> dict:
|
|
148
|
+
body: dict[str, Any] = {}
|
|
149
|
+
if name:
|
|
150
|
+
body["name"] = name
|
|
151
|
+
if start_url:
|
|
152
|
+
body["start_url"] = start_url
|
|
153
|
+
if auth_profile:
|
|
154
|
+
body["auth_profile"] = auth_profile
|
|
155
|
+
return self._post("/sessions", body)
|
|
156
|
+
|
|
157
|
+
async def async_create_session(
|
|
158
|
+
self,
|
|
159
|
+
*,
|
|
160
|
+
name: str | None = None,
|
|
161
|
+
start_url: str | None = None,
|
|
162
|
+
auth_profile: str | None = None,
|
|
163
|
+
) -> dict:
|
|
164
|
+
body: dict[str, Any] = {}
|
|
165
|
+
if name:
|
|
166
|
+
body["name"] = name
|
|
167
|
+
if start_url:
|
|
168
|
+
body["start_url"] = start_url
|
|
169
|
+
if auth_profile:
|
|
170
|
+
body["auth_profile"] = auth_profile
|
|
171
|
+
return await self._apost("/sessions", body)
|
|
172
|
+
|
|
173
|
+
def get_session(self, session_id: str) -> dict:
|
|
174
|
+
return self._get(f"/sessions/{session_id}")
|
|
175
|
+
|
|
176
|
+
async def async_get_session(self, session_id: str) -> dict:
|
|
177
|
+
return await self._aget(f"/sessions/{session_id}")
|
|
178
|
+
|
|
179
|
+
def close_session(self, session_id: str) -> dict:
|
|
180
|
+
return self._delete(f"/sessions/{session_id}")
|
|
181
|
+
|
|
182
|
+
async def async_close_session(self, session_id: str) -> dict:
|
|
183
|
+
return await self._adelete(f"/sessions/{session_id}")
|
|
184
|
+
|
|
185
|
+
# ── Observe ──────────────────────────────────────────────────────────────
|
|
186
|
+
|
|
187
|
+
def observe(
|
|
188
|
+
self,
|
|
189
|
+
session_id: str,
|
|
190
|
+
*,
|
|
191
|
+
preset: str = "normal",
|
|
192
|
+
limit: int = 40,
|
|
193
|
+
) -> dict:
|
|
194
|
+
"""Observe the current browser state.
|
|
195
|
+
|
|
196
|
+
preset: "fast" (screenshot only), "normal" (default), "rich" (extended)
|
|
197
|
+
"""
|
|
198
|
+
return self._post(f"/sessions/{session_id}/observe", {"preset": preset, "limit": limit})
|
|
199
|
+
|
|
200
|
+
async def async_observe(
|
|
201
|
+
self,
|
|
202
|
+
session_id: str,
|
|
203
|
+
*,
|
|
204
|
+
preset: str = "normal",
|
|
205
|
+
limit: int = 40,
|
|
206
|
+
) -> dict:
|
|
207
|
+
return await self._apost(f"/sessions/{session_id}/observe", {"preset": preset, "limit": limit})
|
|
208
|
+
|
|
209
|
+
# ── Navigation & actions ─────────────────────────────────────────────────
|
|
210
|
+
|
|
211
|
+
def navigate(self, session_id: str, url: str) -> dict:
|
|
212
|
+
return self._post(f"/sessions/{session_id}/actions/navigate", {"url": url})
|
|
213
|
+
|
|
214
|
+
async def async_navigate(self, session_id: str, url: str) -> dict:
|
|
215
|
+
return await self._apost(f"/sessions/{session_id}/actions/navigate", {"url": url})
|
|
216
|
+
|
|
217
|
+
def click(self, session_id: str, *, selector: str | None = None, element_id: str | None = None, x: float | None = None, y: float | None = None) -> dict:
|
|
218
|
+
body: dict[str, Any] = {}
|
|
219
|
+
if selector:
|
|
220
|
+
body["selector"] = selector
|
|
221
|
+
if element_id:
|
|
222
|
+
body["element_id"] = element_id
|
|
223
|
+
if x is not None:
|
|
224
|
+
body["x"] = x
|
|
225
|
+
if y is not None:
|
|
226
|
+
body["y"] = y
|
|
227
|
+
return self._post(f"/sessions/{session_id}/actions/click", body)
|
|
228
|
+
|
|
229
|
+
async def async_click(self, session_id: str, *, selector: str | None = None, element_id: str | None = None, x: float | None = None, y: float | None = None) -> dict:
|
|
230
|
+
body: dict[str, Any] = {}
|
|
231
|
+
if selector:
|
|
232
|
+
body["selector"] = selector
|
|
233
|
+
if element_id:
|
|
234
|
+
body["element_id"] = element_id
|
|
235
|
+
if x is not None:
|
|
236
|
+
body["x"] = x
|
|
237
|
+
if y is not None:
|
|
238
|
+
body["y"] = y
|
|
239
|
+
return await self._apost(f"/sessions/{session_id}/actions/click", body)
|
|
240
|
+
|
|
241
|
+
def type_text(self, session_id: str, text: str, *, selector: str | None = None, element_id: str | None = None, clear_first: bool = True) -> dict:
|
|
242
|
+
body: dict[str, Any] = {"text": text, "clear_first": clear_first}
|
|
243
|
+
if selector:
|
|
244
|
+
body["selector"] = selector
|
|
245
|
+
if element_id:
|
|
246
|
+
body["element_id"] = element_id
|
|
247
|
+
return self._post(f"/sessions/{session_id}/actions/type", body)
|
|
248
|
+
|
|
249
|
+
async def async_type_text(self, session_id: str, text: str, *, selector: str | None = None, element_id: str | None = None, clear_first: bool = True) -> dict:
|
|
250
|
+
body: dict[str, Any] = {"text": text, "clear_first": clear_first}
|
|
251
|
+
if selector:
|
|
252
|
+
body["selector"] = selector
|
|
253
|
+
if element_id:
|
|
254
|
+
body["element_id"] = element_id
|
|
255
|
+
return await self._apost(f"/sessions/{session_id}/actions/type", body)
|
|
256
|
+
|
|
257
|
+
def scroll(self, session_id: str, *, delta_x: float = 0, delta_y: float = 600) -> dict:
|
|
258
|
+
return self._post(f"/sessions/{session_id}/actions/scroll", {"delta_x": delta_x, "delta_y": delta_y})
|
|
259
|
+
|
|
260
|
+
async def async_scroll(self, session_id: str, *, delta_x: float = 0, delta_y: float = 600) -> dict:
|
|
261
|
+
return await self._apost(f"/sessions/{session_id}/actions/scroll", {"delta_x": delta_x, "delta_y": delta_y})
|
|
262
|
+
|
|
263
|
+
def screenshot(self, session_id: str, label: str = "manual") -> dict:
|
|
264
|
+
return self._post(f"/sessions/{session_id}/screenshot", {"label": label})
|
|
265
|
+
|
|
266
|
+
async def async_screenshot(self, session_id: str, label: str = "manual") -> dict:
|
|
267
|
+
return await self._apost(f"/sessions/{session_id}/screenshot", {"label": label})
|
|
268
|
+
|
|
269
|
+
def screenshot_diff(self, session_id: str) -> dict:
|
|
270
|
+
"""Capture screenshot and diff against the most recent prior screenshot."""
|
|
271
|
+
return self._post(f"/sessions/{session_id}/screenshot/compare")
|
|
272
|
+
|
|
273
|
+
async def async_screenshot_diff(self, session_id: str) -> dict:
|
|
274
|
+
return await self._apost(f"/sessions/{session_id}/screenshot/compare")
|
|
275
|
+
|
|
276
|
+
# ── Agent ────────────────────────────────────────────────────────────────
|
|
277
|
+
|
|
278
|
+
def agent_step(self, session_id: str, *, provider: str, goal: str, **kwargs) -> dict:
|
|
279
|
+
return self._post(f"/sessions/{session_id}/agent/step", {"provider": provider, "goal": goal, **kwargs})
|
|
280
|
+
|
|
281
|
+
async def async_agent_step(self, session_id: str, *, provider: str, goal: str, **kwargs) -> dict:
|
|
282
|
+
return await self._apost(f"/sessions/{session_id}/agent/step", {"provider": provider, "goal": goal, **kwargs})
|
|
283
|
+
|
|
284
|
+
def agent_run(self, session_id: str, *, provider: str, goal: str, max_steps: int = 6, **kwargs) -> dict:
|
|
285
|
+
return self._post(f"/sessions/{session_id}/agent/run", {"provider": provider, "goal": goal, "max_steps": max_steps, **kwargs})
|
|
286
|
+
|
|
287
|
+
async def async_agent_run(self, session_id: str, *, provider: str, goal: str, max_steps: int = 6, **kwargs) -> dict:
|
|
288
|
+
return await self._apost(f"/sessions/{session_id}/agent/run", {"provider": provider, "goal": goal, "max_steps": max_steps, **kwargs})
|
|
289
|
+
|
|
290
|
+
# ── Approvals ────────────────────────────────────────────────────────────
|
|
291
|
+
|
|
292
|
+
def list_approvals(self, *, status: str | None = None, session_id: str | None = None) -> list[dict]:
|
|
293
|
+
params: dict[str, Any] = {}
|
|
294
|
+
if status:
|
|
295
|
+
params["status"] = status
|
|
296
|
+
if session_id:
|
|
297
|
+
params["session_id"] = session_id
|
|
298
|
+
return self._get("/approvals", **params)
|
|
299
|
+
|
|
300
|
+
async def async_list_approvals(self, *, status: str | None = None, session_id: str | None = None) -> list[dict]:
|
|
301
|
+
params: dict[str, Any] = {}
|
|
302
|
+
if status:
|
|
303
|
+
params["status"] = status
|
|
304
|
+
if session_id:
|
|
305
|
+
params["session_id"] = session_id
|
|
306
|
+
return await self._aget("/approvals", **params)
|
|
307
|
+
|
|
308
|
+
def approve(self, approval_id: str, comment: str | None = None) -> dict:
|
|
309
|
+
return self._post(f"/approvals/{approval_id}/approve", {"comment": comment})
|
|
310
|
+
|
|
311
|
+
async def async_approve(self, approval_id: str, comment: str | None = None) -> dict:
|
|
312
|
+
return await self._apost(f"/approvals/{approval_id}/approve", {"comment": comment})
|
|
313
|
+
|
|
314
|
+
def reject(self, approval_id: str, comment: str | None = None) -> dict:
|
|
315
|
+
return self._post(f"/approvals/{approval_id}/reject", {"comment": comment})
|
|
316
|
+
|
|
317
|
+
async def async_reject(self, approval_id: str, comment: str | None = None) -> dict:
|
|
318
|
+
return await self._apost(f"/approvals/{approval_id}/reject", {"comment": comment})
|
|
319
|
+
|
|
320
|
+
# ── Auth profiles ────────────────────────────────────────────────────────
|
|
321
|
+
|
|
322
|
+
def list_auth_profiles(self) -> list[dict]:
|
|
323
|
+
return self._get("/auth-profiles")
|
|
324
|
+
|
|
325
|
+
async def async_list_auth_profiles(self) -> list[dict]:
|
|
326
|
+
return await self._aget("/auth-profiles")
|
|
327
|
+
|
|
328
|
+
def save_auth_profile(self, session_id: str, profile_name: str) -> dict:
|
|
329
|
+
return self._post(f"/sessions/{session_id}/auth-profiles", {"profile_name": profile_name})
|
|
330
|
+
|
|
331
|
+
async def async_save_auth_profile(self, session_id: str, profile_name: str) -> dict:
|
|
332
|
+
return await self._apost(f"/sessions/{session_id}/auth-profiles", {"profile_name": profile_name})
|
|
333
|
+
|
|
334
|
+
def import_auth_profile(self, archive_path: str, *, overwrite: bool = False) -> dict:
|
|
335
|
+
return self._post("/auth-profiles/import", {"archive_path": archive_path, "overwrite": overwrite})
|
|
336
|
+
|
|
337
|
+
async def async_import_auth_profile(self, archive_path: str, *, overwrite: bool = False) -> dict:
|
|
338
|
+
return await self._apost("/auth-profiles/import", {"archive_path": archive_path, "overwrite": overwrite})
|
|
339
|
+
|
|
340
|
+
# ── SSE event stream (sync generator) ────────────────────────────────────
|
|
341
|
+
|
|
342
|
+
def stream_events(self, session_id: str) -> Generator[dict, None, None]:
|
|
343
|
+
"""Yield parsed event dicts from the SSE stream. Blocks until disconnected."""
|
|
344
|
+
import json as _json
|
|
345
|
+
|
|
346
|
+
url = f"{self.base_url}/sessions/{session_id}/events"
|
|
347
|
+
with httpx.stream("GET", url, headers=self._headers, timeout=None) as r:
|
|
348
|
+
self._raise(r)
|
|
349
|
+
buffer = ""
|
|
350
|
+
for chunk in r.iter_text():
|
|
351
|
+
buffer += chunk
|
|
352
|
+
while "\n\n" in buffer:
|
|
353
|
+
block, buffer = buffer.split("\n\n", 1)
|
|
354
|
+
for line in block.splitlines():
|
|
355
|
+
if line.startswith("data: "):
|
|
356
|
+
try:
|
|
357
|
+
yield _json.loads(line[6:])
|
|
358
|
+
except Exception:
|
|
359
|
+
pass
|
|
360
|
+
|
|
361
|
+
# ── Audit ────────────────────────────────────────────────────────────────
|
|
362
|
+
|
|
363
|
+
def list_audit_events(self, *, limit: int = 50, session_id: str | None = None) -> list[dict]:
|
|
364
|
+
params: dict[str, Any] = {"limit": limit}
|
|
365
|
+
if session_id:
|
|
366
|
+
params["session_id"] = session_id
|
|
367
|
+
return self._get("/audit/events", **params)
|
|
368
|
+
|
|
369
|
+
async def async_list_audit_events(self, *, limit: int = 50, session_id: str | None = None) -> list[dict]:
|
|
370
|
+
params: dict[str, Any] = {"limit": limit}
|
|
371
|
+
if session_id:
|
|
372
|
+
params["session_id"] = session_id
|
|
373
|
+
return await self._aget("/audit/events", **params)
|
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
"""Stdio <-> HTTP bridge for Auto Browser's MCP endpoint.
|
|
2
|
+
|
|
3
|
+
Single-file and stdlib-only by design. This module ships in two places:
|
|
4
|
+
as ``auto_browser_client.mcp_bridge`` (the PyPI ``auto-browser-mcp`` console
|
|
5
|
+
script) and as ``app.mcp_stdio`` inside the controller image (Glama/Docker
|
|
6
|
+
entrypoint). A guard test asserts the two copies stay byte-identical, so any
|
|
7
|
+
edit here must be mirrored to the other copy.
|
|
8
|
+
"""
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import argparse
|
|
12
|
+
import json
|
|
13
|
+
import os
|
|
14
|
+
import sys
|
|
15
|
+
from dataclasses import dataclass
|
|
16
|
+
from typing import Any, TextIO
|
|
17
|
+
from urllib.error import HTTPError, URLError
|
|
18
|
+
from urllib.request import Request, urlopen
|
|
19
|
+
|
|
20
|
+
# MCP spec header names, kept as local constants so the bridge has no
|
|
21
|
+
# controller dependency. The controller test suite asserts these match
|
|
22
|
+
# app.mcp_transport's values.
|
|
23
|
+
MCP_SESSION_HEADER = "MCP-Session-Id"
|
|
24
|
+
MCP_PROTOCOL_HEADER = "MCP-Protocol-Version"
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@dataclass
|
|
28
|
+
class HttpMcpResponse:
|
|
29
|
+
status_code: int
|
|
30
|
+
headers: dict[str, str]
|
|
31
|
+
body: dict[str, Any] | None
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class HttpMcpClient:
|
|
35
|
+
def __init__(self, *, base_url: str, bearer_token: str | None = None, timeout_seconds: float = 60.0):
|
|
36
|
+
self.base_url = base_url
|
|
37
|
+
self.bearer_token = bearer_token
|
|
38
|
+
self.timeout_seconds = timeout_seconds
|
|
39
|
+
|
|
40
|
+
def post_json(
|
|
41
|
+
self,
|
|
42
|
+
payload: dict[str, Any],
|
|
43
|
+
*,
|
|
44
|
+
session_id: str | None = None,
|
|
45
|
+
protocol_version: str | None = None,
|
|
46
|
+
) -> HttpMcpResponse:
|
|
47
|
+
headers = {"Content-Type": "application/json"}
|
|
48
|
+
if session_id:
|
|
49
|
+
headers[MCP_SESSION_HEADER] = session_id
|
|
50
|
+
if protocol_version:
|
|
51
|
+
headers[MCP_PROTOCOL_HEADER] = protocol_version
|
|
52
|
+
if self.bearer_token:
|
|
53
|
+
headers["Authorization"] = f"Bearer {self.bearer_token}"
|
|
54
|
+
return self._request("POST", headers=headers, body=payload)
|
|
55
|
+
|
|
56
|
+
def delete_session(self, *, session_id: str | None) -> None:
|
|
57
|
+
if not session_id:
|
|
58
|
+
return
|
|
59
|
+
headers = {MCP_SESSION_HEADER: session_id}
|
|
60
|
+
if self.bearer_token:
|
|
61
|
+
headers["Authorization"] = f"Bearer {self.bearer_token}"
|
|
62
|
+
try:
|
|
63
|
+
self._request("DELETE", headers=headers, body=None)
|
|
64
|
+
except Exception:
|
|
65
|
+
return
|
|
66
|
+
|
|
67
|
+
def _request(self, method: str, *, headers: dict[str, str], body: dict[str, Any] | None) -> HttpMcpResponse:
|
|
68
|
+
payload = None if body is None else json.dumps(body, ensure_ascii=False).encode("utf-8")
|
|
69
|
+
request = Request(self.base_url, data=payload, headers=headers, method=method)
|
|
70
|
+
try:
|
|
71
|
+
with urlopen(request, timeout=self.timeout_seconds) as response:
|
|
72
|
+
raw = response.read()
|
|
73
|
+
return HttpMcpResponse(
|
|
74
|
+
status_code=response.status,
|
|
75
|
+
headers={k.lower(): v for k, v in response.headers.items()},
|
|
76
|
+
body=self._decode_json(raw),
|
|
77
|
+
)
|
|
78
|
+
except HTTPError as exc:
|
|
79
|
+
return HttpMcpResponse(
|
|
80
|
+
status_code=exc.code,
|
|
81
|
+
headers={k.lower(): v for k, v in exc.headers.items()},
|
|
82
|
+
body=self._decode_json(exc.read()),
|
|
83
|
+
)
|
|
84
|
+
|
|
85
|
+
@staticmethod
|
|
86
|
+
def _decode_json(raw: bytes) -> dict[str, Any] | None:
|
|
87
|
+
if not raw:
|
|
88
|
+
return None
|
|
89
|
+
return json.loads(raw.decode("utf-8"))
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
class StdioMcpBridge:
|
|
93
|
+
def __init__(self, *, client: HttpMcpClient, stderr: TextIO | None = None):
|
|
94
|
+
self.client = client
|
|
95
|
+
self.stderr = stderr or sys.stderr
|
|
96
|
+
self.session_id: str | None = None
|
|
97
|
+
self.protocol_version: str | None = None
|
|
98
|
+
|
|
99
|
+
def run(self, *, stdin: TextIO | None = None, stdout: TextIO | None = None) -> int:
|
|
100
|
+
input_stream = stdin or sys.stdin
|
|
101
|
+
output_stream = stdout or sys.stdout
|
|
102
|
+
try:
|
|
103
|
+
for raw_line in input_stream:
|
|
104
|
+
line = raw_line.strip()
|
|
105
|
+
if not line:
|
|
106
|
+
continue
|
|
107
|
+
response = self._handle_line(line)
|
|
108
|
+
if response is not None:
|
|
109
|
+
output_stream.write(json.dumps(response, ensure_ascii=False) + "\n")
|
|
110
|
+
output_stream.flush()
|
|
111
|
+
finally:
|
|
112
|
+
self.client.delete_session(session_id=self.session_id)
|
|
113
|
+
return 0
|
|
114
|
+
|
|
115
|
+
def _handle_line(self, line: str) -> dict[str, Any] | None:
|
|
116
|
+
try:
|
|
117
|
+
payload = json.loads(line)
|
|
118
|
+
except json.JSONDecodeError:
|
|
119
|
+
return self._jsonrpc_error(None, -32700, "Invalid JSON payload")
|
|
120
|
+
|
|
121
|
+
if isinstance(payload, list):
|
|
122
|
+
return self._jsonrpc_error(None, -32600, "JSON-RPC batches are not supported")
|
|
123
|
+
if not isinstance(payload, dict):
|
|
124
|
+
return self._jsonrpc_error(None, -32600, "JSON-RPC body must be an object")
|
|
125
|
+
|
|
126
|
+
request_id = payload.get("id")
|
|
127
|
+
try:
|
|
128
|
+
response = self.client.post_json(
|
|
129
|
+
payload,
|
|
130
|
+
session_id=None if payload.get("method") == "initialize" else self.session_id,
|
|
131
|
+
protocol_version=self.protocol_version,
|
|
132
|
+
)
|
|
133
|
+
except URLError as exc:
|
|
134
|
+
return self._jsonrpc_error(request_id, -32000, f"Unable to reach Auto Browser MCP HTTP endpoint: {exc.reason}")
|
|
135
|
+
except Exception as exc: # pragma: no cover - defensive bridge guard
|
|
136
|
+
print(f"stdio bridge unexpected error: {exc}", file=self.stderr)
|
|
137
|
+
return self._jsonrpc_error(request_id, -32000, f"Unexpected stdio bridge failure: {exc}")
|
|
138
|
+
|
|
139
|
+
next_session_id = response.headers.get(MCP_SESSION_HEADER.lower())
|
|
140
|
+
if next_session_id:
|
|
141
|
+
self.session_id = next_session_id
|
|
142
|
+
|
|
143
|
+
next_protocol_version = response.headers.get(MCP_PROTOCOL_HEADER.lower())
|
|
144
|
+
if next_protocol_version:
|
|
145
|
+
self.protocol_version = next_protocol_version
|
|
146
|
+
elif payload.get("method") == "initialize":
|
|
147
|
+
result = (response.body or {}).get("result") if isinstance(response.body, dict) else None
|
|
148
|
+
if isinstance(result, dict):
|
|
149
|
+
version = result.get("protocolVersion")
|
|
150
|
+
if isinstance(version, str) and version.strip():
|
|
151
|
+
self.protocol_version = version.strip()
|
|
152
|
+
|
|
153
|
+
if payload.get("id") is None:
|
|
154
|
+
return None
|
|
155
|
+
if response.body is None:
|
|
156
|
+
return self._jsonrpc_error(request_id, -32000, f"Empty response from Auto Browser MCP endpoint ({response.status_code})")
|
|
157
|
+
return response.body
|
|
158
|
+
|
|
159
|
+
@staticmethod
|
|
160
|
+
def _jsonrpc_error(request_id: Any, code: int, message: str) -> dict[str, Any]:
|
|
161
|
+
return {
|
|
162
|
+
"jsonrpc": "2.0",
|
|
163
|
+
"id": request_id,
|
|
164
|
+
"error": {
|
|
165
|
+
"code": code,
|
|
166
|
+
"message": message,
|
|
167
|
+
},
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def build_arg_parser() -> argparse.ArgumentParser:
|
|
172
|
+
parser = argparse.ArgumentParser(description="Bridge stdio MCP clients to the Auto Browser HTTP MCP endpoint.")
|
|
173
|
+
parser.add_argument(
|
|
174
|
+
"--base-url",
|
|
175
|
+
default=os.environ.get("AUTO_BROWSER_BASE_URL", "http://127.0.0.1:8000/mcp"),
|
|
176
|
+
help="HTTP MCP endpoint to proxy to (default: %(default)s)",
|
|
177
|
+
)
|
|
178
|
+
parser.add_argument(
|
|
179
|
+
"--bearer-token",
|
|
180
|
+
default=os.environ.get("AUTO_BROWSER_BEARER_TOKEN"),
|
|
181
|
+
help="Optional API bearer token for the Auto Browser HTTP server.",
|
|
182
|
+
)
|
|
183
|
+
parser.add_argument(
|
|
184
|
+
"--timeout-seconds",
|
|
185
|
+
type=float,
|
|
186
|
+
default=float(os.environ.get("AUTO_BROWSER_HTTP_TIMEOUT_SECONDS", "60")),
|
|
187
|
+
help="Per-request timeout when talking to the HTTP MCP endpoint.",
|
|
188
|
+
)
|
|
189
|
+
return parser
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def main() -> int:
|
|
193
|
+
args = build_arg_parser().parse_args()
|
|
194
|
+
bridge = StdioMcpBridge(
|
|
195
|
+
client=HttpMcpClient(
|
|
196
|
+
base_url=args.base_url,
|
|
197
|
+
bearer_token=args.bearer_token,
|
|
198
|
+
timeout_seconds=args.timeout_seconds,
|
|
199
|
+
)
|
|
200
|
+
)
|
|
201
|
+
return bridge.run()
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
if __name__ == "__main__":
|
|
205
|
+
raise SystemExit(main())
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: auto-browser-client
|
|
3
|
+
Version: 1.2.1
|
|
4
|
+
Summary: Python client SDK and MCP stdio bridge for Auto Browser
|
|
5
|
+
License: MIT
|
|
6
|
+
Project-URL: Homepage, https://github.com/LvcidPsyche/auto-browser
|
|
7
|
+
Project-URL: Repository, https://github.com/LvcidPsyche/auto-browser
|
|
8
|
+
Project-URL: Changelog, https://github.com/LvcidPsyche/auto-browser/blob/main/CHANGELOG.md
|
|
9
|
+
Project-URL: Issues, https://github.com/LvcidPsyche/auto-browser/issues
|
|
10
|
+
Keywords: auto-browser,mcp,browser-automation,playwright
|
|
11
|
+
Classifier: Development Status :: 4 - Beta
|
|
12
|
+
Classifier: Intended Audience :: Developers
|
|
13
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
14
|
+
Classifier: Programming Language :: Python :: 3
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
20
|
+
Classifier: Topic :: Internet :: WWW/HTTP :: Browsers
|
|
21
|
+
Requires-Python: >=3.10
|
|
22
|
+
Description-Content-Type: text/markdown
|
|
23
|
+
Requires-Dist: httpx>=0.27
|
|
24
|
+
Provides-Extra: dev
|
|
25
|
+
Requires-Dist: pytest; extra == "dev"
|
|
26
|
+
Requires-Dist: pytest-asyncio; extra == "dev"
|
|
27
|
+
Requires-Dist: respx; extra == "dev"
|
|
28
|
+
|
|
29
|
+
# auto-browser-client
|
|
30
|
+
|
|
31
|
+
Python SDK and MCP stdio bridge for the [Auto Browser](https://github.com/LvcidPsyche/auto-browser) REST API.
|
|
32
|
+
|
|
33
|
+
## SDK
|
|
34
|
+
|
|
35
|
+
```python
|
|
36
|
+
from auto_browser_client import AutoBrowserClient
|
|
37
|
+
|
|
38
|
+
client = AutoBrowserClient("http://localhost:8000", token="secret")
|
|
39
|
+
session = client.create_session(start_url="https://example.com")
|
|
40
|
+
client.navigate(session["id"], "https://example.com/dashboard")
|
|
41
|
+
client.close_session(session["id"])
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
## MCP stdio bridge
|
|
45
|
+
|
|
46
|
+
This package also ships the `auto-browser-mcp` console script, which bridges
|
|
47
|
+
stdio MCP clients (Claude Desktop, Cursor, ...) to a running Auto Browser
|
|
48
|
+
controller:
|
|
49
|
+
|
|
50
|
+
```bash
|
|
51
|
+
auto-browser-mcp --base-url http://127.0.0.1:8000/mcp
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
Or zero-install via [uv](https://docs.astral.sh/uv/): `uvx auto-browser-mcp`
|
|
55
|
+
(resolved through the [auto-browser-mcp](https://pypi.org/project/auto-browser-mcp/)
|
|
56
|
+
metapackage).
|
|
57
|
+
|
|
58
|
+
The server itself is Docker-first — see the
|
|
59
|
+
[Auto Browser repository](https://github.com/LvcidPsyche/auto-browser) to run it.
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
auto_browser_client/__init__.py,sha256=z3n69rLIF8QOAsrCzhynYoZKJY3Uf6OPVQDqVqIFxSY,131
|
|
2
|
+
auto_browser_client/client.py,sha256=mU-Ji9Gwyh7ge-KoVwuBxHQr7ukvERw_JqRwQLQnkbc,16595
|
|
3
|
+
auto_browser_client/mcp_bridge.py,sha256=E4vog71SgKS9Au2PHlsM42cC1j_IhXK1_tertr7w9NY,7853
|
|
4
|
+
auto_browser_client-1.2.1.dist-info/METADATA,sha256=21SMvQtONGPC1pfCdgdKWcZC8uEsCwOIYG3AZrMiz1k,2223
|
|
5
|
+
auto_browser_client-1.2.1.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
|
|
6
|
+
auto_browser_client-1.2.1.dist-info/entry_points.txt,sha256=FFMGj01khJB9wnindg1FRJKcG7A3ca0BYhSzu3Un758,73
|
|
7
|
+
auto_browser_client-1.2.1.dist-info/top_level.txt,sha256=8etgv_rs25N5QzUhG_6Lqzcm9BeB9SjwpV3goH-NJrw,20
|
|
8
|
+
auto_browser_client-1.2.1.dist-info/RECORD,,
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
auto_browser_client
|