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.
@@ -0,0 +1,5 @@
1
+ """auto-browser Python client SDK."""
2
+ from .client import AutoBrowserClient
3
+
4
+ __all__ = ["AutoBrowserClient"]
5
+ __version__ = "1.2.1"
@@ -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,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (82.0.1)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ auto-browser-mcp = auto_browser_client.mcp_bridge:main
@@ -0,0 +1 @@
1
+ auto_browser_client