uber-mcp 0.1.2__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.
uber_login/__init__.py ADDED
@@ -0,0 +1,9 @@
1
+ """uber-login: the browser a person signs in to Uber with, shown to them through a remote-desktop view.
2
+
3
+ The person sees Uber's own sign-in page, running in a Chromium next to the server, through noVNC. They sign in
4
+ exactly as on uber.com (code, Google, Apple or QR code); this service automates nothing on the sign-in
5
+ (``tests/test_login_safety.py`` pins that). It only notices when Uber says signed in, opens Uber's trips site once
6
+ (Uber's single sign-on for it), checks both from the same browser, hands the session to uber-mcp once and closes
7
+ the browser. It holds no session of its own and no key; only uber-mcp can call it (a bearer).
8
+ ``window.sign_in`` is the same window on the person's own screen, for ``uber-mcp login``.
9
+ """
uber_login/__main__.py ADDED
@@ -0,0 +1,3 @@
1
+ from .app import main
2
+
3
+ main()
uber_login/app.py ADDED
@@ -0,0 +1,218 @@
1
+ """The login service's API. uber-mcp (``uber-mcp admin login``) holds its bearer; the person, one view token.
2
+
3
+ POST /logins {"layout": "phone"|"desktop"} -> 201 {id, state, view_token, ...} | 503 busy (bearer)
4
+ GET /logins/{id} -> {id, state, host, error, layout} (bearer)
5
+ POST /logins/{id}/result -> the proven session once {account, user_agent, cookies} | 409 not yet (bearer)
6
+ DELETE /logins/{id} -> 204 (bearer)
7
+ GET /view/{id}?t=TOKEN -> the person's sign-in page: Uber's page through noVNC (view token)
8
+ GET /view/{id}/state?t= -> {state, host} (view token)
9
+ WS /logins/{id}/vnc?t= -> the login's remote-desktop stream (RFB), for noVNC (view token)
10
+ GET /novnc/... -> noVNC's own files (the viewer) (open)
11
+ GET /health -> open
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import asyncio
17
+ import hmac
18
+ import logging
19
+ import os
20
+ from pathlib import Path
21
+
22
+ from starlette.applications import Starlette
23
+ from starlette.middleware import Middleware
24
+ from starlette.requests import Request
25
+ from starlette.responses import HTMLResponse, JSONResponse, Response
26
+ from starlette.routing import Mount, Route, WebSocketRoute
27
+ from starlette.staticfiles import StaticFiles
28
+ from starlette.types import ASGIApp, Receive, Scope, Send
29
+ from starlette.websockets import WebSocket, WebSocketDisconnect
30
+
31
+ from .logins import Busy, Logins
32
+
33
+ log = logging.getLogger("uber-login")
34
+
35
+ VIEW_PAGE = """<!doctype html><meta name=viewport content="width=device-width,initial-scale=1">
36
+ <meta name=referrer content=no-referrer>
37
+ <title>Sign in to Uber</title>
38
+ <style>
39
+ html,body{margin:0;height:100%;font:15px system-ui;background:#f4f5f4}
40
+ header{padding:.6em 1em;background:#fff;border-bottom:1px solid #ddd}
41
+ header b{font-size:1.05em} .url{color:#2a7a2a;font-size:.85em;margin-top:.2em}
42
+ #screen{position:absolute;top:4.2em;bottom:2.4em;left:0;right:0;background:#fff}
43
+ footer{position:absolute;bottom:0;left:0;right:0;padding:.5em 1em;font-size:.8em;color:#555;background:#fff;
44
+ border-top:1px solid #ddd}
45
+ #done{display:none;padding:3em 1em;text-align:center;font-size:1.2em}
46
+ </style>
47
+ <header><b>Sign in to Uber for uber-mcp</b><div class=url id=url>auth.uber.com</div></header>
48
+ <div id=screen></div><div id=done></div>
49
+ <footer id=status>Sign in as you normally do (code, Google, Apple or QR code). This page finishes by itself.</footer>
50
+ <script type=module>
51
+ import RFB from '/novnc/core/rfb.js';
52
+ const id = location.pathname.split('/')[2], t = new URLSearchParams(location.search).get('t');
53
+ history.replaceState(null, '', location.pathname); // keep the token out of the address bar
54
+ const ws = (location.protocol === 'https:' ? 'wss' : 'ws') + '://' + location.host;
55
+ const rfb = new RFB(document.getElementById('screen'), `${ws}/logins/${id}/vnc?t=${encodeURIComponent(t)}`);
56
+ rfb.scaleViewport = true; rfb.resizeSession = false; rfb.focusOnClick = true;
57
+ const status = document.getElementById('status');
58
+ async function poll() {
59
+ let s;
60
+ try { s = await (await fetch(`/view/${id}/state?t=${encodeURIComponent(t)}`)).json(); } catch (e) { s = {}; }
61
+ if (s.host) document.getElementById('url').textContent = s.host;
62
+ if (s.state === 'signed_in' || s.state === 'closed') {
63
+ rfb.disconnect(); document.getElementById('screen').remove();
64
+ const d = document.getElementById('done'); d.style.display = 'block';
65
+ d.textContent = 'Signed in. You can close this page.'; status.textContent = ''; return;
66
+ }
67
+ if (['failed', 'expired', 'unknown'].includes(s.state)) {
68
+ status.textContent = 'That did not work (' + s.state + ').'; return;
69
+ }
70
+ setTimeout(poll, 1000);
71
+ }
72
+ poll();
73
+ </script>"""
74
+
75
+
76
+ class BearerOnly:
77
+ """Every route but /health, /novnc and the person's view (its own token) needs the bearer, checked before any
78
+ WebSocket handshake too."""
79
+
80
+ def __init__(self, app: ASGIApp, token: str) -> None:
81
+ self.app, self.expected = app, f"Bearer {token}".encode()
82
+
83
+ @staticmethod
84
+ def _open(scope: Scope) -> bool:
85
+ path = scope.get("path") or ""
86
+ if scope["type"] == "websocket":
87
+ return path.startswith("/logins/") and path.endswith("/vnc") # the view token is checked there
88
+ return path == "/health" or path.startswith(("/novnc/", "/view/"))
89
+
90
+ async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
91
+ if scope["type"] in ("http", "websocket") and not self._open(scope):
92
+ given = dict(scope.get("headers") or []).get(b"authorization", b"")
93
+ if not hmac.compare_digest(given, self.expected):
94
+ if scope["type"] == "websocket":
95
+ await send({"type": "websocket.close", "code": 4401})
96
+ else:
97
+ await Response(status_code=401)(scope, receive, send)
98
+ return
99
+ await self.app(scope, receive, send)
100
+
101
+
102
+ def build_app(logins: Logins, *, token: str, novnc_dir: Path) -> Starlette:
103
+ async def health(_: Request) -> JSONResponse:
104
+ return JSONResponse({"status": "healthy"})
105
+
106
+ async def start(request: Request) -> JSONResponse:
107
+ try:
108
+ body = await request.json()
109
+ except ValueError:
110
+ body = {}
111
+ layout = body.get("layout") if isinstance(body, dict) else None
112
+ try:
113
+ login = await logins.start(layout if layout in ("phone", "desktop") else "desktop")
114
+ except Busy:
115
+ return JSONResponse({"error": "busy"}, status_code=503)
116
+ return JSONResponse({**login.public(), "view_token": login.view_token}, status_code=201)
117
+
118
+ async def state(request: Request) -> JSONResponse:
119
+ login = logins.get(request.path_params["id"])
120
+ if login is None:
121
+ return JSONResponse({"error": "unknown"}, status_code=404)
122
+ return JSONResponse(login.public())
123
+
124
+ async def result(request: Request) -> JSONResponse:
125
+ login_id = request.path_params["id"]
126
+ if logins.get(login_id) is None:
127
+ return JSONResponse({"error": "unknown"}, status_code=404)
128
+ session = logins.take(login_id)
129
+ if session is None:
130
+ return JSONResponse({"error": "not_signed_in"}, status_code=409)
131
+ return JSONResponse(session)
132
+
133
+ async def close(request: Request) -> Response:
134
+ await logins.close(request.path_params["id"])
135
+ return Response(status_code=204)
136
+
137
+ def _viewable(login_id: str, view_token: str): # type: ignore[no-untyped-def]
138
+ login = logins.get(login_id)
139
+ return login if login is not None and login.view_ok(view_token) else None
140
+
141
+ async def view(request: Request) -> Response:
142
+ if _viewable(request.path_params["id"], request.query_params.get("t", "")) is None:
143
+ return Response(status_code=404)
144
+ return HTMLResponse(VIEW_PAGE, headers={"Cache-Control": "no-store", "X-Frame-Options": "DENY"})
145
+
146
+ async def view_state(request: Request) -> JSONResponse:
147
+ login = _viewable(request.path_params["id"], request.query_params.get("t", ""))
148
+ if login is None:
149
+ return JSONResponse({"state": "unknown"})
150
+ return JSONResponse({"state": login.state, "host": login.host})
151
+
152
+ async def vnc(ws: WebSocket) -> None:
153
+ login = _viewable(ws.path_params["id"], ws.query_params.get("t", ""))
154
+ if login is None or login.state not in ("starting", "waiting"):
155
+ await ws.close(code=4404)
156
+ return
157
+ await ws.accept(subprotocol="binary" if "binary" in ws.scope.get("subprotocols", []) else None)
158
+ try:
159
+ reader, writer = await asyncio.open_connection("127.0.0.1", login.vnc_port)
160
+ except OSError:
161
+ await ws.close(code=1011)
162
+ return
163
+
164
+ async def upstream() -> None:
165
+ try:
166
+ while True:
167
+ message = await ws.receive()
168
+ if message["type"] == "websocket.disconnect":
169
+ return
170
+ writer.write(message.get("bytes") or (message.get("text") or "").encode())
171
+ await writer.drain()
172
+ except (WebSocketDisconnect, ConnectionError):
173
+ return
174
+
175
+ async def downstream() -> None:
176
+ try:
177
+ while data := await reader.read(65536):
178
+ await ws.send_bytes(data)
179
+ except (WebSocketDisconnect, ConnectionError, RuntimeError):
180
+ return
181
+
182
+ tasks = [asyncio.create_task(upstream()), asyncio.create_task(downstream())]
183
+ await asyncio.wait(tasks, return_when=asyncio.FIRST_COMPLETED)
184
+ for task in tasks:
185
+ task.cancel()
186
+ writer.close()
187
+
188
+ routes = [
189
+ Route("/health", health),
190
+ Route("/logins", start, methods=["POST"]),
191
+ Route("/logins/{id}", state, methods=["GET"]),
192
+ Route("/logins/{id}", close, methods=["DELETE"]),
193
+ Route("/logins/{id}/result", result, methods=["POST"]),
194
+ Route("/view/{id}", view, methods=["GET"]),
195
+ Route("/view/{id}/state", view_state, methods=["GET"]),
196
+ WebSocketRoute("/logins/{id}/vnc", vnc),
197
+ ]
198
+ if novnc_dir.is_dir():
199
+ routes.append(Mount("/novnc", app=StaticFiles(directory=novnc_dir)))
200
+ return Starlette(routes=routes, middleware=[Middleware(BearerOnly, token=token)])
201
+
202
+
203
+ def main() -> None:
204
+ import uvicorn
205
+
206
+ logging.basicConfig(level=logging.INFO, format="%(asctime)s %(name)s %(levelname)s %(message)s")
207
+ token = (os.environ.get("UBER_LOGIN_TOKEN") or "").strip()
208
+ if len(token) < 32:
209
+ raise SystemExit("UBER_LOGIN_TOKEN (at least 32 characters) is required")
210
+ logins = Logins(
211
+ slots=int(os.environ.get("UBER_LOGIN_SLOTS") or 1),
212
+ minutes=int(os.environ.get("UBER_LOGIN_MINUTES") or 20),
213
+ xvfb=os.environ.get("UBER_LOGIN_XVFB") or "Xvfb",
214
+ x11vnc=os.environ.get("UBER_LOGIN_X11VNC") or "x11vnc",
215
+ )
216
+ novnc = Path(os.environ.get("UBER_LOGIN_NOVNC") or "/usr/share/novnc")
217
+ app = build_app(logins, token=token, novnc_dir=novnc)
218
+ uvicorn.run(app, host="0.0.0.0", port=int(os.environ.get("PORT") or 8080), log_level="warning")
uber_login/logins.py ADDED
@@ -0,0 +1,215 @@
1
+ """One login = one virtual display, one remote-desktop server on loopback and one throwaway Chromium profile.
2
+
3
+ States: ``starting`` -> ``waiting`` (Uber's sign-in page is up; the person is signing in) -> ``signed_in``
4
+ (proven; the session waits to be taken once) -> ``closed``; or ``failed`` / ``expired`` / ``closed`` early.
5
+ Nothing here clicks, types or fills: the person does every step of the sign-in. Each login has its own view token,
6
+ which only the caller that started it gets: the person's viewer page and remote-desktop stream need it.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import asyncio
12
+ import contextlib
13
+ import logging
14
+ import os
15
+ import secrets
16
+ import shutil
17
+ import subprocess
18
+ import tempfile
19
+ import time
20
+ from collections.abc import Awaitable, Callable
21
+ from dataclasses import dataclass, field
22
+ from typing import Any
23
+
24
+ from .window import DOCUMENT_HOSTS, LAYOUTS, LOGIN_URL, allowed_document, sign_in
25
+
26
+ __all__ = ["DOCUMENT_HOSTS", "LOGIN_URL", "Busy", "Login", "Logins", "allowed_document"]
27
+
28
+ log = logging.getLogger("uber-login")
29
+
30
+ RESULT_TTL = 5 * 60 # a proven session waits this long to be taken, then it is dropped
31
+
32
+
33
+ class Busy(Exception):
34
+ """Every login slot is in use."""
35
+
36
+
37
+ @dataclass
38
+ class Login:
39
+ id: str
40
+ layout: str
41
+ slot: int
42
+ created: float
43
+ state: str = "starting"
44
+ host: str = ""
45
+ error: str | None = None
46
+ result: dict[str, Any] | None = None
47
+ result_at: float | None = None
48
+ procs: list[subprocess.Popen] = field(default_factory=list)
49
+ profile: str | None = None
50
+ task: asyncio.Task | None = None
51
+ view_token: str = field(default_factory=lambda: secrets.token_urlsafe(24))
52
+
53
+ @property
54
+ def display(self) -> str:
55
+ return f":{90 + self.slot}"
56
+
57
+ @property
58
+ def vnc_port(self) -> int:
59
+ return 5990 + self.slot
60
+
61
+ def public(self) -> dict[str, Any]:
62
+ return {"id": self.id, "state": self.state, "host": self.host, "error": self.error, "layout": self.layout}
63
+
64
+ def view_ok(self, token: str) -> bool:
65
+ return secrets.compare_digest(token or "", self.view_token)
66
+
67
+
68
+ Browser = Callable[[Login, "Logins"], Awaitable[None]]
69
+
70
+
71
+ class Logins:
72
+ def __init__(
73
+ self,
74
+ *,
75
+ slots: int = 2,
76
+ minutes: int = 15,
77
+ xvfb: str = "Xvfb",
78
+ x11vnc: str = "x11vnc",
79
+ browser: Browser | None = None,
80
+ clock: Callable[[], float] = time.monotonic,
81
+ ) -> None:
82
+ self.slots, self.limit = slots, minutes * 60
83
+ self.xvfb, self.x11vnc = xvfb, x11vnc
84
+ self._browser = browser or run_browser
85
+ self._clock = clock
86
+ self._logins: dict[str, Login] = {}
87
+ self._lock = asyncio.Lock()
88
+
89
+ async def start(self, layout: str) -> Login:
90
+ if layout not in LAYOUTS:
91
+ raise ValueError("layout")
92
+ async with self._lock:
93
+ self.reap()
94
+ used = {login.slot for login in self._logins.values()}
95
+ free = [slot for slot in range(self.slots) if slot not in used]
96
+ if not free:
97
+ raise Busy()
98
+ login = Login(secrets.token_urlsafe(18), layout, free[0], self._clock())
99
+ self._logins[login.id] = login
100
+ try:
101
+ await asyncio.to_thread(self._start_display, login)
102
+ except Exception:
103
+ await self.close(login.id, "failed", "display")
104
+ raise
105
+ login.task = asyncio.create_task(self._run(login))
106
+ return login
107
+
108
+ def get(self, login_id: str) -> Login | None:
109
+ self.reap()
110
+ return self._logins.get(login_id)
111
+
112
+ def take(self, login_id: str) -> dict[str, Any] | None:
113
+ """The proven session, once; the login is over after that."""
114
+ login = self.get(login_id)
115
+ if login is None or login.state != "signed_in" or login.result is None:
116
+ return None
117
+ result, login.result = login.result, None
118
+ self._forget(login, "closed")
119
+ return result
120
+
121
+ async def close(self, login_id: str, state: str = "closed", error: str | None = None) -> None:
122
+ login = self._logins.get(login_id)
123
+ if login is None:
124
+ return
125
+ if login.task and not login.task.done() and login.task is not asyncio.current_task():
126
+ login.task.cancel()
127
+ with contextlib.suppress(asyncio.CancelledError, Exception):
128
+ await login.task
129
+ login.error = error or login.error
130
+ self._forget(login, state)
131
+
132
+ def reap(self) -> None:
133
+ """Logins past the time limit and proven sessions nobody took are ended."""
134
+ now = self._clock()
135
+ for login in list(self._logins.values()):
136
+ if login.state in ("starting", "waiting") and now - login.created > self.limit:
137
+ if login.task:
138
+ login.task.cancel()
139
+ self._forget(login, "expired")
140
+ elif login.state == "signed_in" and login.result_at and now - login.result_at > RESULT_TTL:
141
+ login.result = None
142
+ self._forget(login, "expired")
143
+
144
+ def _forget(self, login: Login, state: str) -> None:
145
+ login.state = state
146
+ self._stop_display(login)
147
+ self._logins.pop(login.id, None)
148
+ log.info("login %s ended: %s%s", login.id[:6], state, f" ({login.error})" if login.error else "")
149
+
150
+ # -- the display and its remote-desktop view ----------------------------------------------
151
+
152
+ def _start_display(self, login: Login) -> None:
153
+ w, h = LAYOUTS[login.layout]["size"]
154
+ login.procs.append(
155
+ subprocess.Popen(
156
+ [self.xvfb, login.display, "-screen", "0", f"{w}x{h}x24", "-nolisten", "tcp"],
157
+ stdout=subprocess.DEVNULL,
158
+ stderr=subprocess.DEVNULL,
159
+ )
160
+ )
161
+ time.sleep(0.8)
162
+ # Loopback only: the service's WebSocket bridge is the one way in, behind its bearer.
163
+ login.procs.append(
164
+ subprocess.Popen(
165
+ [self.x11vnc, "-display", login.display, "-localhost", "-rfbport", str(login.vnc_port), "-nopw",
166
+ "-forever", "-shared", "-quiet", "-noxdamage"],
167
+ stdout=subprocess.DEVNULL,
168
+ stderr=subprocess.DEVNULL,
169
+ )
170
+ ) # fmt: skip
171
+ time.sleep(0.8)
172
+ if any(proc.poll() is not None for proc in login.procs):
173
+ raise RuntimeError("Xvfb or x11vnc exited at start")
174
+
175
+ def _stop_display(self, login: Login) -> None:
176
+ for proc in reversed(login.procs):
177
+ proc.terminate()
178
+ for proc in login.procs:
179
+ with contextlib.suppress(subprocess.TimeoutExpired):
180
+ proc.wait(timeout=5)
181
+ login.procs.clear()
182
+ if login.profile:
183
+ shutil.rmtree(login.profile, ignore_errors=True)
184
+ login.profile = None
185
+
186
+ async def _run(self, login: Login) -> None:
187
+ try:
188
+ await self._browser(login, self)
189
+ except asyncio.CancelledError:
190
+ raise
191
+ except Exception as exc:
192
+ log.warning("login %s failed: %s", login.id[:6], type(exc).__name__)
193
+ if login.id in self._logins and login.state != "signed_in":
194
+ self._forget(login, "failed")
195
+
196
+ def proven(self, login: Login, result: dict[str, Any]) -> None:
197
+ login.result, login.result_at, login.state = result, self._clock(), "signed_in"
198
+ log.info("login %s signed in", login.id[:6])
199
+
200
+
201
+ async def run_browser(login: Login, logins: Logins) -> None:
202
+ """Uber's sign-in page in a fresh profile on the login's display; returns once signed in and proven."""
203
+ login.profile = tempfile.mkdtemp(prefix="uber-login-")
204
+
205
+ def waiting(host: str) -> None:
206
+ login.state, login.host = "waiting", host
207
+
208
+ result = await sign_in(
209
+ profile=login.profile,
210
+ layout=login.layout,
211
+ env={**os.environ, "DISPLAY": login.display},
212
+ extra_args=("--window-position=0,0",),
213
+ on_page=waiting,
214
+ )
215
+ logins.proven(login, result)
uber_login/window.py ADDED
@@ -0,0 +1,147 @@
1
+ """Uber's own sign-in page in a real browser window, until the person has signed in; then the proven session.
2
+
3
+ Used by the login service (a window on a virtual display, shown through noVNC) and by ``uber-mcp login`` (a window
4
+ on the person's own screen). Nothing here clicks, types or fills: the person does every step of the sign-in, Uber's
5
+ own checks included (``tests/test_login_safety.py`` pins that). Once Uber lands on m.uber.com, the window opens
6
+ Uber's trips site once (riders.uber.com signs in through Uber's single sign-on on first visit), reads both as the
7
+ rider with the same cookies, and returns. Only Uber's pages (and Google's or Apple's, for their sign-in buttons)
8
+ may be shown.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import asyncio
14
+ import json
15
+ import re
16
+ from collections.abc import Callable, Mapping
17
+ from typing import Any
18
+ from urllib.parse import urlsplit
19
+
20
+ from uber_mcp.session import clean_cookies
21
+ from uber_mcp.uber import ENDPOINTS, operations
22
+
23
+ LOGIN_URL = "https://m.uber.com/go/login-redirect?previousPathname=%2Fgo%2Fhome"
24
+ RIDERS_URL = "https://riders.uber.com/trips"
25
+ RIDES_LANDED = re.compile(r"https://m\.uber\.com/go/(?!login)")
26
+ RIDERS_LANDED = re.compile(r"https://riders\.uber\.com/")
27
+ # Pages the window may show; anything else is refused (images, scripts and frames load freely).
28
+ DOCUMENT_HOSTS = ("uber.com", "accounts.google.com", "appleid.apple.com")
29
+ LAYOUTS = {
30
+ "desktop": {"size": (1280, 860), "user_agent": None},
31
+ # A phone-sized window gets Uber's mobile pages only with a mobile browser identity.
32
+ "phone": {
33
+ "size": (430, 900),
34
+ "user_agent": "Mozilla/5.0 (Linux; Android 15; Pixel 9) AppleWebKit/537.36 (KHTML, like Gecko) "
35
+ "Chrome/149.0.0.0 Mobile Safari/537.36",
36
+ },
37
+ }
38
+ WATCH_EVERY = 1.5
39
+
40
+
41
+ def allowed_document(url: str) -> bool:
42
+ if url.startswith(("about:", "data:", "blob:")):
43
+ return True
44
+ parts = urlsplit(url)
45
+ host = (parts.hostname or "").lower()
46
+ return parts.scheme == "https" and any(host == h or host.endswith("." + h) for h in DOCUMENT_HOSTS)
47
+
48
+
49
+ class WindowClosed(Exception):
50
+ """The person closed the window before signing in."""
51
+
52
+
53
+ class NotProven(Exception):
54
+ """Uber's pages said signed in, but reading the account with the same cookies did not work."""
55
+
56
+
57
+ async def sign_in(
58
+ *,
59
+ profile: str,
60
+ layout: str = "desktop",
61
+ env: Mapping[str, str] | None = None,
62
+ channel: str | None = None,
63
+ extra_args: tuple[str, ...] = (),
64
+ on_page: Callable[[str], None] | None = None,
65
+ ) -> dict[str, Any]:
66
+ """Open Uber's sign-in page in a browser using ``profile`` and wait until the person has signed in.
67
+
68
+ Returns ``{account: {uuid, name}, user_agent, cookies}``. ``channel="chrome"`` uses the installed Google Chrome;
69
+ None uses the browser patchright installed. ``on_page`` hears the host of the page shown, every look.
70
+ """
71
+ from patchright.async_api import async_playwright
72
+
73
+ width, height = LAYOUTS[layout]["size"]
74
+ async with async_playwright() as p:
75
+ ctx = await p.chromium.launch_persistent_context(
76
+ profile,
77
+ channel=channel,
78
+ headless=False,
79
+ no_viewport=True,
80
+ user_agent=LAYOUTS[layout]["user_agent"],
81
+ env=dict(env) if env is not None else None,
82
+ args=[f"--window-size={width},{height}", *extra_args, "--no-first-run", "--disable-features=Translate"],
83
+ )
84
+ try:
85
+
86
+ async def guard(route: Any) -> None:
87
+ request = route.request
88
+ top = request.is_navigation_request() and request.frame.parent_frame is None
89
+ if top and not allowed_document(request.url):
90
+ await route.abort()
91
+ return
92
+ await route.continue_()
93
+
94
+ await ctx.route("**/*", guard)
95
+ page = ctx.pages[0] if ctx.pages else await ctx.new_page()
96
+ await page.goto(LOGIN_URL, wait_until="domcontentloaded", timeout=60_000)
97
+ while True:
98
+ open_pages = [pg for pg in ctx.pages if not pg.is_closed()]
99
+ if not open_pages:
100
+ raise WindowClosed()
101
+ if on_page is not None:
102
+ on_page(urlsplit(open_pages[0].url).hostname or "")
103
+ landed = next((pg for pg in open_pages if RIDES_LANDED.match(pg.url)), None)
104
+ if landed is not None:
105
+ return await _hop_and_prove(ctx, landed)
106
+ await asyncio.sleep(WATCH_EVERY)
107
+ finally:
108
+ await ctx.close()
109
+
110
+
111
+ async def _hop_and_prove(ctx: Any, page: Any) -> dict[str, Any]:
112
+ await page.goto(RIDERS_URL, wait_until="domcontentloaded", timeout=60_000)
113
+ for _ in range(40):
114
+ if RIDERS_LANDED.match(page.url):
115
+ break
116
+ await asyncio.sleep(0.5)
117
+ user = (await _graphql(ctx, "riders", "CurrentUserRidersWeb", {})).get("currentUser") or {}
118
+ status = (await _graphql(ctx, "rides", "GetStatus", {"latitude": 0, "longitude": 0})).get("status") or {}
119
+ if not user.get("uuid") or not isinstance(status, dict) or "clientStatus" not in status:
120
+ raise NotProven()
121
+ return {
122
+ "account": {"uuid": user["uuid"], "name": str(user.get("firstName") or "")},
123
+ "user_agent": await page.evaluate("() => navigator.userAgent"),
124
+ "cookies": clean_cookies(await ctx.cookies()),
125
+ }
126
+
127
+
128
+ async def _graphql(ctx: Any, surface: str, operation: str, variables: dict[str, Any]) -> dict[str, Any]:
129
+ url = ENDPOINTS[surface]
130
+ origin = "https://" + (urlsplit(url).hostname or "")
131
+ response = await ctx.request.post(
132
+ url,
133
+ data=json.dumps({"operationName": operation, "query": operations()[operation], "variables": variables}),
134
+ headers={
135
+ "content-type": "application/json",
136
+ "accept": "application/json",
137
+ "x-csrf-token": "x",
138
+ "origin": origin,
139
+ "referer": origin + "/",
140
+ }, # fmt: skip
141
+ )
142
+ try:
143
+ payload = await response.json()
144
+ except Exception:
145
+ return {}
146
+ data = payload.get("data") if isinstance(payload, dict) else None
147
+ return data if isinstance(data, dict) else {}
uber_mcp/__init__.py ADDED
@@ -0,0 +1,9 @@
1
+ """uber-mcp: unofficial MCP access to an Uber rider account: places, live fares, rides behind a confirm step."""
2
+
3
+ from importlib.metadata import PackageNotFoundError, version
4
+
5
+ try:
6
+ # The release tool bumps pyproject.toml only; read the installed distribution.
7
+ __version__ = version("uber-mcp")
8
+ except PackageNotFoundError: # pragma: no cover - source tree without an install
9
+ __version__ = "0+unknown"
uber_mcp/__main__.py ADDED
@@ -0,0 +1,10 @@
1
+ """``python -m uber_mcp``: the ``uber-mcp`` command (``cli``)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import sys
6
+
7
+ from .cli import main
8
+
9
+ if __name__ == "__main__":
10
+ sys.exit(main())