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.
- reddit_rss_mcp-1.1.0/.gitignore +10 -0
- reddit_rss_mcp-1.1.0/CHANGELOG.md +30 -0
- reddit_rss_mcp-1.1.0/LICENSE +21 -0
- reddit_rss_mcp-1.1.0/PKG-INFO +179 -0
- reddit_rss_mcp-1.1.0/README.md +160 -0
- reddit_rss_mcp-1.1.0/pyproject.toml +45 -0
- reddit_rss_mcp-1.1.0/server.py +336 -0
|
@@ -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()
|