Source code for aiohttp_tiny_mcp.client.loopback

"""Optional browser and loopback callback helper for native applications."""

from __future__ import annotations

import asyncio
import webbrowser
from collections.abc import Awaitable, Callable, Sequence

from aiohttp import web
from yarl import URL

from .callback import SUCCESS_HTML, OAuthCallback
from .oauth import OAuth
from .oauth_helpers import OAuthError


[docs] class LoopbackCallback: """Run the aiohttp callback on 127.0.0.1 and optionally open a browser. Enter this context before constructing OAuth, then call ``login(auth)``. Register ``redirect_uri`` with the authorization server, or use DCR. A fixed port can be supplied for pre-registered clients. By default, the operating system selects a free port. ``success_html`` is trusted application HTML, served unchanged only after the token exchange and save. """ def __init__( self, *, port: int = 0, timeout: float = 300, open_browser: Callable[[str], Awaitable[None]] | None = None, success_html: str = SUCCESS_HTML, ) -> None: if timeout <= 0: raise ValueError("The callback timeout must be positive.") self.port = port self.timeout = timeout self.open_browser = open_browser self.success_html = success_html self.runner: web.AppRunner | None = None self.callback_uri: str | None = None self.callback: OAuthCallback | None = None @property def redirect_uri(self) -> str: if self.callback_uri is None: raise RuntimeError("Enter the LoopbackCallback context first.") return self.callback_uri async def __aenter__(self) -> LoopbackCallback: if self.runner is not None: raise RuntimeError("The callback listener is already open.") app = web.Application() app.router.add_get("/callback", self.handle) runner = web.AppRunner(app, access_log=None) await runner.setup() try: await web.TCPSite(runner, "127.0.0.1", self.port).start() except BaseException: await runner.cleanup() raise self.runner = runner port = runner.addresses[0][1] self.callback_uri = f"http://127.0.0.1:{port}/callback" return self async def __aexit__(self, *exc: object) -> None: if self.callback is not None: self.callback.cancel() if self.runner is not None: await self.runner.cleanup() self.runner = None self.callback_uri = None
[docs] async def login(self, auth: OAuth, *, scopes: Sequence[str] | None = None) -> None: """Open the browser and wait until the token exchange and save finish.""" if auth.redirect_uri != self.redirect_uri: raise ValueError("OAuth uses another callback URI.") if self.callback is not None: raise RuntimeError("A browser authorization is already in progress.") callback = OAuthCallback(auth, success_html=self.success_html) self.callback = callback try: attempt = await callback.begin(scopes=scopes) async def interact() -> None: if self.open_browser is not None: await self.open_browser(attempt.url) elif not await asyncio.to_thread(webbrowser.open, attempt.url): raise OAuthError("The browser could not be opened.") await callback.wait() await asyncio.wait_for(interact(), timeout=self.timeout) finally: callback.cancel() self.callback = None
async def handle(self, request: web.Request) -> web.Response: if request.host != URL(self.redirect_uri).authority: return OAuthCallback.response(False, "Invalid callback host.") if self.callback is None: return OAuthCallback.response(False, "No matching authorization attempt.") return await self.callback.handle(request)
__all__ = ["LoopbackCallback"]