From 8091a08dbb461b24107c0391d3bc38156f64140f Mon Sep 17 00:00:00 2001 From: Elias Biondo Date: Sat, 24 Oct 2026 20:31:42 -0300 Subject: [PATCH] feat: add optional Reddit OAuth credentials and surface real HTTP errors Reddit now answers most unauthenticated programmatic requests with HTTP 403 ("You've been blocked by network security"), and redd reports every transport failure as HttpError(0), so users only saw "HTTP error (0). Reddit may be temporarily unavailable". - Optional REDDIT_CLIENT_ID / REDDIT_CLIENT_SECRET (and REDDIT_USER_AGENT): when set, redd's HTTP transport is replaced by an adapter that obtains an app-only token (client_credentials grant), refreshes it on expiry or 401, and sends requests to oauth.reddit.com with a bearer token. Anonymous access stays the default; setting only one variable is a usage error. - Recover the real cause behind redd's HttpError(0): HTTP status plus a short body summary, 404 -> not found, network errors, non-JSON bodies, and OAuth token failures. Tool errors now explain 401/403/429/5xx, and 403 points to the OAuth credentials. - Disable the FastMCP startup banner on the stdio transport. - Remove the emoji from the CLI description; --help crashed on Windows cp1252 consoles. - Read __version__ from package metadata (it was stuck at 0.1.0). - README: optional credentials, 403 explanation, banner note. --- README.md | 66 +++- pyproject.toml | 3 +- src/reddit_mcp_server/__init__.py | 7 +- .../adapters/inbound/mcp_cli_adapter.py | 22 +- .../adapters/inbound/mcp_error_mapping.py | 53 ++- .../adapters/outbound/_http_errors.py | 49 +++ .../adapters/outbound/env_config_adapter.py | 19 + .../adapters/outbound/redd_reddit_adapter.py | 133 ++++++- .../outbound/reddit_oauth_http_adapter.py | 200 ++++++++++ src/reddit_mcp_server/domain/exceptions.py | 5 + src/reddit_mcp_server/domain/value_objects.py | 8 + tests/test_cli_adapter.py | 60 +++ tests/test_config_adapter.py | 45 +++ tests/test_error_mapping.py | 54 ++- tests/test_reddit_oauth.py | 347 ++++++++++++++++++ uv.lock | 2 + 16 files changed, 1049 insertions(+), 24 deletions(-) create mode 100644 src/reddit_mcp_server/adapters/outbound/_http_errors.py create mode 100644 src/reddit_mcp_server/adapters/outbound/reddit_oauth_http_adapter.py create mode 100644 tests/test_cli_adapter.py create mode 100644 tests/test_reddit_oauth.py diff --git a/README.md b/README.md index b8841e2..6d121c3 100644 --- a/README.md +++ b/README.md @@ -5,7 +5,9 @@ A [Model Context Protocol](https://modelcontextprotocol.io/) (MCP) server that provides AI assistants with access to Reddit data. Built on top of -[redd](https://github.com/eliasbiondo/redd) — no API keys required. +[redd](https://github.com/eliasbiondo/redd). It works without API keys where +Reddit allows anonymous access, and can use optional +[Reddit OAuth credentials](#41-reddit-api-credentials-optional) where it doesn't. @@ -35,7 +37,10 @@ https://github.com/user-attachments/assets/af8ae52c-f9f3-4d04-80d5-4be0dfa61b0f - 👤 **User Activity** — View a user's recent posts and comments - 📝 **User Posts** — Get a user's submitted posts -No API keys, no authentication, no browser required. Just install and run. +No browser required, and API keys are optional. Reddit now blocks unauthenticated +requests from many networks (you'll see `HTTP 403 ... blocked by network +security`). If that happens to you, add +[Reddit API credentials](#41-reddit-api-credentials-optional). --- @@ -215,6 +220,9 @@ All settings can be configured via environment variables: | `REDDIT_TIMEOUT` | `10.0` | Request timeout in seconds | | `REDDIT_THROTTLE_MIN` | `1.0` | Min delay between paginated requests (seconds) | | `REDDIT_THROTTLE_MAX` | `2.0` | Max delay between paginated requests (seconds) | +| `REDDIT_CLIENT_ID` | — | Reddit app client ID; enables the OAuth API (see [4.1](#41-reddit-api-credentials-optional)) | +| `REDDIT_CLIENT_SECRET` | — | Reddit app client secret (required together with `REDDIT_CLIENT_ID`) | +| `REDDIT_USER_AGENT` | `python:reddit-no-auth-mcp-server:v (...)` | User-Agent for OAuth API requests | CLI arguments take precedence over environment variables: @@ -225,6 +233,60 @@ uv run reddit-no-auth-mcp-server \ --log-level DEBUG ``` +### 4.1. Reddit API credentials (optional) + +By default the server makes anonymous requests to `www.reddit.com`, the same +JSON endpoints a browser uses. Reddit blocks those from many networks: every tool +then fails with `HTTP 403: You've been blocked by network security`. In that case, +use Reddit's official OAuth API with app-only ("userless") credentials. No Reddit +account login is involved; the server only reads public data. + +1. Sign in to Reddit and open . +2. Click **create another app...**, choose **script**, give it a name, and enter + any redirect URI (for example `http://localhost:8080`; it is not used). +3. Copy the **client ID** (the string under the app name) and the **secret**. +4. Pass them to the server as environment variables: + +```json +{ + "mcpServers": { + "reddit": { + "command": "uvx", + "args": ["reddit-no-auth-mcp-server"], + "env": { + "REDDIT_CLIENT_ID": "your-client-id", + "REDDIT_CLIENT_SECRET": "your-client-secret" + } + } + } +} +``` + +With both variables set, the server gets an access token using the +`client_credentials` grant, refreshes it automatically, and sends all requests +to `https://oauth.reddit.com`. Leave both unset to keep anonymous access. Setting +only one of them is an error. + +Notes: + +- Reddit's [API rules](https://github.com/reddit-archive/reddit/wiki/API) + apply, including its OAuth rate limits (about 100 requests per minute per + client). Depending on Reddit's current policy, a new app may need approval + before its credentials work. +- Reddit asks API clients to send a descriptive User-Agent. Set + `REDDIT_USER_AGENT` to identify yourself, e.g. + `python:my-reddit-mcp:v1.0 (by /u/your_username)`. +- An `HTTP 401` error or "OAuth authentication failed" means the client ID or + secret is wrong. + +### 4.2. stdio and the FastMCP banner + +With the default `stdio` transport, the server turns off FastMCP's startup +banner, so the only output is JSON-RPC on stdout and logs on stderr. Some MCP +clients (notably on Windows) misbehave when the banner's box-drawing art shows up +on stderr. For the HTTP transport the banner is still shown; set +`FASTMCP_SHOW_SERVER_BANNER=false` to hide it there too. + --- ## 5. Architecture diff --git a/pyproject.toml b/pyproject.toml index 7bda883..ee5524d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,7 +1,7 @@ [project] name = "reddit-no-auth-mcp-server" version = "0.1.2" -description = "A Model Context Protocol Server for Reddit — browse, search, and read Reddit from any MCP client. No API keys required." +description = "A Model Context Protocol Server for Reddit — browse, search, and read Reddit from any MCP client, with or without Reddit API credentials." readme = "README.md" license = { text = "MIT" } requires-python = ">=3.12" @@ -24,6 +24,7 @@ classifiers = [ dependencies = [ "fastmcp>=3.0.0", + "httpx>=0.27.0", "python-dotenv>=1.1.1", "redd>=0.2.0", ] diff --git a/src/reddit_mcp_server/__init__.py b/src/reddit_mcp_server/__init__.py index 3e6ffc8..b25fd0f 100644 --- a/src/reddit_mcp_server/__init__.py +++ b/src/reddit_mcp_server/__init__.py @@ -1,3 +1,8 @@ """Reddit MCP Server""" -__version__ = "0.1.0" +from importlib.metadata import PackageNotFoundError, version + +try: + __version__ = version("reddit-no-auth-mcp-server") +except PackageNotFoundError: # pragma: no cover - running from a source tree without install + __version__ = "0.0.0+unknown" diff --git a/src/reddit_mcp_server/adapters/inbound/mcp_cli_adapter.py b/src/reddit_mcp_server/adapters/inbound/mcp_cli_adapter.py index 50cceb3..3a7e2a7 100644 --- a/src/reddit_mcp_server/adapters/inbound/mcp_cli_adapter.py +++ b/src/reddit_mcp_server/adapters/inbound/mcp_cli_adapter.py @@ -7,6 +7,7 @@ from reddit_mcp_server.adapters.inbound.mcp_server_adapter import McpServerAdapter from reddit_mcp_server.adapters.outbound.env_config_adapter import EnvConfigAdapter from reddit_mcp_server.container import Container +from reddit_mcp_server.domain.exceptions import ConfigurationError from reddit_mcp_server.ports.inbound import InboundAdapter _EPILOG = """\ @@ -20,6 +21,9 @@ REDDIT_TIMEOUT Request timeout in seconds (default: 10.0) REDDIT_THROTTLE_MIN Min delay between requests in seconds (default: 1.0) REDDIT_THROTTLE_MAX Max delay between requests in seconds (default: 2.0) + REDDIT_CLIENT_ID Reddit app client ID (optional, enables OAuth API access) + REDDIT_CLIENT_SECRET Reddit app client secret (required with REDDIT_CLIENT_ID) + REDDIT_USER_AGENT User-Agent for OAuth API requests (optional) examples: %(prog)s Start with stdio transport @@ -41,7 +45,9 @@ def _build_parser() -> argparse.ArgumentParser: """Build the CLI argument parser with rich help and grouped options.""" parser = argparse.ArgumentParser( prog="reddit-mcp-server", - description="🤖 Model Context Protocol server for Reddit data extraction", + # Keep help text ASCII-only: Windows consoles default to cp1252 and + # argparse crashes with UnicodeEncodeError on characters like emoji. + description="Model Context Protocol server for Reddit data extraction", epilog=_EPILOG, formatter_class=argparse.RawDescriptionHelpFormatter, ) @@ -91,8 +97,10 @@ def run(self) -> None: args = parser.parse_args() # Load configuration - config_adapter = EnvConfigAdapter(cli_args=args) - config = config_adapter.load() + try: + config = EnvConfigAdapter(cli_args=args).load() + except ConfigurationError as exc: + parser.error(str(exc)) # Configure logging logging.basicConfig( @@ -101,7 +109,10 @@ def run(self) -> None: ) # Build DI container - container = Container(config) + try: + container = Container(config) + except ConfigurationError as exc: + parser.error(str(exc)) # Create and run MCP server server_adapter = McpServerAdapter(container) @@ -116,7 +127,8 @@ def run(self) -> None: path=config.server.path, ) else: - mcp.run(transport="stdio") + # stdio is reserved for JSON-RPC; keep the startup banner off it. + mcp.run(transport="stdio", show_banner=False) def main() -> None: diff --git a/src/reddit_mcp_server/adapters/inbound/mcp_error_mapping.py b/src/reddit_mcp_server/adapters/inbound/mcp_error_mapping.py index 5846456..fa99c25 100644 --- a/src/reddit_mcp_server/adapters/inbound/mcp_error_mapping.py +++ b/src/reddit_mcp_server/adapters/inbound/mcp_error_mapping.py @@ -11,12 +11,39 @@ from redd import HttpError, NotFoundError, ParseError, ReddError from reddit_mcp_server.domain.exceptions import ( + AuthenticationError, ConfigurationError, + NetworkError, RedditMCPError, ) +from reddit_mcp_server.domain.exceptions import HttpError as DomainHttpError +from reddit_mcp_server.domain.exceptions import NotFoundError as DomainNotFoundError +from reddit_mcp_server.domain.exceptions import ParseError as DomainParseError logger = logging.getLogger(__name__) +_CREDENTIALS_HINT = "Check REDDIT_CLIENT_ID and REDDIT_CLIENT_SECRET." + + +def _http_error_message(status_code: int, detail: str = "") -> str: + """Build an actionable message for an HTTP error status returned by Reddit.""" + status = f"HTTP {status_code}" + (f": {detail}" if detail else "") + + if status_code == 403: + return ( + f"Reddit refused the request ({status}). Reddit blocks unauthenticated " + "requests from many networks; set REDDIT_CLIENT_ID and REDDIT_CLIENT_SECRET " + "to use Reddit's OAuth API (see the README). If credentials are already " + "configured, the subreddit, post, or user may be private, quarantined, or banned." + ) + if status_code == 401: + return f"Reddit rejected the request as unauthorized ({status}). {_CREDENTIALS_HINT}" + if status_code == 429: + return f"Reddit rate limit exceeded ({status}). Wait a minute and try again." + if status_code >= 500: + return f"Reddit is temporarily unavailable ({status}). Please try again later." + return f"Reddit returned an error ({status})." + class McpErrorMapper: """Maps domain and redd exceptions to MCP ``ToolError`` responses. @@ -38,18 +65,34 @@ def map(exception: Exception, context: str = "") -> NoReturn: """ prefix = f"[{context}] " if context else "" - if isinstance(exception, NotFoundError): + if isinstance(exception, NotFoundError | DomainNotFoundError): raise ToolError( f"{prefix}Resource not found. Please check the username, subreddit, or permalink." ) from exception - if isinstance(exception, HttpError): + if isinstance(exception, AuthenticationError): raise ToolError( - f"{prefix}HTTP error ({exception.status_code}). " - "Reddit may be temporarily unavailable. Please try again later." + f"{prefix}Reddit OAuth authentication failed. {exception}" ) from exception - if isinstance(exception, ParseError): + if isinstance(exception, DomainHttpError): + message = _http_error_message(exception.status_code, exception.detail) + raise ToolError(f"{prefix}{message}") from exception + + if isinstance(exception, NetworkError): + raise ToolError( + f"{prefix}{exception}. Check your network connection or REDDIT_PROXY." + ) from exception + + if isinstance(exception, HttpError): + if exception.status_code == 0: + raise ToolError( + f"{prefix}Request to Reddit failed: {exception}. " + "Check your network connection or REDDIT_PROXY." + ) from exception + raise ToolError(f"{prefix}{_http_error_message(exception.status_code)}") from exception + + if isinstance(exception, ParseError | DomainParseError): raise ToolError( f"{prefix}Failed to parse Reddit response. The data format may have changed." ) from exception diff --git a/src/reddit_mcp_server/adapters/outbound/_http_errors.py b/src/reddit_mcp_server/adapters/outbound/_http_errors.py new file mode 100644 index 0000000..28bedb8 --- /dev/null +++ b/src/reddit_mcp_server/adapters/outbound/_http_errors.py @@ -0,0 +1,49 @@ +"""Helpers for turning Reddit HTTP error responses into short, readable details.""" + +import html +import re + +import httpx + +_SCRIPT_STYLE_RE = re.compile(r"<(script|style)\b.*?", re.IGNORECASE | re.DOTALL) +_TAG_RE = re.compile(r"<[^>]+>") +_SENTENCE_END_RE = re.compile(r"[.!?](\s|$)") +_JSON_MESSAGE_KEYS = ("message", "reason", "explanation", "error_description", "error") + + +def describe_response(response: httpx.Response, *, limit: int = 200) -> str: + """Return a short human-readable summary of an error response body. + + Reddit answers blocked requests with a large HTML page ("You've been + blocked by network security...") and API errors with small JSON + objects (``{"message": "Forbidden", "error": 403}``). This extracts the + meaningful text from either, truncated to *limit* characters. + """ + text = "" + content_type = response.headers.get("content-type", "") + + if "json" in content_type: + try: + payload = response.json() + except ValueError: + payload = None + if isinstance(payload, dict): + for key in _JSON_MESSAGE_KEYS: + value = payload.get(key) + if isinstance(value, str) and value.strip(): + text = value.strip() + break + else: + body = _SCRIPT_STYLE_RE.sub(" ", response.text) + body = _TAG_RE.sub(" ", body) + text = " ".join(html.unescape(body).split()) + # HTML error pages carry boilerplate after the headline; keep the first sentence. + first_sentence = _SENTENCE_END_RE.search(text) + if first_sentence: + text = text[: first_sentence.end()].rstrip() + + # Callers embed the detail in their own sentence, e.g. "(HTTP 403: )." + text = text.rstrip(" .") + if len(text) > limit: + text = text[:limit].rstrip() + "..." + return text diff --git a/src/reddit_mcp_server/adapters/outbound/env_config_adapter.py b/src/reddit_mcp_server/adapters/outbound/env_config_adapter.py index b5727ec..a5ecab3 100644 --- a/src/reddit_mcp_server/adapters/outbound/env_config_adapter.py +++ b/src/reddit_mcp_server/adapters/outbound/env_config_adapter.py @@ -11,6 +11,7 @@ from dotenv import load_dotenv +from reddit_mcp_server.domain.exceptions import ConfigurationError from reddit_mcp_server.domain.value_objects import ( AppConfig, RedditConfig, @@ -46,11 +47,23 @@ def load(self) -> AppConfig: path=os.environ.get("REDDIT_PATH", "/mcp"), ) + client_id = self._env_str("REDDIT_CLIENT_ID") + client_secret = self._env_str("REDDIT_CLIENT_SECRET") + if bool(client_id) != bool(client_secret): + missing = "REDDIT_CLIENT_SECRET" if client_id else "REDDIT_CLIENT_ID" + raise ConfigurationError( + f"{missing} is not set. Reddit OAuth needs both REDDIT_CLIENT_ID and " + "REDDIT_CLIENT_SECRET; unset both to use unauthenticated access." + ) + reddit_config = RedditConfig( proxy=os.environ.get("REDDIT_PROXY"), timeout=self._resolve_float("REDDIT_TIMEOUT", 10.0), throttle_min=self._resolve_float("REDDIT_THROTTLE_MIN", 1.0), throttle_max=self._resolve_float("REDDIT_THROTTLE_MAX", 2.0), + client_id=client_id, + client_secret=client_secret, + user_agent=self._env_str("REDDIT_USER_AGENT"), ) return AppConfig(server=server_config, reddit=reddit_config) @@ -79,6 +92,12 @@ def _resolve_int(self, cli_attr: str, env_key: str, default: int) -> int: return int(cli_val) return self._env_int(env_key, default) + @staticmethod + def _env_str(key: str) -> str | None: + """Get a stripped string from an environment variable; empty means unset.""" + val = os.environ.get(key, "").strip() + return val or None + @staticmethod def _env_int(key: str, default: int) -> int: """Get an integer from environment variable.""" diff --git a/src/reddit_mcp_server/adapters/outbound/redd_reddit_adapter.py b/src/reddit_mcp_server/adapters/outbound/redd_reddit_adapter.py index d6e7ab8..c658a31 100644 --- a/src/reddit_mcp_server/adapters/outbound/redd_reddit_adapter.py +++ b/src/reddit_mcp_server/adapters/outbound/redd_reddit_adapter.py @@ -3,27 +3,105 @@ Wraps the async redd client (AsyncRedd) behind the RedditPort interface. """ +from collections.abc import Awaitable +from typing import TypeVar + +import httpx from redd import AsyncRedd +from redd import HttpError as ReddHttpError from redd.domain.enums import Category, SortOrder, TimeFilter, UserCategory from redd.domain.models import PostDetail, SearchResult, SubredditPost, UserItem +from reddit_mcp_server import __version__ +from reddit_mcp_server.adapters.outbound._http_errors import describe_response +from reddit_mcp_server.adapters.outbound.reddit_oauth_http_adapter import RedditOAuthHttpAdapter +from reddit_mcp_server.domain.exceptions import ( + ConfigurationError, + HttpError, + NetworkError, + NotFoundError, + ParseError, + RedditMCPError, +) from reddit_mcp_server.domain.value_objects import RedditConfig from reddit_mcp_server.ports.reddit import RedditPort +T = TypeVar("T") + +DEFAULT_USER_AGENT = ( + f"python:reddit-no-auth-mcp-server:v{__version__} " + "(+https://github.com/eliasbiondo/reddit-mcp-server)" +) + class ReddRedditAdapter(RedditPort): """RedditPort implementation backed by redd's AsyncRedd client. Naming convention: ``Redd`` (implementation) + ``Reddit`` (port) + ``Adapter``. + + When OAuth credentials are configured, redd's default HTTP transport is + replaced with :class:`RedditOAuthHttpAdapter`, so redd keeps building the + URLs and parsing the responses while requests go to Reddit's OAuth API. + + redd reports every transport failure as ``HttpError(0, ...)``; this + adapter recovers the real cause (HTTP status, network error, auth + failure) and raises the matching domain exception instead. """ - def __init__(self, config: RedditConfig) -> None: + def __init__( + self, + config: RedditConfig, + *, + oauth_transport: httpx.AsyncBaseTransport | None = None, + ) -> None: super().__init__() self._client = AsyncRedd( proxy=config.proxy, timeout=config.timeout, throttle=(config.throttle_min, config.throttle_max), ) + self._oauth_http: RedditOAuthHttpAdapter | None = None + self._default_http: object | None = None + + if config.oauth_enabled: + self._install_oauth(config, oauth_transport) + + @property + def uses_oauth(self) -> bool: + """Whether requests go through Reddit's OAuth API.""" + return self._oauth_http is not None + + def _install_oauth( + self, + config: RedditConfig, + transport: httpx.AsyncBaseTransport | None, + ) -> None: + # redd (<= 0.2.x) has no public hook for a custom HTTP transport, but + # AsyncRedd delegates every request to its ``_http`` attribute. + if not hasattr(self._client, "_http"): + raise ConfigurationError( + "Reddit OAuth credentials are set, but the installed redd version does " + "not expose the HTTP transport this server needs to authenticate requests." + ) + self._oauth_http = RedditOAuthHttpAdapter( + config.client_id or "", + config.client_secret or "", + user_agent=config.user_agent or DEFAULT_USER_AGENT, + proxy=config.proxy, + timeout=config.timeout, + transport=transport, + ) + self._default_http = self._client._http + self._client._http = self._oauth_http # type: ignore[assignment] + + async def _call(self, awaitable: Awaitable[T]) -> T: + try: + return await awaitable + except ReddHttpError as exc: + translated = _translate_http_error(exc) + if translated is exc.__cause__: + raise translated from None + raise translated from exc async def search( self, @@ -32,7 +110,7 @@ async def search( limit: int = 25, sort: SortOrder = SortOrder.RELEVANCE, ) -> list[SearchResult]: - return await self._client.search(query, limit=limit, sort=sort) + return await self._call(self._client.search(query, limit=limit, sort=sort)) async def search_subreddit( self, @@ -42,13 +120,15 @@ async def search_subreddit( limit: int = 25, sort: SortOrder = SortOrder.RELEVANCE, ) -> list[SearchResult]: - return await self._client.search_subreddit(subreddit, query, limit=limit, sort=sort) + return await self._call( + self._client.search_subreddit(subreddit, query, limit=limit, sort=sort) + ) async def get_post(self, permalink: str) -> PostDetail: - return await self._client.get_post(permalink) + return await self._call(self._client.get_post(permalink)) async def get_user(self, username: str, *, limit: int = 25) -> list[UserItem]: - return await self._client.get_user(username, limit=limit) + return await self._call(self._client.get_user(username, limit=limit)) async def get_subreddit_posts( self, @@ -58,8 +138,10 @@ async def get_subreddit_posts( category: Category = Category.HOT, time_filter: TimeFilter = TimeFilter.ALL, ) -> list[SubredditPost]: - return await self._client.get_subreddit_posts( - subreddit, limit=limit, category=category, time_filter=time_filter + return await self._call( + self._client.get_subreddit_posts( + subreddit, limit=limit, category=category, time_filter=time_filter + ) ) async def get_user_posts( @@ -70,9 +152,42 @@ async def get_user_posts( category: UserCategory = UserCategory.NEW, time_filter: TimeFilter = TimeFilter.ALL, ) -> list[SubredditPost]: - return await self._client.get_user_posts( - username, limit=limit, category=category, time_filter=time_filter + return await self._call( + self._client.get_user_posts( + username, limit=limit, category=category, time_filter=time_filter + ) ) async def close(self) -> None: await self._client.close() + default_close = getattr(self._default_http, "close", None) + if default_close is not None: + await default_close() + + +def _translate_http_error(exc: ReddHttpError) -> Exception: + """Map redd's catch-all ``HttpError(0, ...)`` to a precise domain exception.""" + cause = exc.__cause__ + + if isinstance(cause, RedditMCPError): + # Raised by our own OAuth adapter (e.g. AuthenticationError). + return cause + + if isinstance(cause, httpx.HTTPStatusError): + response = cause.response + if response.status_code == 404: + return NotFoundError(f"Reddit returned 404 Not Found for {exc.url}") + return HttpError(response.status_code, exc.url, describe_response(response)) + + if isinstance(cause, httpx.TransportError): + reason = str(cause) or type(cause).__name__ + return NetworkError(f"Could not reach Reddit ({reason})") + + if isinstance(cause, ValueError): + # JSON decoding failed: Reddit answered 2xx with something that is not JSON. + return ParseError("Reddit returned a response that is not valid JSON") + + if exc.status_code: + return HttpError(exc.status_code, exc.url) + + return NetworkError(f"Request to Reddit failed ({cause or exc})") diff --git a/src/reddit_mcp_server/adapters/outbound/reddit_oauth_http_adapter.py b/src/reddit_mcp_server/adapters/outbound/reddit_oauth_http_adapter.py new file mode 100644 index 0000000..e3e2ee9 --- /dev/null +++ b/src/reddit_mcp_server/adapters/outbound/reddit_oauth_http_adapter.py @@ -0,0 +1,200 @@ +"""Reddit OAuth HTTP adapter — app-only ("userless") access to Reddit's official API. + +Reddit blocks most unauthenticated programmatic requests to ``www.reddit.com``. +When OAuth app credentials are configured, this adapter replaces redd's default +HTTP transport: it obtains an application-only bearer token with the +``client_credentials`` grant and sends every request to ``oauth.reddit.com``. +""" + +import asyncio +import logging +import os +import time +from collections.abc import Callable +from typing import Any +from urllib.parse import urlsplit, urlunsplit + +import httpx + +from reddit_mcp_server.adapters.outbound._http_errors import describe_response +from reddit_mcp_server.domain.exceptions import AuthenticationError + +logger = logging.getLogger(__name__) + +TOKEN_URL = "https://www.reddit.com/api/v1/access_token" +OAUTH_HOST = "oauth.reddit.com" + +# Hosts redd builds URLs for; requests to them are routed to the OAuth API host. +_REDDIT_WEB_HOSTS = frozenset({"reddit.com", "www.reddit.com", "old.reddit.com"}) + +# Refresh the token slightly before Reddit says it expires. +_EXPIRY_MARGIN_SECONDS = 60.0 +_DEFAULT_EXPIRES_IN_SECONDS = 3600.0 + + +class RedditOAuthHttpAdapter: + """Async HTTP transport for redd that authenticates with Reddit OAuth. + + Implements the same interface as redd's ``HttpxAsyncAdapter`` + (``get`` / ``download`` / ``close``), so it can stand in for it inside + ``AsyncRedd``. + + Parameters + ---------- + client_id / client_secret: + Credentials of a Reddit app (https://www.reddit.com/prefs/apps). + user_agent: + Descriptive User-Agent, as required by Reddit's API rules. + proxy: + Optional HTTP/HTTPS/SOCKS proxy URL. + timeout: + Default request timeout in seconds. + max_retries: + Connection-level retries for the default transport. + transport: + Optional custom ``httpx`` transport (used by tests). + clock: + Monotonic clock used for token expiry (used by tests). + """ + + def __init__( + self, + client_id: str, + client_secret: str, + *, + user_agent: str, + proxy: str | None = None, + timeout: float = 10.0, + max_retries: int = 3, + transport: httpx.AsyncBaseTransport | None = None, + clock: Callable[[], float] = time.monotonic, + ) -> None: + self._auth = httpx.BasicAuth(client_id, client_secret) + self._client = httpx.AsyncClient( + transport=transport or httpx.AsyncHTTPTransport(retries=max_retries), + proxy=proxy, + follow_redirects=True, + headers={"User-Agent": user_agent}, + ) + self._timeout = timeout + self._clock = clock + self._token: str | None = None + self._expires_at = 0.0 + self._lock = asyncio.Lock() + + # ── AsyncHttpPort interface ─────────────────────────────────────────── + + async def get( + self, + url: str, + *, + params: dict[str, Any] | None = None, + timeout: float | None = None, + ) -> dict[str, Any]: + """Perform an authenticated GET against the OAuth API and return decoded JSON.""" + api_url = self.to_oauth_url(url) + effective_timeout = timeout if timeout is not None else self._timeout + + token = await self._get_token() + response = await self._authorized_get(api_url, token, params, effective_timeout) + if response.status_code == 401: + # Token expired or was revoked early: fetch a fresh one and retry once. + logger.info("Reddit rejected the access token; refreshing it and retrying") + self._invalidate(token) + token = await self._get_token() + response = await self._authorized_get(api_url, token, params, effective_timeout) + + response.raise_for_status() + logger.debug("GET %s -> %s", api_url, response.status_code) + result: dict[str, Any] = response.json() + return result + + async def download(self, url: str, *, dest: str, timeout: float | None = None) -> str: + """Stream-download a media file (media hosts need no OAuth token).""" + effective_timeout = timeout if timeout is not None else 30.0 + os.makedirs(os.path.dirname(dest) or ".", exist_ok=True) + + async with self._client.stream("GET", url, timeout=effective_timeout) as response: + response.raise_for_status() + with open(dest, "wb") as fh: + async for chunk in response.aiter_bytes(chunk_size=8192): + fh.write(chunk) + return dest + + async def close(self) -> None: + """Close the underlying ``httpx.AsyncClient``.""" + await self._client.aclose() + + # ── Helpers ─────────────────────────────────────────────────────────── + + @staticmethod + def to_oauth_url(url: str) -> str: + """Route a ``www.reddit.com`` URL to the equivalent ``oauth.reddit.com`` URL.""" + parts = urlsplit(url) + if parts.hostname in _REDDIT_WEB_HOSTS: + return urlunsplit(("https", OAUTH_HOST, parts.path, parts.query, parts.fragment)) + return url + + async def _authorized_get( + self, + url: str, + token: str, + params: dict[str, Any] | None, + timeout: float, + ) -> httpx.Response: + return await self._client.get( + url, + params=params, + headers={"Authorization": f"bearer {token}"}, + timeout=timeout, + ) + + async def _get_token(self) -> str: + async with self._lock: + if self._token is None or self._clock() >= self._expires_at: + self._token, self._expires_at = await self._fetch_token() + return self._token + + def _invalidate(self, token: str) -> None: + if self._token == token: + self._token = None + + async def _fetch_token(self) -> tuple[str, float]: + """Request an application-only access token (``client_credentials`` grant).""" + response = await self._client.post( + TOKEN_URL, + data={"grant_type": "client_credentials"}, + auth=self._auth, + timeout=self._timeout, + ) + if response.status_code in (400, 401, 403): + detail = describe_response(response) + raise AuthenticationError( + f"Reddit rejected the OAuth token request (HTTP {response.status_code}" + + (f": {detail}" if detail else "") + + "). Check REDDIT_CLIENT_ID and REDDIT_CLIENT_SECRET." + ) + response.raise_for_status() + + try: + payload = response.json() + except ValueError as exc: + raise AuthenticationError( + "Reddit returned a non-JSON response to the OAuth token request." + ) from exc + + token = payload.get("access_token") if isinstance(payload, dict) else None + if not isinstance(token, str) or not token: + error = payload.get("error") if isinstance(payload, dict) else None + raise AuthenticationError( + "Reddit did not return an access token" + + (f" ({error})" if error else "") + + ". Check REDDIT_CLIENT_ID and REDDIT_CLIENT_SECRET." + ) + + try: + expires_in = float(payload.get("expires_in", _DEFAULT_EXPIRES_IN_SECONDS)) + except (TypeError, ValueError): + expires_in = _DEFAULT_EXPIRES_IN_SECONDS + logger.info("Obtained Reddit OAuth access token (expires in %.0fs)", expires_in) + return token, self._clock() + max(expires_in - _EXPIRY_MARGIN_SECONDS, 0.0) diff --git a/src/reddit_mcp_server/domain/exceptions.py b/src/reddit_mcp_server/domain/exceptions.py index d3e5d46..b3a6735 100644 --- a/src/reddit_mcp_server/domain/exceptions.py +++ b/src/reddit_mcp_server/domain/exceptions.py @@ -17,6 +17,7 @@ class HttpError(RedditMCPError): def __init__(self, status_code: int, url: str, detail: str = ""): self.status_code = status_code self.url = url + self.detail = detail super().__init__(f"HTTP {status_code} for {url}" + (f": {detail}" if detail else "")) @@ -24,6 +25,10 @@ class NetworkError(RedditMCPError): """Network-level failure (connection, timeout).""" +class AuthenticationError(RedditMCPError): + """Reddit OAuth authentication failed (bad credentials, rejected token request).""" + + # ── Data ────────────────────────────────────────────────────────────────────── diff --git a/src/reddit_mcp_server/domain/value_objects.py b/src/reddit_mcp_server/domain/value_objects.py index b128b89..5515cf1 100644 --- a/src/reddit_mcp_server/domain/value_objects.py +++ b/src/reddit_mcp_server/domain/value_objects.py @@ -23,6 +23,14 @@ class RedditConfig: timeout: float = 10.0 throttle_min: float = 1.0 throttle_max: float = 2.0 + client_id: str | None = None + client_secret: str | None = None + user_agent: str | None = None + + @property + def oauth_enabled(self) -> bool: + """Whether Reddit OAuth app credentials are configured.""" + return bool(self.client_id and self.client_secret) @dataclass(frozen=True) diff --git a/tests/test_cli_adapter.py b/tests/test_cli_adapter.py new file mode 100644 index 0000000..eab873c --- /dev/null +++ b/tests/test_cli_adapter.py @@ -0,0 +1,60 @@ +"""Tests for the CLI entry point.""" + +import os +import sys +from unittest.mock import MagicMock, patch + +import pytest + +from reddit_mcp_server.adapters.inbound import mcp_cli_adapter +from reddit_mcp_server.adapters.inbound.mcp_cli_adapter import McpCliAdapter + + +def _run_cli(argv: list[str], env: dict[str, str]) -> MagicMock: + """Run the CLI with a fake server; return the mock FastMCP instance.""" + fake_mcp = MagicMock() + with ( + patch.object(sys, "argv", ["reddit-no-auth-mcp-server", *argv]), + patch.dict(os.environ, env, clear=True), + patch.object(mcp_cli_adapter.McpServerAdapter, "create", return_value=fake_mcp), + patch.object(mcp_cli_adapter.logging, "basicConfig"), + ): + McpCliAdapter().run() + return fake_mcp + + +class TestHelp: + def test_help_text_encodes_on_windows_default_codepage(self): + help_text = McpCliAdapter._build_parser().format_help() + + # Windows consoles default to cp1252; emoji there crash `--help`. + help_text.encode("cp1252") + assert help_text.isascii() + + def test_help_documents_oauth_variables(self): + help_text = McpCliAdapter._build_parser().format_help() + + assert "REDDIT_CLIENT_ID" in help_text + assert "REDDIT_CLIENT_SECRET" in help_text + assert "REDDIT_USER_AGENT" in help_text + + +class TestRun: + def test_stdio_disables_banner(self): + fake_mcp = _run_cli([], {}) + + fake_mcp.run.assert_called_once_with(transport="stdio", show_banner=False) + + def test_http_transport(self): + fake_mcp = _run_cli(["--transport", "streamable-http", "--port", "9001"], {}) + + fake_mcp.run.assert_called_once_with( + transport="streamable-http", host="127.0.0.1", port=9001, path="/mcp" + ) + + def test_partial_oauth_credentials_exit_with_usage_error(self, capsys): + with pytest.raises(SystemExit) as exc_info: + _run_cli([], {"REDDIT_CLIENT_ID": "abc"}) + + assert exc_info.value.code == 2 + assert "REDDIT_CLIENT_SECRET is not set" in capsys.readouterr().err diff --git a/tests/test_config_adapter.py b/tests/test_config_adapter.py index 5061e96..59547a1 100644 --- a/tests/test_config_adapter.py +++ b/tests/test_config_adapter.py @@ -4,7 +4,10 @@ import os from unittest.mock import patch +import pytest + from reddit_mcp_server.adapters.outbound.env_config_adapter import EnvConfigAdapter +from reddit_mcp_server.domain.exceptions import ConfigurationError class TestEnvConfigAdapterDefaults: @@ -28,6 +31,48 @@ def test_default_reddit_config(self): assert config.reddit.timeout == 10.0 assert config.reddit.throttle_min == 1.0 assert config.reddit.throttle_max == 2.0 + assert config.reddit.client_id is None + assert config.reddit.client_secret is None + assert config.reddit.user_agent is None + assert config.reddit.oauth_enabled is False + + +class TestEnvConfigAdapterOAuth: + def test_oauth_credentials_from_env(self): + env = { + "REDDIT_CLIENT_ID": "abc", + "REDDIT_CLIENT_SECRET": "xyz", + "REDDIT_USER_AGENT": "python:my-bot:v1 (by /u/me)", + } + with patch.dict(os.environ, env, clear=True): + config = EnvConfigAdapter().load() + + assert config.reddit.client_id == "abc" + assert config.reddit.client_secret == "xyz" + assert config.reddit.user_agent == "python:my-bot:v1 (by /u/me)" + assert config.reddit.oauth_enabled is True + + def test_blank_credentials_are_treated_as_unset(self): + env = {"REDDIT_CLIENT_ID": " ", "REDDIT_CLIENT_SECRET": ""} + with patch.dict(os.environ, env, clear=True): + config = EnvConfigAdapter().load() + + assert config.reddit.client_id is None + assert config.reddit.oauth_enabled is False + + @pytest.mark.parametrize( + ("env", "missing"), + [ + ({"REDDIT_CLIENT_ID": "abc"}, "REDDIT_CLIENT_SECRET"), + ({"REDDIT_CLIENT_SECRET": "xyz"}, "REDDIT_CLIENT_ID"), + ], + ) + def test_partial_credentials_are_rejected(self, env, missing): + with ( + patch.dict(os.environ, env, clear=True), + pytest.raises(ConfigurationError, match=f"{missing} is not set"), + ): + EnvConfigAdapter().load() class TestEnvConfigAdapterEnvVars: diff --git a/tests/test_error_mapping.py b/tests/test_error_mapping.py index e2b3d50..d74d7c8 100644 --- a/tests/test_error_mapping.py +++ b/tests/test_error_mapping.py @@ -6,9 +6,14 @@ from reddit_mcp_server.adapters.inbound.mcp_error_mapping import McpErrorMapper from reddit_mcp_server.domain.exceptions import ( + AuthenticationError, ConfigurationError, + NetworkError, RedditMCPError, ) +from reddit_mcp_server.domain.exceptions import HttpError as DomainHttpError +from reddit_mcp_server.domain.exceptions import NotFoundError as DomainNotFoundError +from reddit_mcp_server.domain.exceptions import ParseError as DomainParseError class TestMcpErrorMapper: @@ -17,9 +22,56 @@ def test_not_found_error(self): McpErrorMapper.map(NotFoundError("user not found")) def test_http_error(self): - with pytest.raises(ToolError, match=r"HTTP error.*429"): + with pytest.raises(ToolError, match=r"rate limit exceeded \(HTTP 429\)"): McpErrorMapper.map(HttpError(429, "https://reddit.com")) + def test_redd_status_zero_is_reported_as_request_failure(self): + with pytest.raises(ToolError) as exc_info: + McpErrorMapper.map(HttpError(0, "https://reddit.com", "timed out")) + message = str(exc_info.value) + assert "Request to Reddit failed" in message + assert "timed out" in message + assert "HTTP error (0)" not in message + + def test_domain_403_mentions_oauth_credentials(self): + error = DomainHttpError(403, "https://www.reddit.com/r/x/hot.json", "blocked") + with pytest.raises(ToolError) as exc_info: + McpErrorMapper.map(error, "reddit_get_subreddit_posts") + message = str(exc_info.value) + assert message.startswith("[reddit_get_subreddit_posts] Reddit refused the request") + assert "HTTP 403: blocked" in message + assert "REDDIT_CLIENT_ID" in message + assert "REDDIT_CLIENT_SECRET" in message + + @pytest.mark.parametrize( + ("status", "expected"), + [ + (401, r"unauthorized \(HTTP 401\).*REDDIT_CLIENT_ID"), + (429, r"rate limit exceeded \(HTTP 429\)"), + (503, r"temporarily unavailable \(HTTP 503\)"), + (418, r"Reddit returned an error \(HTTP 418\)"), + ], + ) + def test_domain_http_error_statuses(self, status, expected): + with pytest.raises(ToolError, match=expected): + McpErrorMapper.map(DomainHttpError(status, "https://oauth.reddit.com")) + + def test_authentication_error(self): + with pytest.raises(ToolError, match=r"OAuth authentication failed\. bad creds"): + McpErrorMapper.map(AuthenticationError("bad creds")) + + def test_network_error(self): + with pytest.raises(ToolError, match=r"Could not reach Reddit.*REDDIT_PROXY"): + McpErrorMapper.map(NetworkError("Could not reach Reddit (ConnectError)")) + + def test_domain_not_found_error(self): + with pytest.raises(ToolError, match="Resource not found"): + McpErrorMapper.map(DomainNotFoundError("404")) + + def test_domain_parse_error(self): + with pytest.raises(ToolError, match="Failed to parse"): + McpErrorMapper.map(DomainParseError("not json")) + def test_parse_error(self): with pytest.raises(ToolError, match="Failed to parse"): McpErrorMapper.map(ParseError("bad json")) diff --git a/tests/test_reddit_oauth.py b/tests/test_reddit_oauth.py new file mode 100644 index 0000000..0d6d09e --- /dev/null +++ b/tests/test_reddit_oauth.py @@ -0,0 +1,347 @@ +"""Tests for Reddit OAuth support and HTTP error translation, using mocked HTTP. + +No real Reddit credentials or network access are needed: requests are served +by ``httpx.MockTransport`` handlers that emulate Reddit's token endpoint, the +OAuth API host, and the "blocked by network security" HTML page. +""" + +import base64 +from urllib.parse import parse_qs + +import httpx +import pytest +from fastmcp.exceptions import ToolError + +from reddit_mcp_server.adapters.inbound.mcp_error_mapping import McpErrorMapper +from reddit_mcp_server.adapters.outbound.redd_reddit_adapter import ( + DEFAULT_USER_AGENT, + ReddRedditAdapter, +) +from reddit_mcp_server.adapters.outbound.reddit_oauth_http_adapter import ( + TOKEN_URL, + RedditOAuthHttpAdapter, +) +from reddit_mcp_server.domain.exceptions import ( + AuthenticationError, + HttpError, + NetworkError, + NotFoundError, + ParseError, +) +from reddit_mcp_server.domain.value_objects import RedditConfig + +CLIENT_ID = "my-client-id" +CLIENT_SECRET = "my-client-secret" + +_LISTING = { + "kind": "Listing", + "data": { + "after": None, + "children": [ + { + "kind": "t3", + "data": { + "title": "Hello from OAuth", + "author": "someone", + "permalink": "/r/Python/comments/abc/hello/", + "score": 7, + "num_comments": 2, + "created_utc": 1700000000.0, + "subreddit": "Python", + "url": "https://www.reddit.com/r/Python/comments/abc/hello/", + }, + } + ], + }, +} + +# Trimmed version of the page Reddit serves to blocked clients. +_BLOCKED_HTML = ( + "" + "

You've been blocked by network security.

" + "

If you think you've been blocked by mistake, file a ticket below and " + "we'll look into it.

" +) + + +class FakeReddit: + """Minimal emulation of Reddit's OAuth token endpoint and API host.""" + + def __init__(self, *, api_status: int = 200, token_status: int = 200) -> None: + self.api_status = api_status + self.token_status = token_status + self.requests: list[httpx.Request] = [] + self.tokens_issued = 0 + self.reject_tokens: set[str] = set() + + @property + def token_requests(self) -> list[httpx.Request]: + return [r for r in self.requests if str(r.url) == TOKEN_URL] + + @property + def api_requests(self) -> list[httpx.Request]: + return [r for r in self.requests if r.url.host == "oauth.reddit.com"] + + def handler(self, request: httpx.Request) -> httpx.Response: + self.requests.append(request) + + if str(request.url) == TOKEN_URL: + if self.token_status != 200: + return httpx.Response( + self.token_status, json={"message": "Unauthorized", "error": 401} + ) + self.tokens_issued += 1 + return httpx.Response( + 200, + json={ + "access_token": f"token-{self.tokens_issued}", + "token_type": "bearer", + "expires_in": 86400, + "scope": "*", + }, + ) + + if request.url.host == "oauth.reddit.com": + token = request.headers.get("Authorization", "").removeprefix("bearer ") + if not token or token in self.reject_tokens: + return httpx.Response(401, json={"message": "Unauthorized", "error": 401}) + if self.api_status == 403: + return httpx.Response(403, html=_BLOCKED_HTML) + if self.api_status != 200: + return httpx.Response(self.api_status, json={"message": "nope"}) + return httpx.Response(200, json=_LISTING) + + return httpx.Response(403, html=_BLOCKED_HTML) + + +def _oauth_config(**overrides) -> RedditConfig: + values = { + "client_id": CLIENT_ID, + "client_secret": CLIENT_SECRET, + "throttle_min": 0.0, + "throttle_max": 0.0, + } + values.update(overrides) + return RedditConfig(**values) + + +def _oauth_adapter(fake: FakeReddit, **config_overrides) -> ReddRedditAdapter: + return ReddRedditAdapter( + _oauth_config(**config_overrides), + oauth_transport=httpx.MockTransport(fake.handler), + ) + + +def _no_auth_adapter(handler) -> ReddRedditAdapter: + """Unauthenticated adapter whose redd HTTP client is served by *handler*.""" + adapter = ReddRedditAdapter(RedditConfig(throttle_min=0.0, throttle_max=0.0)) + adapter._client._http._client = httpx.AsyncClient( # type: ignore[attr-defined] + transport=httpx.MockTransport(handler) + ) + return adapter + + +# ── OAuth flow ──────────────────────────────────────────────────────────────── + + +class TestOAuthFlow: + async def test_requests_go_to_oauth_host_with_bearer_token(self): + fake = FakeReddit() + adapter = _oauth_adapter(fake) + assert adapter.uses_oauth + + posts = await adapter.get_subreddit_posts("Python", limit=5) + await adapter.close() + + assert [p.title for p in posts] == ["Hello from OAuth"] + + # 1) client_credentials token request with HTTP Basic client auth + (token_request,) = fake.token_requests + assert token_request.method == "POST" + expected_basic = base64.b64encode(f"{CLIENT_ID}:{CLIENT_SECRET}".encode()).decode() + assert token_request.headers["Authorization"] == f"Basic {expected_basic}" + assert parse_qs(token_request.content.decode()) == {"grant_type": ["client_credentials"]} + assert token_request.headers["User-Agent"] == DEFAULT_USER_AGENT + + # 2) API call routed to oauth.reddit.com with the bearer token + (api_request,) = fake.api_requests + assert api_request.method == "GET" + assert api_request.url.path == "/r/Python/hot.json" + assert api_request.url.params["limit"] == "5" + assert api_request.headers["Authorization"] == "bearer token-1" + assert api_request.headers["User-Agent"] == DEFAULT_USER_AGENT + + # Nothing else (e.g. www.reddit.com) was contacted. + assert len(fake.requests) == 2 + + async def test_token_is_cached_across_requests(self): + fake = FakeReddit() + adapter = _oauth_adapter(fake) + + await adapter.get_subreddit_posts("Python") + await adapter.search("python") + await adapter.close() + + assert len(fake.token_requests) == 1 + assert [r.headers["Authorization"] for r in fake.api_requests] == ["bearer token-1"] * 2 + assert fake.api_requests[1].url.path == "/search.json" + + async def test_custom_user_agent(self): + fake = FakeReddit() + adapter = _oauth_adapter(fake, user_agent="python:my-bot:v1 (by /u/me)") + + await adapter.get_subreddit_posts("Python") + await adapter.close() + + assert {r.headers["User-Agent"] for r in fake.requests} == {"python:my-bot:v1 (by /u/me)"} + + async def test_expired_token_is_refreshed(self): + fake = FakeReddit() + now = [1000.0] + http = RedditOAuthHttpAdapter( + CLIENT_ID, + CLIENT_SECRET, + user_agent="ua", + transport=httpx.MockTransport(fake.handler), + clock=lambda: now[0], + ) + + await http.get("https://www.reddit.com/r/Python/hot.json") + now[0] += 86400 # past expires_in + await http.get("https://www.reddit.com/r/Python/hot.json") + await http.close() + + assert len(fake.token_requests) == 2 + assert [r.headers["Authorization"] for r in fake.api_requests] == [ + "bearer token-1", + "bearer token-2", + ] + + async def test_rejected_token_is_refreshed_and_request_retried_once(self): + fake = FakeReddit() + fake.reject_tokens.add("token-1") + adapter = _oauth_adapter(fake) + + posts = await adapter.get_subreddit_posts("Python") + await adapter.close() + + assert len(posts) == 1 + assert [r.headers["Authorization"] for r in fake.api_requests] == [ + "bearer token-1", + "bearer token-2", + ] + + async def test_bad_credentials_raise_authentication_error(self): + fake = FakeReddit(token_status=401) + adapter = _oauth_adapter(fake) + + with pytest.raises(AuthenticationError, match=r"HTTP 401.*REDDIT_CLIENT_ID"): + await adapter.get_subreddit_posts("Python") + await adapter.close() + + assert fake.api_requests == [] + + async def test_token_response_without_access_token(self): + def handler(request: httpx.Request) -> httpx.Response: + return httpx.Response(200, json={"error": "unsupported_grant_type"}) + + http = RedditOAuthHttpAdapter( + CLIENT_ID, CLIENT_SECRET, user_agent="ua", transport=httpx.MockTransport(handler) + ) + with pytest.raises(AuthenticationError, match="unsupported_grant_type"): + await http.get("https://www.reddit.com/r/Python/hot.json") + await http.close() + + async def test_oauth_api_403_surfaces_status_and_detail(self): + adapter = _oauth_adapter(FakeReddit(api_status=403)) + + with pytest.raises(HttpError) as exc_info: + await adapter.get_subreddit_posts("Python") + await adapter.close() + + assert exc_info.value.status_code == 403 + assert "blocked by network security" in exc_info.value.detail + + @pytest.mark.parametrize( + ("url", "expected"), + [ + ( + "https://www.reddit.com/r/Python/hot.json", + "https://oauth.reddit.com/r/Python/hot.json", + ), + ("https://reddit.com/search.json?q=x", "https://oauth.reddit.com/search.json?q=x"), + ( + "https://old.reddit.com/user/spez/.json", + "https://oauth.reddit.com/user/spez/.json", + ), + ("https://i.redd.it/abc.jpg", "https://i.redd.it/abc.jpg"), + ], + ) + def test_to_oauth_url(self, url: str, expected: str): + assert RedditOAuthHttpAdapter.to_oauth_url(url) == expected + + def test_no_credentials_keeps_unauthenticated_transport(self): + adapter = ReddRedditAdapter(RedditConfig()) + assert not adapter.uses_oauth + + +# ── Error translation (unauthenticated path) ────────────────────────────────── + + +class TestErrorTranslation: + async def test_403_block_page_reports_real_status_instead_of_zero(self): + adapter = _no_auth_adapter(lambda request: httpx.Response(403, html=_BLOCKED_HTML)) + + with pytest.raises(HttpError) as exc_info: + await adapter.get_subreddit_posts("programming") + await adapter.close() + + error = exc_info.value + assert error.status_code == 403 + assert error.detail == "You've been blocked by network security" + assert error.url == "https://www.reddit.com/r/programming/hot.json" + + with pytest.raises(ToolError) as tool_error: + McpErrorMapper.map(error, "reddit_get_subreddit_posts") + message = str(tool_error.value) + assert "HTTP 403: You've been blocked by network security" in message + assert "REDDIT_CLIENT_ID" in message + assert "(0)" not in message + + async def test_404_maps_to_not_found(self): + adapter = _no_auth_adapter( + lambda request: httpx.Response(404, json={"message": "Not Found", "error": 404}) + ) + + with pytest.raises(NotFoundError): + await adapter.get_user("does-not-exist-123") + await adapter.close() + + async def test_429_keeps_status(self): + adapter = _no_auth_adapter( + lambda request: httpx.Response(429, json={"message": "Too Many Requests"}) + ) + + with pytest.raises(HttpError) as exc_info: + await adapter.search("python") + await adapter.close() + + assert exc_info.value.status_code == 429 + assert exc_info.value.detail == "Too Many Requests" + + async def test_connection_failure_maps_to_network_error(self): + def handler(request: httpx.Request) -> httpx.Response: + raise httpx.ConnectError("Name or service not known", request=request) + + adapter = _no_auth_adapter(handler) + + with pytest.raises(NetworkError, match="Could not reach Reddit"): + await adapter.search("python") + await adapter.close() + + async def test_non_json_success_maps_to_parse_error(self): + adapter = _no_auth_adapter(lambda request: httpx.Response(200, text="")) + + with pytest.raises(ParseError): + await adapter.search("python") + await adapter.close() diff --git a/uv.lock b/uv.lock index 72f5a33..fa5320f 100644 --- a/uv.lock +++ b/uv.lock @@ -1172,6 +1172,7 @@ version = "0.1.2" source = { editable = "." } dependencies = [ { name = "fastmcp" }, + { name = "httpx" }, { name = "python-dotenv" }, { name = "redd" }, ] @@ -1188,6 +1189,7 @@ dev = [ [package.metadata] requires-dist = [ { name = "fastmcp", specifier = ">=3.0.0" }, + { name = "httpx", specifier = ">=0.27.0" }, { name = "python-dotenv", specifier = ">=1.1.1" }, { name = "redd", specifier = ">=0.2.0" }, ]