reddit-rss-mcp 1.1.0__tar.gz

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.
@@ -0,0 +1,10 @@
1
+ *.pyc
2
+ .DS_Store
3
+ .env
4
+ .venv/
5
+ __pycache__/
6
+ uv.lock
7
+ dist/
8
+ build/
9
+ *.egg-info/
10
+ .pytest_cache/
@@ -0,0 +1,30 @@
1
+ # Changelog
2
+
3
+ All notable changes to this project are documented in this file.
4
+
5
+ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
+ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
+
8
+ ## [1.1.0] - 2026-06-15
9
+
10
+ ### Added
11
+
12
+ - Transparent retry with backoff on Reddit HTTP 429 rate limits. The `Retry-After`
13
+ header is honored when present; otherwise the request uses exponential backoff
14
+ with jitter (up to 7 retries). A global minimum interval between outbound
15
+ requests reduces 429s at the source. Shared across all tools (`browse_subreddit`,
16
+ `get_post`, `search_reddit`, `fetch_json`). Closes #1.
17
+ - Unit tests for the retry/backoff and throttle layer.
18
+
19
+ ### Fixed
20
+
21
+ - `browse_subreddit` params dict type annotation (`str` was not assignable to the
22
+ inferred `int`-only dict).
23
+
24
+ ## [1.0.0] - 2026-06-01
25
+
26
+ ### Added
27
+
28
+ - Initial stdio MCP server fetching and refining Reddit content via public
29
+ RSS/Atom feeds: `browse_subreddit`, `get_post`, `search_reddit`, and a generic
30
+ `fetch_json`. No authentication required.
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Jörgen Karlsson
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,179 @@
1
+ Metadata-Version: 2.4
2
+ Name: reddit-rss-mcp
3
+ Version: 1.1.0
4
+ Summary: Local MCP server that fetches and refines Reddit (and other JSON) content for Claude, via Reddit's public RSS feeds (no auth).
5
+ Project-URL: Homepage, https://github.com/jorgen-k/reddit-mcp
6
+ Project-URL: Repository, https://github.com/jorgen-k/reddit-mcp
7
+ Project-URL: Changelog, https://github.com/jorgen-k/reddit-mcp/blob/main/CHANGELOG.md
8
+ Author: Jörgen Karlsson
9
+ License-Expression: MIT
10
+ License-File: LICENSE
11
+ Keywords: claude,mcp,model-context-protocol,reddit,rss
12
+ Classifier: License :: OSI Approved :: MIT License
13
+ Classifier: Operating System :: OS Independent
14
+ Classifier: Programming Language :: Python :: 3
15
+ Requires-Python: >=3.10
16
+ Requires-Dist: httpx>=0.27
17
+ Requires-Dist: mcp>=1.2.0
18
+ Description-Content-Type: text/markdown
19
+
20
+ # reddit-mcp
21
+
22
+ <!-- mcp-name: io.github.jorgen-k/reddit-mcp -->
23
+
24
+ A small **local MCP server** that lets Claude read Reddit (and other JSON
25
+ endpoints), refining the responses down to the fields that matter.
26
+
27
+ > **How it reaches Reddit:** Reddit gates its Data API (new apps require a
28
+ > moderation use case + approval) and blocks anonymous `.json` access, but it
29
+ > still publishes public **Atom/RSS feeds** for reading content. This server
30
+ > uses those — so it needs **no account, no app, no API key, no login**.
31
+ >
32
+ > Trade-off: RSS carries title, author, link, timestamp, and full post/comment
33
+ > text, but **not** scores, upvote ratios, or comment counts (those only live in
34
+ > the gated API).
35
+
36
+ ## Tools
37
+
38
+ | Tool | What it does |
39
+ |------|--------------|
40
+ | `browse_subreddit(subreddit, sort="hot", time_filter="day", limit=25)` | Posts from a subreddit (`hot`/`new`/`top`/`rising`/`controversial`). |
41
+ | `get_post(url, comment_limit=50)` | A post plus its comments (flat list — RSS doesn't expose the reply tree). |
42
+ | `search_reddit(query, subreddit=None, sort="relevance", time_filter="all", limit=25)` | Search Reddit, optionally scoped to one subreddit. |
43
+ | `fetch_json(url)` | Reddit URLs → the `.rss` feed (refined); other URLs → fetched as-is, falling back to the `.json` convention. |
44
+
45
+ ## Requirements
46
+
47
+ - [`uv`](https://docs.astral.sh/uv/) — handles Python + deps. `uv run server.py`
48
+ provisions an isolated env from `pyproject.toml` on first run. No other setup.
49
+
50
+ ## Install
51
+
52
+ The published package is [`reddit-rss-mcp`](https://pypi.org/project/reddit-rss-mcp/)
53
+ on PyPI (the plain `reddit-mcp` name belongs to an unrelated project). `uvx`
54
+ fetches and runs it for you, no clone needed.
55
+
56
+ **Claude Code:**
57
+
58
+ ```sh
59
+ claude mcp add reddit -s user -- uvx reddit-rss-mcp
60
+ ```
61
+
62
+ **Claude Desktop & Cowork** (add to `claude_desktop_config.json`; use the absolute
63
+ path from `which uvx`, then follow the quit/relaunch steps below):
64
+
65
+ ```json
66
+ {
67
+ "mcpServers": {
68
+ "reddit": {
69
+ "command": "/absolute/path/to/uvx",
70
+ "args": ["reddit-rss-mcp"]
71
+ }
72
+ }
73
+ }
74
+ ```
75
+
76
+ ### From GitHub (latest `main`, no clone)
77
+
78
+ To run unreleased changes, point `uvx` at the repo and the `reddit-rss-mcp` entry
79
+ point. Append `@v1.1.0` (or any tag) to pin a release instead of tracking `main`:
80
+
81
+ ```sh
82
+ claude mcp add reddit -s user -- uvx --from git+https://github.com/jorgen-k/reddit-mcp reddit-rss-mcp
83
+ ```
84
+
85
+ ### From a local clone
86
+
87
+ Prefer this if you want to edit the code. Clone it somewhere first:
88
+
89
+ ```sh
90
+ git clone https://github.com/jorgen-k/reddit-mcp.git
91
+ cd reddit-mcp
92
+ ```
93
+
94
+ #### Claude Code
95
+
96
+ ```sh
97
+ claude mcp add reddit -s user -- uv --directory "$(pwd)" run server.py
98
+ ```
99
+
100
+ Verify with `claude mcp list` (should show `reddit: ✓ Connected`). If Claude
101
+ can't find `uv`, use its absolute path (`which uv`) instead of bare `uv`.
102
+
103
+ #### Claude Desktop & Cowork
104
+
105
+ Both use the same config file:
106
+ `~/Library/Application Support/Claude/claude_desktop_config.json`. Add a
107
+ `mcpServers` entry using **absolute paths** — the app doesn't inherit your shell
108
+ `PATH`. Get the values with `which uv` and `pwd`:
109
+
110
+ ```json
111
+ {
112
+ "mcpServers": {
113
+ "reddit": {
114
+ "command": "/absolute/path/to/uv",
115
+ "args": ["--directory", "/absolute/path/to/reddit-mcp", "run", "server.py"]
116
+ }
117
+ }
118
+ }
119
+ ```
120
+
121
+ If the file already has other top-level keys, add `mcpServers` alongside them —
122
+ don't overwrite the file.
123
+
124
+ Then:
125
+
126
+ 1. **Fully quit the app** — `Cmd+Q`, not just closing the window. The running
127
+ app rewrites this file, so an edit made while it's open can be discarded.
128
+ 2. **Relaunch.** It may take a couple of restarts before the server registers.
129
+ 3. **Grant permission** when the app prompts to run the server.
130
+
131
+ The Reddit tools then appear in the app.
132
+
133
+ > **Don't use a Custom Connector** (the "add server by URL" option) for a local
134
+ > server — those are dialed from Anthropic's cloud and can't reach `localhost`,
135
+ > no matter the cert or tunnel. The config-file method above spawns the server
136
+ > locally on your machine, which is what works.
137
+
138
+ ### Updating after a code change
139
+
140
+ The server runs as a long-lived process that's spawned once when the client
141
+ connects. Editing `server.py` does **not** hot-reload it — the running process
142
+ keeps the old code until it's restarted. After any change, restart the server so
143
+ the new code takes effect:
144
+
145
+ - **Claude Code:** run `/mcp`, select `reddit`, and reconnect it (or restart
146
+ Claude Code).
147
+ - **Claude Desktop & Cowork:** fully quit the app (`Cmd+Q`) and relaunch.
148
+
149
+ ## Notes
150
+
151
+ - Read-only, public content only.
152
+ - No scores/vote counts/comment counts (RSS limitation). For those you'd need
153
+ Reddit's Data API, which now requires a moderation use case + approval.
154
+ - Be considerate with request volume — these are public feeds.
155
+
156
+ ### Rate limiting
157
+
158
+ Reddit throttles its unauthenticated RSS feeds aggressively. On an HTTP 429 the
159
+ server transparently retries with backoff (honoring the `Retry-After` header
160
+ when present, otherwise exponential backoff with jitter) and only surfaces an
161
+ error after retries are exhausted. It also keeps a small minimum gap between
162
+ outbound requests to avoid tripping the limit in the first place. All tools
163
+ share this behavior. A 429 is retried up to 7 times, sleeping roughly
164
+ 2, 4, 8, 16, 32, 64, 128 seconds (plus jitter) between attempts.
165
+
166
+ ### Search is only as good as Reddit's search
167
+
168
+ `search_reddit` uses Reddit's own search engine — RSS is just the output format,
169
+ so results are identical to the website/API search, not a separate (weaker)
170
+ index. That engine has real limits:
171
+
172
+ - **It doesn't search comment text** — only post titles and bodies (and
173
+ community names). A term that only appears in a comment won't be found.
174
+ - **Very new posts lag** — search indexing isn't instant. To catch brand-new
175
+ posts reliably, use `browse_subreddit(sort="new")` instead of search.
176
+ - **It isn't exhaustive** — low-relevance results get dropped or buried.
177
+
178
+ So a "no results" means *"Reddit's search didn't surface it,"* not a guarantee
179
+ it was never posted anywhere on the site.
@@ -0,0 +1,160 @@
1
+ # reddit-mcp
2
+
3
+ <!-- mcp-name: io.github.jorgen-k/reddit-mcp -->
4
+
5
+ A small **local MCP server** that lets Claude read Reddit (and other JSON
6
+ endpoints), refining the responses down to the fields that matter.
7
+
8
+ > **How it reaches Reddit:** Reddit gates its Data API (new apps require a
9
+ > moderation use case + approval) and blocks anonymous `.json` access, but it
10
+ > still publishes public **Atom/RSS feeds** for reading content. This server
11
+ > uses those — so it needs **no account, no app, no API key, no login**.
12
+ >
13
+ > Trade-off: RSS carries title, author, link, timestamp, and full post/comment
14
+ > text, but **not** scores, upvote ratios, or comment counts (those only live in
15
+ > the gated API).
16
+
17
+ ## Tools
18
+
19
+ | Tool | What it does |
20
+ |------|--------------|
21
+ | `browse_subreddit(subreddit, sort="hot", time_filter="day", limit=25)` | Posts from a subreddit (`hot`/`new`/`top`/`rising`/`controversial`). |
22
+ | `get_post(url, comment_limit=50)` | A post plus its comments (flat list — RSS doesn't expose the reply tree). |
23
+ | `search_reddit(query, subreddit=None, sort="relevance", time_filter="all", limit=25)` | Search Reddit, optionally scoped to one subreddit. |
24
+ | `fetch_json(url)` | Reddit URLs → the `.rss` feed (refined); other URLs → fetched as-is, falling back to the `.json` convention. |
25
+
26
+ ## Requirements
27
+
28
+ - [`uv`](https://docs.astral.sh/uv/) — handles Python + deps. `uv run server.py`
29
+ provisions an isolated env from `pyproject.toml` on first run. No other setup.
30
+
31
+ ## Install
32
+
33
+ The published package is [`reddit-rss-mcp`](https://pypi.org/project/reddit-rss-mcp/)
34
+ on PyPI (the plain `reddit-mcp` name belongs to an unrelated project). `uvx`
35
+ fetches and runs it for you, no clone needed.
36
+
37
+ **Claude Code:**
38
+
39
+ ```sh
40
+ claude mcp add reddit -s user -- uvx reddit-rss-mcp
41
+ ```
42
+
43
+ **Claude Desktop & Cowork** (add to `claude_desktop_config.json`; use the absolute
44
+ path from `which uvx`, then follow the quit/relaunch steps below):
45
+
46
+ ```json
47
+ {
48
+ "mcpServers": {
49
+ "reddit": {
50
+ "command": "/absolute/path/to/uvx",
51
+ "args": ["reddit-rss-mcp"]
52
+ }
53
+ }
54
+ }
55
+ ```
56
+
57
+ ### From GitHub (latest `main`, no clone)
58
+
59
+ To run unreleased changes, point `uvx` at the repo and the `reddit-rss-mcp` entry
60
+ point. Append `@v1.1.0` (or any tag) to pin a release instead of tracking `main`:
61
+
62
+ ```sh
63
+ claude mcp add reddit -s user -- uvx --from git+https://github.com/jorgen-k/reddit-mcp reddit-rss-mcp
64
+ ```
65
+
66
+ ### From a local clone
67
+
68
+ Prefer this if you want to edit the code. Clone it somewhere first:
69
+
70
+ ```sh
71
+ git clone https://github.com/jorgen-k/reddit-mcp.git
72
+ cd reddit-mcp
73
+ ```
74
+
75
+ #### Claude Code
76
+
77
+ ```sh
78
+ claude mcp add reddit -s user -- uv --directory "$(pwd)" run server.py
79
+ ```
80
+
81
+ Verify with `claude mcp list` (should show `reddit: ✓ Connected`). If Claude
82
+ can't find `uv`, use its absolute path (`which uv`) instead of bare `uv`.
83
+
84
+ #### Claude Desktop & Cowork
85
+
86
+ Both use the same config file:
87
+ `~/Library/Application Support/Claude/claude_desktop_config.json`. Add a
88
+ `mcpServers` entry using **absolute paths** — the app doesn't inherit your shell
89
+ `PATH`. Get the values with `which uv` and `pwd`:
90
+
91
+ ```json
92
+ {
93
+ "mcpServers": {
94
+ "reddit": {
95
+ "command": "/absolute/path/to/uv",
96
+ "args": ["--directory", "/absolute/path/to/reddit-mcp", "run", "server.py"]
97
+ }
98
+ }
99
+ }
100
+ ```
101
+
102
+ If the file already has other top-level keys, add `mcpServers` alongside them —
103
+ don't overwrite the file.
104
+
105
+ Then:
106
+
107
+ 1. **Fully quit the app** — `Cmd+Q`, not just closing the window. The running
108
+ app rewrites this file, so an edit made while it's open can be discarded.
109
+ 2. **Relaunch.** It may take a couple of restarts before the server registers.
110
+ 3. **Grant permission** when the app prompts to run the server.
111
+
112
+ The Reddit tools then appear in the app.
113
+
114
+ > **Don't use a Custom Connector** (the "add server by URL" option) for a local
115
+ > server — those are dialed from Anthropic's cloud and can't reach `localhost`,
116
+ > no matter the cert or tunnel. The config-file method above spawns the server
117
+ > locally on your machine, which is what works.
118
+
119
+ ### Updating after a code change
120
+
121
+ The server runs as a long-lived process that's spawned once when the client
122
+ connects. Editing `server.py` does **not** hot-reload it — the running process
123
+ keeps the old code until it's restarted. After any change, restart the server so
124
+ the new code takes effect:
125
+
126
+ - **Claude Code:** run `/mcp`, select `reddit`, and reconnect it (or restart
127
+ Claude Code).
128
+ - **Claude Desktop & Cowork:** fully quit the app (`Cmd+Q`) and relaunch.
129
+
130
+ ## Notes
131
+
132
+ - Read-only, public content only.
133
+ - No scores/vote counts/comment counts (RSS limitation). For those you'd need
134
+ Reddit's Data API, which now requires a moderation use case + approval.
135
+ - Be considerate with request volume — these are public feeds.
136
+
137
+ ### Rate limiting
138
+
139
+ Reddit throttles its unauthenticated RSS feeds aggressively. On an HTTP 429 the
140
+ server transparently retries with backoff (honoring the `Retry-After` header
141
+ when present, otherwise exponential backoff with jitter) and only surfaces an
142
+ error after retries are exhausted. It also keeps a small minimum gap between
143
+ outbound requests to avoid tripping the limit in the first place. All tools
144
+ share this behavior. A 429 is retried up to 7 times, sleeping roughly
145
+ 2, 4, 8, 16, 32, 64, 128 seconds (plus jitter) between attempts.
146
+
147
+ ### Search is only as good as Reddit's search
148
+
149
+ `search_reddit` uses Reddit's own search engine — RSS is just the output format,
150
+ so results are identical to the website/API search, not a separate (weaker)
151
+ index. That engine has real limits:
152
+
153
+ - **It doesn't search comment text** — only post titles and bodies (and
154
+ community names). A term that only appears in a comment won't be found.
155
+ - **Very new posts lag** — search indexing isn't instant. To catch brand-new
156
+ posts reliably, use `browse_subreddit(sort="new")` instead of search.
157
+ - **It isn't exhaustive** — low-relevance results get dropped or buried.
158
+
159
+ So a "no results" means *"Reddit's search didn't surface it,"* not a guarantee
160
+ it was never posted anywhere on the site.
@@ -0,0 +1,45 @@
1
+ [project]
2
+ name = "reddit-rss-mcp"
3
+ version = "1.1.0"
4
+ description = "Local MCP server that fetches and refines Reddit (and other JSON) content for Claude, via Reddit's public RSS feeds (no auth)."
5
+ readme = "README.md"
6
+ license = "MIT"
7
+ license-files = ["LICENSE"]
8
+ requires-python = ">=3.10"
9
+ authors = [{ name = "Jörgen Karlsson" }]
10
+ keywords = ["mcp", "reddit", "rss", "claude", "model-context-protocol"]
11
+ classifiers = [
12
+ "Programming Language :: Python :: 3",
13
+ "License :: OSI Approved :: MIT License",
14
+ "Operating System :: OS Independent",
15
+ ]
16
+ dependencies = [
17
+ "mcp>=1.2.0",
18
+ "httpx>=0.27",
19
+ ]
20
+
21
+ [project.urls]
22
+ Homepage = "https://github.com/jorgen-k/reddit-mcp"
23
+ Repository = "https://github.com/jorgen-k/reddit-mcp"
24
+ Changelog = "https://github.com/jorgen-k/reddit-mcp/blob/main/CHANGELOG.md"
25
+
26
+ [project.scripts]
27
+ reddit-rss-mcp = "server:main"
28
+
29
+ [build-system]
30
+ requires = ["hatchling"]
31
+ build-backend = "hatchling.build"
32
+
33
+ [tool.hatch.build.targets.wheel]
34
+ include = ["server.py"]
35
+
36
+ [tool.hatch.build.targets.sdist]
37
+ include = ["server.py", "README.md", "CHANGELOG.md", "LICENSE", "pyproject.toml"]
38
+
39
+ [dependency-groups]
40
+ dev = [
41
+ "pytest>=8",
42
+ ]
43
+
44
+ [tool.pytest.ini_options]
45
+ pythonpath = ["."]
@@ -0,0 +1,336 @@
1
+ #!/usr/bin/env python3
2
+ """Local MCP server that fetches and refines Reddit (and other JSON) content.
3
+
4
+ Reddit gates its Data API (new apps require a moderation use case + approval)
5
+ and blocks anonymous ``.json`` access, but it still publishes public **Atom/RSS
6
+ feeds** for reading content. These tools fetch those feeds and trim them to the
7
+ useful fields. No authentication, account, or app registration required.
8
+
9
+ Feeds carry title, author, link, timestamp, and full post/comment text — but
10
+ NOT scores, upvote ratios, or comment counts (those only exist in the gated API).
11
+
12
+ The generic ``fetch_json`` tool also works for non-Reddit services that expose
13
+ JSON via the ``.json`` convention.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import asyncio
19
+ import html
20
+ import os
21
+ import random
22
+ import re
23
+ import time
24
+ import xml.etree.ElementTree as ET
25
+ from urllib.parse import urlsplit, urlunsplit
26
+
27
+ import httpx
28
+ from mcp.server.fastmcp import FastMCP
29
+
30
+ # Feed readers send their own User-Agent; Reddit serves RSS to them normally.
31
+ USER_AGENT = os.environ.get("REDDIT_USER_AGENT", "reddit-mcp/1.0 (personal RSS reader)")
32
+ REDDIT_BASE = "https://www.reddit.com"
33
+ ATOM = {"a": "http://www.w3.org/2005/Atom"}
34
+ TIMEOUT = 30.0
35
+ TEXT_MAX = 2000
36
+
37
+ # Reddit throttles its unauthenticated RSS feeds aggressively. Retry 429s with
38
+ # backoff (honoring Retry-After) and keep a global minimum gap between requests
39
+ # rather than failing fast.
40
+ MAX_RETRIES = 7 # retries after the first attempt: ~2,4,8,16,32,64,128s backoff
41
+ BACKOFF_BASE = 2.0 # seconds; per-retry sleep is BACKOFF_BASE * 2**attempt + jitter
42
+ MIN_INTERVAL = 1.0 # minimum seconds between outbound requests
43
+
44
+ mcp = FastMCP("reddit")
45
+
46
+ # Serialize the throttle so concurrent tool calls still respect MIN_INTERVAL.
47
+ _throttle_lock = asyncio.Lock()
48
+ _last_request_at = 0.0
49
+
50
+
51
+ async def _throttle() -> None:
52
+ """Sleep so successive outbound requests are at least MIN_INTERVAL apart."""
53
+ global _last_request_at
54
+ if MIN_INTERVAL <= 0:
55
+ return
56
+ async with _throttle_lock:
57
+ wait = MIN_INTERVAL - (time.monotonic() - _last_request_at)
58
+ if wait > 0:
59
+ await asyncio.sleep(wait)
60
+ _last_request_at = time.monotonic()
61
+
62
+
63
+ def _parse_retry_after(value: str | None) -> float | None:
64
+ """Reddit sends Retry-After as integer seconds; ignore the HTTP-date form."""
65
+ if not value:
66
+ return None
67
+ try:
68
+ return max(0.0, float(value))
69
+ except ValueError:
70
+ return None
71
+
72
+
73
+ async def _get_with_retry(
74
+ client: httpx.AsyncClient, url: str, params: dict | None = None
75
+ ) -> httpx.Response:
76
+ """GET with throttling and 429 retry/backoff.
77
+
78
+ On a 429 we wait for the Retry-After header (if present) or an exponential
79
+ backoff with jitter, retrying up to MAX_RETRIES times. Only after exhausting
80
+ retries do we raise the rate-limited error, noting attempts and the last
81
+ Retry-After seen.
82
+ """
83
+ last_retry_after: float | None = None
84
+ for attempt in range(MAX_RETRIES + 1):
85
+ await _throttle()
86
+ resp = await client.get(url, params=params)
87
+ if resp.status_code != 429:
88
+ return resp
89
+ last_retry_after = _parse_retry_after(resp.headers.get("Retry-After"))
90
+ if attempt == MAX_RETRIES:
91
+ break
92
+ if last_retry_after is not None:
93
+ delay = last_retry_after
94
+ else:
95
+ delay = BACKOFF_BASE * (2**attempt) + random.random()
96
+ await asyncio.sleep(delay)
97
+
98
+ attempts = MAX_RETRIES + 1
99
+ suffix = (
100
+ f"; last Retry-After was {last_retry_after:g}s"
101
+ if last_retry_after is not None
102
+ else ""
103
+ )
104
+ raise RuntimeError(
105
+ f"Reddit rate-limited this request (HTTP 429) after {attempts} "
106
+ f"attempt(s){suffix}. Wait a bit and retry."
107
+ )
108
+
109
+
110
+ # --------------------------------------------------------------------------- #
111
+ # Fetch + parse
112
+ # --------------------------------------------------------------------------- #
113
+ async def _fetch_feed(url: str, params: dict | None = None) -> tuple[str | None, list[dict]]:
114
+ """GET an Atom feed and return (feed_title, [refined entries])."""
115
+ headers = {"User-Agent": USER_AGENT}
116
+ async with httpx.AsyncClient(
117
+ timeout=TIMEOUT, follow_redirects=True, headers=headers
118
+ ) as client:
119
+ resp = await _get_with_retry(client, url, params)
120
+ resp.raise_for_status()
121
+ try:
122
+ root = ET.fromstring(resp.content)
123
+ except ET.ParseError as e:
124
+ raise RuntimeError(f"Could not parse feed (not valid Atom XML): {e}")
125
+ feed_title = root.findtext("a:title", namespaces=ATOM)
126
+ entries = [_refine_entry(e) for e in root.findall("a:entry", ATOM)]
127
+ return feed_title, entries
128
+
129
+
130
+ # --------------------------------------------------------------------------- #
131
+ # Refinement helpers
132
+ # --------------------------------------------------------------------------- #
133
+ def _truncate(text: str | None, limit: int = TEXT_MAX) -> str | None:
134
+ if not text:
135
+ return text
136
+ if len(text) <= limit:
137
+ return text
138
+ return text[:limit] + f"… [truncated, {len(text)} chars total]"
139
+
140
+
141
+ def _html_to_text(raw: str | None, limit: int = TEXT_MAX) -> str | None:
142
+ """Convert the HTML in a feed <content> to readable plain text."""
143
+ if not raw:
144
+ return raw
145
+ s = re.sub(r"(?is)<(script|style).*?</\1>", "", raw)
146
+ s = re.sub(r"(?i)<br\s*/?>", "\n", s)
147
+ s = re.sub(r"(?i)</p>", "\n\n", s)
148
+ s = re.sub(r"(?s)<[^>]+>", "", s)
149
+ s = html.unescape(s)
150
+ s = re.sub(r"\n{3,}", "\n\n", s).strip()
151
+ return _truncate(s, limit)
152
+
153
+
154
+ def _refine_entry(entry: ET.Element) -> dict:
155
+ """Reduce an Atom entry (post or comment) to the useful fields."""
156
+ full_id = entry.findtext("a:id", default="", namespaces=ATOM)
157
+ kind, _, short_id = full_id.partition("_") # e.g. "t3_abc123"
158
+ link_el = entry.find("a:link", ATOM)
159
+ category = entry.find("a:category", ATOM)
160
+ return {
161
+ "id": short_id or full_id,
162
+ "type": {"t3": "post", "t1": "comment"}.get(kind, kind or None),
163
+ "title": entry.findtext("a:title", namespaces=ATOM),
164
+ "author": entry.findtext("a:author/a:name", namespaces=ATOM),
165
+ "subreddit": category.get("term") if category is not None else None,
166
+ "published": entry.findtext("a:published", namespaces=ATOM),
167
+ "updated": entry.findtext("a:updated", namespaces=ATOM),
168
+ "link": link_el.get("href") if link_el is not None else None,
169
+ "text": _html_to_text(entry.findtext("a:content", namespaces=ATOM)),
170
+ }
171
+
172
+
173
+ def _is_reddit_host(netloc: str) -> bool:
174
+ host = netloc.lower().split(":")[0]
175
+ return host == "reddit.com" or host.endswith(".reddit.com")
176
+
177
+
178
+ # --------------------------------------------------------------------------- #
179
+ # Tools
180
+ # --------------------------------------------------------------------------- #
181
+ @mcp.tool()
182
+ async def browse_subreddit(
183
+ subreddit: str,
184
+ sort: str = "hot",
185
+ time_filter: str = "day",
186
+ limit: int = 25,
187
+ ) -> dict:
188
+ """List posts from a subreddit (via its public RSS feed).
189
+
190
+ Note: RSS does not include scores, upvote ratios, or comment counts.
191
+
192
+ Args:
193
+ subreddit: Subreddit name, with or without the "r/" prefix.
194
+ sort: One of hot, new, top, rising, controversial.
195
+ time_filter: For top/controversial — hour, day, week, month, year, all.
196
+ limit: Number of posts (1-100).
197
+ """
198
+ sub = subreddit.strip().removeprefix("r/").strip("/")
199
+ sort = sort.strip().lower()
200
+ limit = max(1, min(int(limit), 100))
201
+ params: dict[str, str | int] = {"limit": limit}
202
+ if sort in ("top", "controversial"):
203
+ params["t"] = time_filter
204
+ title, entries = await _fetch_feed(f"{REDDIT_BASE}/r/{sub}/{sort}/.rss", params)
205
+ return {
206
+ "subreddit": sub,
207
+ "sort": sort,
208
+ "feed_title": title,
209
+ "count": len(entries),
210
+ "posts": entries,
211
+ }
212
+
213
+
214
+ @mcp.tool()
215
+ async def get_post(url: str, comment_limit: int = 50) -> dict:
216
+ """Fetch a single post and its comments (via the post's RSS feed).
217
+
218
+ Comments come back as a flat list (RSS doesn't expose the reply tree), and
219
+ scores/vote counts are not available.
220
+
221
+ Args:
222
+ url: A Reddit post URL or permalink (e.g.
223
+ https://www.reddit.com/r/python/comments/abc123/title/ or
224
+ /r/python/comments/abc123/title/).
225
+ comment_limit: Max comments to return.
226
+ """
227
+ u = url.strip()
228
+ path = u if u.startswith("/") else urlsplit(u).path
229
+ path = path.rstrip("/")
230
+ if path.endswith("/.rss"):
231
+ path = path[: -len("/.rss")]
232
+ feed_url = f"{REDDIT_BASE}{path}/.rss"
233
+
234
+ _, entries = await _fetch_feed(feed_url, {"limit": comment_limit})
235
+ posts = [e for e in entries if e["type"] == "post"]
236
+ comments = [e for e in entries if e["type"] == "comment"][:comment_limit]
237
+ if not posts and not comments:
238
+ raise RuntimeError("No content found — is this a valid post URL?")
239
+
240
+ return {
241
+ "post": posts[0] if posts else None,
242
+ "comment_count": len(comments),
243
+ "comments": comments,
244
+ }
245
+
246
+
247
+ @mcp.tool()
248
+ async def search_reddit(
249
+ query: str,
250
+ subreddit: str | None = None,
251
+ sort: str = "relevance",
252
+ time_filter: str = "all",
253
+ limit: int = 25,
254
+ ) -> dict:
255
+ """Search Reddit, optionally scoped to a subreddit (via the search RSS feed).
256
+
257
+ Note: RSS results do not include scores or comment counts.
258
+
259
+ Args:
260
+ query: Search terms.
261
+ subreddit: Restrict to this subreddit (with or without "r/").
262
+ sort: relevance, hot, top, new, comments.
263
+ time_filter: hour, day, week, month, year, all.
264
+ limit: Number of results (1-100).
265
+ """
266
+ limit = max(1, min(int(limit), 100))
267
+ params = {"q": query, "sort": sort, "t": time_filter, "limit": limit}
268
+ if subreddit:
269
+ sub = subreddit.strip().removeprefix("r/").strip("/")
270
+ params["restrict_sr"] = "1"
271
+ url = f"{REDDIT_BASE}/r/{sub}/search.rss"
272
+ else:
273
+ url = f"{REDDIT_BASE}/search.rss"
274
+ title, entries = await _fetch_feed(url, params)
275
+ return {"query": query, "count": len(entries), "results": entries}
276
+
277
+
278
+ @mcp.tool()
279
+ async def fetch_json(url: str) -> dict:
280
+ """Fetch a URL as structured data.
281
+
282
+ For Reddit URLs (any feed type — user, domain, multireddit, etc.) this
283
+ fetches the ``.rss`` feed and returns refined entries. For other services it
284
+ tries the URL as-is, falling back to the ``.json`` convention if the plain
285
+ response isn't JSON.
286
+
287
+ Args:
288
+ url: The URL to fetch.
289
+ """
290
+ parts = urlsplit(url.strip())
291
+
292
+ if _is_reddit_host(parts.netloc):
293
+ path = parts.path.rstrip("/")
294
+ if not path.endswith(".rss"):
295
+ path += "/.rss"
296
+ target = urlunsplit((parts.scheme, parts.netloc, path, parts.query, ""))
297
+ title, entries = await _fetch_feed(target)
298
+ return {"url": target, "feed_title": title, "count": len(entries), "entries": entries}
299
+
300
+ # Non-Reddit: try the URL as given, then fall back to the .json convention.
301
+ async with httpx.AsyncClient(
302
+ timeout=TIMEOUT, follow_redirects=True, headers={"User-Agent": USER_AGENT}
303
+ ) as client:
304
+ resp = await _get_with_retry(client, url.strip())
305
+ if resp.is_success:
306
+ try:
307
+ return {"url": str(resp.url), "data": resp.json()}
308
+ except ValueError:
309
+ pass # not JSON — try the .json fallback below
310
+
311
+ path = parts.path.rstrip("/")
312
+ if not path.endswith(".json"):
313
+ fallback = urlunsplit(
314
+ (parts.scheme, parts.netloc, path + ".json", parts.query, "")
315
+ )
316
+ resp = await _get_with_retry(client, fallback)
317
+
318
+ resp.raise_for_status()
319
+ try:
320
+ return {"url": str(resp.url), "data": resp.json()}
321
+ except ValueError:
322
+ return {
323
+ "url": str(resp.url),
324
+ "note": f"Response was not JSON (content-type: "
325
+ f"{resp.headers.get('content-type', '')}).",
326
+ "text": _truncate(resp.text, 5000),
327
+ }
328
+
329
+
330
+ def main() -> None:
331
+ """Run the server over stdio (for Claude Code and Cowork plugins)."""
332
+ mcp.run()
333
+
334
+
335
+ if __name__ == "__main__":
336
+ main()