Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
66 changes: 64 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.



Expand Down Expand Up @@ -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).

---

Expand Down Expand Up @@ -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<version> (...)` | User-Agent for OAuth API requests |

CLI arguments take precedence over environment variables:

Expand All @@ -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 <https://www.reddit.com/prefs/apps>.
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
Expand Down
3 changes: 2 additions & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
@@ -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"
Expand All @@ -24,6 +24,7 @@ classifiers = [

dependencies = [
"fastmcp>=3.0.0",
"httpx>=0.27.0",
"python-dotenv>=1.1.1",
"redd>=0.2.0",
]
Expand Down
7 changes: 6 additions & 1 deletion src/reddit_mcp_server/__init__.py
Original file line number Diff line number Diff line change
@@ -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"
22 changes: 17 additions & 5 deletions src/reddit_mcp_server/adapters/inbound/mcp_cli_adapter.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 = """\
Expand All @@ -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
Expand All @@ -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,
)
Expand Down Expand Up @@ -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(
Expand All @@ -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)
Expand All @@ -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:
Expand Down
53 changes: 48 additions & 5 deletions src/reddit_mcp_server/adapters/inbound/mcp_error_mapping.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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
Expand Down
49 changes: 49 additions & 0 deletions src/reddit_mcp_server/adapters/outbound/_http_errors.py
Original file line number Diff line number Diff line change
@@ -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.*?</\1>", 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: <detail>)."
text = text.rstrip(" .")
if len(text) > limit:
text = text[:limit].rstrip() + "..."
return text
19 changes: 19 additions & 0 deletions src/reddit_mcp_server/adapters/outbound/env_config_adapter.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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."""
Expand Down
Loading
Loading