trymemcp/app/client.py

179 lines
5.6 KiB
Python
Raw Normal View History

2026-11-12 03:23:16 +00:00
"""Async HTTP client for the TryMe Gaming App API."""
from __future__ import annotations
from http.cookies import SimpleCookie
from typing import Any
import httpx
from fastmcp.server.dependencies import get_http_headers
from app.config import Settings, settings
class TryMeApiError(RuntimeError):
"""Raised when the upstream TryMe API returns an error response."""
def __init__(self, status_code: int, detail: Any, path: str) -> None:
self.status_code = status_code
self.detail = detail
self.path = path
super().__init__(f"TryMe API error ({status_code}) on {path}: {detail}")
def _normalize_authorization(value: str) -> str:
"""Ensure raw tokens are prefixed with 'Bearer ' while preserving full auth headers."""
cleaned = value.strip()
if not cleaned:
return ""
if " " in cleaned:
return cleaned
return f"Bearer {cleaned}"
def _extract_cookie_from_header(cookie_header: str | None, name: str) -> str | None:
"""Extract a named cookie from an incoming HTTP Cookie header string."""
if not cookie_header:
return None
cookie = SimpleCookie()
try:
cookie.load(cookie_header)
except Exception:
return None
morsel = cookie.get(name)
return morsel.value if morsel else None
def _safe_incoming_headers() -> dict[str, str]:
"""Read incoming MCP HTTP headers if running inside an HTTP request context."""
try:
raw = get_http_headers(include_all=True) or {}
return {k.lower(): v for k, v in raw.items() if isinstance(v, str)}
except Exception:
return {}
class TryMeClient:
"""HTTP client wrapper for interacting with https://tryme.tpk.pw."""
def __init__(
self,
config: Settings | None = None,
transport: httpx.AsyncBaseTransport | None = None,
) -> None:
self.config = config or settings
self._transport = transport
def build_auth(
self,
*,
api_key: str | None = None,
authorization: str | None = None,
tryme_session: str | None = None,
) -> tuple[dict[str, str], dict[str, str]]:
"""Resolve authentication headers and cookies with precedence:
1. Explicit tool parameters
2. Incoming MCP HTTP request headers/cookies
3. Server environment variables
"""
incoming = _safe_incoming_headers()
headers: dict[str, str] = {}
cookies: dict[str, str] = {}
resolved_api_key = (
api_key
or incoming.get("x-api-key")
or self.config.tryme_api_key
)
if resolved_api_key:
headers["X-API-Key"] = resolved_api_key.strip()
resolved_auth = (
authorization
or incoming.get("authorization")
or self.config.tryme_bearer_token
)
if resolved_auth:
norm = _normalize_authorization(resolved_auth)
if norm:
headers["Authorization"] = norm
resolved_session = (
tryme_session
or _extract_cookie_from_header(incoming.get("cookie"), "tryme_session")
or self.config.tryme_session_cookie
)
if resolved_session:
cookies["tryme_session"] = resolved_session.strip()
return headers, cookies
async def request(
self,
method: str,
path: str,
*,
params: dict[str, Any] | None = None,
json_body: Any = None,
api_key: str | None = None,
authorization: str | None = None,
tryme_session: str | None = None,
follow_redirects: bool = False,
) -> Any:
"""Send an async HTTP request to the TryMe API and parse the response."""
headers, cookies = self.build_auth(
api_key=api_key,
authorization=authorization,
tryme_session=tryme_session,
)
headers.setdefault("Accept", "application/json")
cleaned_params: dict[str, Any] | None = None
if params:
cleaned_params = {k: v for k, v in params.items() if v is not None}
async with httpx.AsyncClient(
base_url=self.config.tryme_base_url,
timeout=self.config.tryme_timeout_seconds,
follow_redirects=follow_redirects,
cookies=cookies,
transport=self._transport,
) as http:
response = await http.request(
method=method.upper(),
url=path,
params=cleaned_params,
json=json_body,
headers=headers,
)
if 300 <= response.status_code < 400:
result: dict[str, Any] = {
"status_code": response.status_code,
"redirect_url": response.headers.get("location", ""),
}
set_cookie = response.headers.get("set-cookie")
if set_cookie:
result["cookies_set"] = True
return result
if response.status_code == 204 or not response.content:
return None
if response.status_code >= 400:
try:
err_payload = response.json()
detail = err_payload.get("detail", err_payload) if isinstance(err_payload, dict) else err_payload
except Exception:
detail = response.text
raise TryMeApiError(response.status_code, detail, path)
content_type = response.headers.get("content-type", "")
if "application/json" in content_type:
return response.json()
try:
return response.json()
except Exception:
return {"status_code": response.status_code, "text": response.text}