redditapis-mcp 0.9.0 → 0.10.1
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.
- package/CHANGELOG.md +183 -0
- package/README.md +38 -2
- package/package.json +3 -3
- package/src/feedback.js +1 -1
- package/src/playbooks.js +156 -0
- package/src/provenance.js +124 -0
- package/src/server.js +66 -6
- package/src/tools.js +61 -1
- package/src/watch-text.js +106 -0
- package/src/watch.js +783 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,188 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.10.1 (2026-10-14)
|
|
4
|
+
|
|
5
|
+
### One bounded retry on the API's transient 503, and a clearer 429
|
|
6
|
+
|
|
7
|
+
A read refused by the API with `503 {"error":"upstream_unavailable"}` (the body says it
|
|
8
|
+
was not billed and names `retry_after`) is now retried once after that many seconds
|
|
9
|
+
(clamped 1..5) before the error reaches the model; measured on 2026-10-14, a per-read
|
|
10
|
+
refusal of about 1 in 3 showed up as failed tool calls that one retry turns into slow
|
|
11
|
+
successes. Writes are never retried, any other 503 is never retried, and the gateway
|
|
12
|
+
retries are unchanged. The wait is clamped to 5 s, so the API's 60 s `retry_after`
|
|
13
|
+
(an exhausted-egress 503) costs one 5 s wait before the error is reported, never a loop. A `429` now tells the model to lower concurrency (stay under the per-key limit the
|
|
14
|
+
message names) and wait the `retry_after`, matching the API's
|
|
15
|
+
`too_many_concurrent_reads` answer.
|
|
16
|
+
|
|
17
|
+
## 0.10.0 (2026-10-02)
|
|
18
|
+
|
|
19
|
+
### The watch compiler refuses rather than guesses
|
|
20
|
+
|
|
21
|
+
Seventeen defects were found in this compiler across three review rounds. Every
|
|
22
|
+
single one had the same signature: it consumed part of the description, emitted
|
|
23
|
+
a filter anyway, and said nothing. The worst produced a monitor whose keyword
|
|
24
|
+
was a single `"` character.
|
|
25
|
+
|
|
26
|
+
Rounds one and two were both fixed by writing better rules, and both shipped a
|
|
27
|
+
suite that pinned the sentences just fixed and could not fail on an input nobody
|
|
28
|
+
had thought of. Round two's rewrite broke nine inputs round one handled. More
|
|
29
|
+
rules was never going to end.
|
|
30
|
+
|
|
31
|
+
**So the contract is inverted.** Every character of a description must be
|
|
32
|
+
claimed by exactly one thing: a subreddit, a quoted phrase, a keyword, an
|
|
33
|
+
exclusion, a score clause, a delivery clause, or a word with no filter content.
|
|
34
|
+
Anything left over, or counted twice, and the compiler returns **no filter at
|
|
35
|
+
all** and names the words it could not place. A description that both watches
|
|
36
|
+
for and excludes the same phrase is refused too, because that monitor could
|
|
37
|
+
never deliver.
|
|
38
|
+
|
|
39
|
+
That inverts the failure mode from "a silently wrong monitor that looks fine" to
|
|
40
|
+
"a refusal that says why", which for this feature is the only safe direction:
|
|
41
|
+
every failure here is invisible, and the caller otherwise gets a monitor id, a
|
|
42
|
+
green test delivery and then silence or a firehose.
|
|
43
|
+
|
|
44
|
+
Also fixed in the same pass, all found by the change above or by the corpus:
|
|
45
|
+
quoted phrases are now taken out of the sentence **before** anything else reads
|
|
46
|
+
it, so a marker word, a subreddit or a kind word inside your own quotes is just
|
|
47
|
+
text (`for "ignore list"`, `for "except this"`, `for "send to production"` were
|
|
48
|
+
all wrecked by this); the match kind is read from the original word positions
|
|
49
|
+
rather than a string with substrings deleted, which had turned `posts and
|
|
50
|
+
comments for "post"` into a comments-only watch; a subreddit inside a keyword or
|
|
51
|
+
exclusion clause ends that clause instead of being swallowed into the term; and
|
|
52
|
+
a bare multi-word keyword is reported in `notes`, because an unquoted keyword is
|
|
53
|
+
a free-text run and absorbs whatever follows it.
|
|
54
|
+
|
|
55
|
+
**The suite that could not see any of this has been replaced, not extended.**
|
|
56
|
+
`test/watch-invariants.test.mjs` generates 28,511 sentences from pieces chosen
|
|
57
|
+
to collide (marker words inside quotes, subreddits inside quotes, markers before
|
|
58
|
+
the main clause, unicode quotes, conjunctions) and asserts seven structural
|
|
59
|
+
invariants that hold for every input, listed or not. Measured against the two
|
|
60
|
+
previous heads with the same corpus: 20,970 violations on the first, 45,072 on
|
|
61
|
+
the second, 0 now.
|
|
62
|
+
|
|
63
|
+
### Fixed before release, after the first review
|
|
64
|
+
|
|
65
|
+
The watch compiler matched every field with its own regular expression over the
|
|
66
|
+
**whole** sentence, so two clauses read the same words and one of them was
|
|
67
|
+
wrong. Four HIGH defects, one cause, each one silent:
|
|
68
|
+
|
|
69
|
+
- `for pricing except "giveaway"` put the EXCLUDED phrase into `q`, so `q` and
|
|
70
|
+
`exclude_terms[0]` were the same string and the monitor could never deliver,
|
|
71
|
+
while the caller got a monitor id, a green test delivery, and then silence.
|
|
72
|
+
- `for posts about pricing`, the most natural sentence there is, watched for the
|
|
73
|
+
literal phrase "posts about pricing".
|
|
74
|
+
- `for posts with at least 5 upvotes about pricing` dropped the keyword
|
|
75
|
+
entirely, leaving a firehose of every post over the score floor.
|
|
76
|
+
- `for "comment moderation"` read the word *comment* out of the caller's own
|
|
77
|
+
keyword and switched the watch to comments only, missing every post.
|
|
78
|
+
- A pasted `reddit.com/r/X` link was not read as a subreddit, so the watch went
|
|
79
|
+
site-wide and consumed the account's one free site-wide slot.
|
|
80
|
+
|
|
81
|
+
The sentence is now cut into clauses **once** and every field reads only its own
|
|
82
|
+
span; the match kind is derived last, from what is left after the subreddits,
|
|
83
|
+
the quoted spans and the derived terms are removed, so a keyword can never set
|
|
84
|
+
it. Patching the pairs one at a time had already produced three green suites
|
|
85
|
+
over four live defects, which is why the fix is the structure and not another
|
|
86
|
+
rule.
|
|
87
|
+
|
|
88
|
+
**And a lossy compile is no longer silent.** Every one of the above reported
|
|
89
|
+
`notes: []`. When the compiler drops or rewrites something the description
|
|
90
|
+
carries, it says so, and that is asserted per case.
|
|
91
|
+
|
|
92
|
+
Three more in the same family, all "unknown reported as fine":
|
|
93
|
+
|
|
94
|
+
- The webhook signing secret was read only to set a boolean and then thrown
|
|
95
|
+
away, while the result asserted the secret was in it. The API returns that
|
|
96
|
+
secret exactly once and the webhook list never returns it again, so a watch
|
|
97
|
+
set up this way left its owner permanently unable to verify a delivery
|
|
98
|
+
signature, and told them otherwise. It is now returned as `delivery.secret`,
|
|
99
|
+
and the sentence is tied to the value rather than to a flag about it.
|
|
100
|
+
- A test delivery whose body could not be read counted as a SUCCESS
|
|
101
|
+
(`ok !== false`). It is now tri-state, and an unreadable answer says the
|
|
102
|
+
target is unconfirmed.
|
|
103
|
+
- When the created monitor came back without an id, the re-point was skipped
|
|
104
|
+
with no note while the result still reported a registered target.
|
|
105
|
+
|
|
106
|
+
### Added
|
|
107
|
+
|
|
108
|
+
- **`reddit_set_watch`: one call where standing up a watch took three.** It compiles a plain-words
|
|
109
|
+
description into a monitor filter, reads the account's own plan capabilities, creates the monitor,
|
|
110
|
+
then registers, points and test-fires an optional `deliver_to` webhook. The compiler is a set of
|
|
111
|
+
regular expressions, not a model, so the same sentence always compiles to the same filter, and
|
|
112
|
+
everything it derived comes back in `understood` and `compiled_filter` for a person to check. It
|
|
113
|
+
reads `r/Name` subreddits, quoted phrases as keyword terms (two or more become an any-of set),
|
|
114
|
+
`comments` / `posts and comments` as the match kind, terms after `except` or `ignoring` as
|
|
115
|
+
exclusions, and `at least N upvotes` as a score floor.
|
|
116
|
+
- **The two rules that need no round trip are checked locally.** A description anchored by neither
|
|
117
|
+
a subreddit nor a keyword is refused with zero network calls, and `r/all` is dropped as Reddit's
|
|
118
|
+
site-wide listing rather than sent to earn a `subreddit_reserved` 400.
|
|
119
|
+
- **Plan awareness is a field read, not a guess.** `GET /api/reddit/monitor/list` is free and its
|
|
120
|
+
`slots` object states the account's capabilities outright (`scoped_allowed`, `comments_allowed`,
|
|
121
|
+
`sitewide_allowed`, the cadence floor and the distinct-subreddit allowance). On a plan without
|
|
122
|
+
subreddit-scoped watches, a description that names subreddits and also carries a keyword is
|
|
123
|
+
built over every subreddit instead, and the swap is reported; a description with no keyword has
|
|
124
|
+
no equivalent, so it goes as written and the API answers for itself. An unreadable `slots`
|
|
125
|
+
object rewrites nothing.
|
|
126
|
+
- **Ordered for the failure mode.** The monitor is created before the webhook, because a monitor
|
|
127
|
+
with no `webhook_ids` still delivers to every active webhook on the account, so a watch survives
|
|
128
|
+
a failed webhook registration. The reverse order would strand a webhook pointing at nothing when
|
|
129
|
+
the monitor hits a slot limit.
|
|
130
|
+
- **Three playbooks as MCP resources**, at `playbook://competitor-mention-watch`,
|
|
131
|
+
`playbook://subreddit-audit` and `playbook://pain-point-mining`. Resources rather than tools
|
|
132
|
+
because the protocol makes resources application-driven: a host reads one only when it is asked
|
|
133
|
+
for by URI, and nothing is prefetched. Each is built only from calls this package already exposes,
|
|
134
|
+
and a test checks every tool each playbook names against the live catalog, so a recipe cannot
|
|
135
|
+
quietly start pointing at something that does not exist.
|
|
136
|
+
- **`reddit_explain`: where the rows you are holding came from.** Per completed call, the tool that
|
|
137
|
+
made it, the endpoint, when the response arrived, how many seconds old that copy now is, the
|
|
138
|
+
upstream request id and the response size. It reaches no endpoint: the ledger is in memory, holds
|
|
139
|
+
the 20 most recent calls, starts empty and is scoped to one server, so one caller's endpoints and
|
|
140
|
+
request ids never reach another's.
|
|
141
|
+
- **It reports no cache age, because there is no cache.** The read path holds no response cache for
|
|
142
|
+
customer reads, so every call is served live and the age that exists is the age of this
|
|
143
|
+
session's copy of the rows, not of a stored row. The result says `response_cache: "none"` rather
|
|
144
|
+
than implying a freshness guarantee nothing provides, and it points at the published retention
|
|
145
|
+
table instead of restating a figure that would drift the day the table changes.
|
|
146
|
+
|
|
147
|
+
### Changed
|
|
148
|
+
|
|
149
|
+
- The README's monitoring note said monitors were "subreddit-scoped, posts-only" with "no
|
|
150
|
+
all-of-Reddit keyword watch, no comment monitoring yet", and that a webhook needs an active plan.
|
|
151
|
+
All three have been untrue since 2026-08-13 and the note ships in the npm tarball. It now
|
|
152
|
+
describes the free entitlement, site-wide and comment watching, and the full `slots` object.
|
|
153
|
+
- The catalog test allows a `null` path for a local tool that reaches no endpoint, and for nothing
|
|
154
|
+
else; a red test proves a non-local tool with no path, and a foreign prefix with or without a
|
|
155
|
+
local handler, are still refused.
|
|
156
|
+
- The description gate audits resource metadata under the full matcher, and resource bodies for
|
|
157
|
+
hidden text and external links. Full instruction phrasing stays OFF bodies deliberately: a
|
|
158
|
+
description is injected at connect time and nobody chose it, a resource is read only when its URI
|
|
159
|
+
is asked for, and a recipe that could not name the calls it is a recipe for would be useless.
|
|
160
|
+
- **A claim about the body gate was withdrawn, because it was measured and was false.** Its
|
|
161
|
+
"adversarial steering" check shipped with five planted defects that were its own five regular
|
|
162
|
+
expressions restated in English. An independent corpus of ten attacks, written attacks-first with
|
|
163
|
+
the patterns never consulted, is caught **0 of 10**. That number is now asserted in the test, so
|
|
164
|
+
the limit cannot rot into a capability nobody re-measured. The check is kept as a mechanical
|
|
165
|
+
tripwire for literal subversion and encoded payloads, and the control that actually fits the risk
|
|
166
|
+
is now in place: each playbook body's content hash is pinned, so no body can change without the
|
|
167
|
+
test failing and a human reading the diff.
|
|
168
|
+
|
|
169
|
+
### No tool was added for the credit balance
|
|
170
|
+
|
|
171
|
+
`reddit_account_me` already returns `credits_remaining`, `credits_used` and `total_requests`, is
|
|
172
|
+
free, is not metered and is already named in the server instructions. A second tool over the same
|
|
173
|
+
endpoint would be a second name for one job and one more entry competing for attention in the
|
|
174
|
+
catalog, so there is none.
|
|
175
|
+
|
|
176
|
+
## 0.9.1 (2026-10-02)
|
|
177
|
+
|
|
178
|
+
- Feedback reports no longer ask for the user's words: `reddit_feedback_send` `details` is three
|
|
179
|
+
bullets (What happened, Repro, Evidence), optionally after a one-line summary of the goal, and the
|
|
180
|
+
server instructions match. No quote of the conversation is collected (Connectors Directory rule
|
|
181
|
+
against extraneous conversation data).
|
|
182
|
+
- The description gate adds check (e) for conversation-data asks in descriptions and instructions,
|
|
183
|
+
and check (d) refuses external links: https on our own or example hosts only, bare hosts on real
|
|
184
|
+
TLDs, backslash authorities, emails, IP addresses and javascript:/data: schemes.
|
|
185
|
+
|
|
3
186
|
## 0.9.0 (2026-10-02)
|
|
4
187
|
|
|
5
188
|
### Fixed
|
package/README.md
CHANGED
|
@@ -100,7 +100,7 @@ Authentication is a Bearer token: the server sends `Authorization: Bearer <REDDI
|
|
|
100
100
|
|
|
101
101
|
## Tools
|
|
102
102
|
|
|
103
|
-
|
|
103
|
+
46 tools and 3 playbook resources: 30 Reddit reads, 10 monitor/webhook management tools, `reddit_set_watch` (one call that stands up a whole watch), `reddit_explain` (where the rows you are holding came from), 3 feedback tools, and 1 account tool (`reddit_account_me`, which is also where the credit balance lives). Reddit writes (posting, commenting, voting, DMs) remain a separate authenticated surface and are intentionally out of scope here -- monitor/webhook tools configure your OWN redditapis.com account (an alerting subscription), never Reddit itself, and the feedback tools send a report to the redditapis.com team, never to Reddit. Every read works with just your API key; the 6 monitor/webhook writes additionally need an active monitoring plan (see Monitoring below). The feedback tools are free and need only your key.
|
|
104
104
|
|
|
105
105
|
A few conventions across the catalog:
|
|
106
106
|
|
|
@@ -163,10 +163,13 @@ A few conventions across the catalog:
|
|
|
163
163
|
|
|
164
164
|
### Monitoring: manage your own monitors and webhooks
|
|
165
165
|
|
|
166
|
-
|
|
166
|
+
Monitors watch **named subreddits or all of Reddit**, for **posts or comments** (`kind`). Every account holds a free entitlement of one all-of-Reddit post watch at a 60s cadence; naming a subreddit, matching comments, a faster cadence and any further watch need a paid plan. Registering a webhook does **not** need a paid plan, because the free watch delivers to one exactly like a paid watch does. Reading your own list, health and deliveries never needs a plan.
|
|
167
|
+
|
|
168
|
+
`reddit_monitor_list` returns a `slots` object that states this account's capabilities outright: `used`, `total`, `tier`, `is_free`, `cadence_s` (the tier's cadence floor), `sitewide_slots`, `distinct_subreddits_used`, `distinct_subreddits_total`, `comments_allowed`, `scoped_allowed` and `sitewide_allowed`. `reddit_set_watch` reads those flags before it builds anything.
|
|
167
169
|
|
|
168
170
|
| Tool | Endpoint | What it does |
|
|
169
171
|
|---|---|---|
|
|
172
|
+
| `reddit_set_watch` | `POST /api/reddit/monitor/add` (+ webhook create/update/test) | One call instead of three: compiles a plain-words description into a monitor filter, reads this account's plan capabilities, creates the monitor, then registers, points and test-fires the `deliver_to` webhook. Quoted phrases are taken out of the sentence first, then it is cut into clauses, so each part reads only its own words: subreddits as `r/Name` **or a pasted `reddit.com/r/Name` link**, quoted phrases as keyword terms, `except`/`without`/`ignoring` as exclusions, `at least N upvotes` as a score floor, `comments` / `posts and comments` as the match kind. **It never returns a filter it cannot fully account for** (see below). Returns `understood` and `compiled_filter` so you can check it, and `notes` names anything it dropped, rewrote or read as a whole phrase. |
|
|
170
173
|
| `reddit_monitor_add` | `POST /api/reddit/monitor/add` | Create a monitor: subreddits to watch plus an optional filter (keyword, author, domain, include/exclude terms, min score, NSFW). Forward-looking only from creation (or from `baseline_item_id`). |
|
|
171
174
|
| `reddit_monitor_list` | `GET /api/reddit/monitor/list` | List every monitor on your account, plus `slots` ({used, total, tier}). |
|
|
172
175
|
| `reddit_monitor_update` | `POST /api/reddit/monitor/update` | Pause/resume (`active`), re-cadence, or replace a monitor's filter. Passing any filter field REPLACES the whole filter -- resupply everything you want kept. |
|
|
@@ -178,6 +181,38 @@ v1 monitors are **subreddit-scoped, posts-only** (no all-of-Reddit keyword watch
|
|
|
178
181
|
| `reddit_monitor_webhook_test` | `POST /api/reddit/monitor/webhook/test` | Send a one-off test delivery to confirm a webhook is wired up correctly. |
|
|
179
182
|
| `reddit_monitor_webhook_delete` | `POST /api/reddit/monitor/webhook/delete` | Permanently delete a webhook. Does not cascade-pause monitors still pointing at it. |
|
|
180
183
|
|
|
184
|
+
#### `reddit_set_watch` refuses rather than guesses
|
|
185
|
+
|
|
186
|
+
Every character of your description has to be claimed by exactly one thing: a subreddit, a quoted phrase, a keyword, an exclusion, a score clause, a delivery clause, or a word that carries no filter content. Anything left over means the compiler did not understand the whole sentence, and it returns **no filter at all** rather than a monitor built from the half it did understand.
|
|
187
|
+
|
|
188
|
+
| outcome | when | what you get |
|
|
189
|
+
|---|---|---|
|
|
190
|
+
| a filter | every word is accounted for | the monitor, plus `understood`, `compiled_filter` and `notes` |
|
|
191
|
+
| `partial_understanding` | some words could not be placed, or two parts of the filter counted the same words twice | the exact unplaced words, what *was* understood, and nothing created |
|
|
192
|
+
| `contradictory_filter` | the same phrase is both watched for and excluded, so the monitor could never deliver | both halves named, and nothing created |
|
|
193
|
+
| `no_anchor` | neither a subreddit nor a keyword | a refusal, before any request is sent |
|
|
194
|
+
|
|
195
|
+
This matters because a wrong watch is **invisible**: you get a monitor id, a green test delivery, and then either silence forever or a firehose. A refusal that names the words it could not place is recoverable in seconds; a monitor whose keyword is also its exclusion is not noticed for a week. If a description is refused, quote the exact phrase to watch for, write subreddits as `r/Name`, or build the filter directly with `reddit_monitor_add`.
|
|
196
|
+
|
|
197
|
+
### Playbooks: three recipes, served as MCP resources
|
|
198
|
+
|
|
199
|
+
Three reusable recipes ship as MCP **resources** rather than tools, because the protocol makes resources application-driven: a host shows them in a picker or a search box and reads one only when it is asked for by URI. Nothing is prefetched and nothing is injected.
|
|
200
|
+
|
|
201
|
+
| URI | What it covers |
|
|
202
|
+
|---|---|
|
|
203
|
+
| `playbook://competitor-mention-watch` | Standing up continuous coverage of competitor names and routing every match to a channel, including how to cut the false positives a brand name that is also an ordinary word produces, and how to tell a quiet monitor from a broken one. |
|
|
204
|
+
| `playbook://subreddit-audit` | Profiling one community before posting in it: size against real comment rate, the rules as written and as enforced, the moderator team, what endures versus what is working this month, and whether posts survive. |
|
|
205
|
+
| `playbook://pain-point-mining` | Finding problems in the words people actually use, from comment bodies rather than post titles, ranking them by distinct authors rather than hits, and turning a one-off sweep into a standing feed. |
|
|
206
|
+
|
|
207
|
+
Each is a short markdown document built only from calls this package already exposes, so a recipe cannot point at something unbuilt. A test checks every tool each playbook names against the live catalog.
|
|
208
|
+
|
|
209
|
+
The bodies are documentation and name the calls they are recipes for, which is the point of them. That is a deliberate difference from tool **descriptions**, which a client fetches at connect time and nobody chose: those state product facts only and a gate enforces it. A resource is read only when something asks for its URI, so it is a document you opened, not an injection. The bodies are still checked for hidden or encoded text, for any link off our own hosts, and for adversarial steering (overriding your instructions, concealment, exfiltration, or spending your credits in a loop), each with a planted-defect control.
|
|
210
|
+
|
|
211
|
+
```jsonc
|
|
212
|
+
// resources/list, then
|
|
213
|
+
{ "method": "resources/read", "params": { "uri": "playbook://subreddit-audit" } }
|
|
214
|
+
```
|
|
215
|
+
|
|
181
216
|
### Feedback: tell the team what broke, after you review the draft
|
|
182
217
|
|
|
183
218
|
Modelled on Claude Code's own feedback tool. When a call fails in a way that is not your key, credits or a rate limit, when you ask for something no tool covers, or when a result is plainly wrong, the model can **draft** a report into a local queue (`~/.redditapis/feedback-queue.json`, at most 10 drafts, override the directory with `REDDITAPIS_FEEDBACK_DIR`). Nothing is sent until you ask to review the queue and name the drafts to send. Each report carries the last failing call's endpoint, status and request id, your client name and this package's version, so the team can act on it without a follow-up. Use `reddit_feedback_get` with the returned server id to see whether it was triaged, shipped or declined. Both tools are free.
|
|
@@ -185,6 +220,7 @@ Modelled on Claude Code's own feedback tool. When a call fails in a way that is
|
|
|
185
220
|
| Tool | Endpoint | What it does |
|
|
186
221
|
|------|----------|--------------|
|
|
187
222
|
| `reddit_feedback_send` | `POST /feedback` | `action: "draft"` (default) queues a report locally and sends nothing; `"list"` shows the queue; `"send"` posts only the drafts you name; `"discard"` drops them. |
|
|
223
|
+
| `reddit_explain` | none (in-process) | Provenance for the rows already fetched in this session: per completed call, the tool, the endpoint, when the response arrived, how old that copy now is, the upstream request id and the response size. The read path holds no response cache, so the age is the age of your copy, not of a stored row. |
|
|
188
224
|
| `reddit_account_me` | `GET /account/me` | How much credit this key has left, before spending any. Free, never metered. |
|
|
189
225
|
| `reddit_feedback_get` | `GET /feedback/{id}` | Read a sent report's status (`new`, `triaged`, `shipped`, `declined`) and the team's response. |
|
|
190
226
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "redditapis-mcp",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.10.1",
|
|
4
4
|
"mcpName": "io.github.redditapis/redditapis-mcp",
|
|
5
5
|
"description": "Official MCP server for redditapis.com. Search Reddit posts, comments, communities, users and media; read subreddits, comment trees, top posts and profiles; create and manage monitors and webhooks. Native tools for Claude, Cursor and any MCP client.",
|
|
6
6
|
"repository": {
|
|
@@ -30,8 +30,8 @@
|
|
|
30
30
|
},
|
|
31
31
|
"scripts": {
|
|
32
32
|
"start": "node src/index.js",
|
|
33
|
-
"check": "node --check src/index.js && node --check src/tools.js && node --check src/feedback.js",
|
|
34
|
-
"test": "node test/tools.test.mjs && node test/description-compliance.test.mjs && node test/inline-credentials.test.mjs && node test/feedback.test.mjs && node test/index-lasterror.test.mjs && node test/scannable-without-key.test.mjs && node test/per-caller-state.test.mjs && node test/paywall.test.mjs && node test/remote-host.test.mjs",
|
|
33
|
+
"check": "node --check src/index.js && node --check src/tools.js && node --check src/feedback.js && node --check src/server.js && node --check src/watch.js && node --check src/watch-text.js && node --check src/provenance.js && node --check src/playbooks.js",
|
|
34
|
+
"test": "node test/tools.test.mjs && node test/watch.test.mjs && node test/watch-invariants.test.mjs && node test/provenance.test.mjs && node test/playbooks.test.mjs && node test/description-compliance.test.mjs && node test/inline-credentials.test.mjs && node test/feedback.test.mjs && node test/index-lasterror.test.mjs && node test/scannable-without-key.test.mjs && node test/per-caller-state.test.mjs && node test/paywall.test.mjs && node test/remote-host.test.mjs",
|
|
35
35
|
"prepublishOnly": "node scripts/prepublish-tenant-check.mjs",
|
|
36
36
|
"postpublish": "node scripts/registry-drift.mjs --publish",
|
|
37
37
|
"check:registry-drift": "node scripts/registry-drift.mjs"
|
package/src/feedback.js
CHANGED
|
@@ -224,7 +224,7 @@ export function createFeedbackHandler({ callEndpoint, version, getClientInfo, ge
|
|
|
224
224
|
const title = String(args.title ?? "").trim();
|
|
225
225
|
if (!title || title.length > MAX.title) return text(`title is required for a draft, at most ${MAX.title} characters.`, true);
|
|
226
226
|
const details = String(args.details ?? "").trim();
|
|
227
|
-
if (!details || details.length > MAX.details) return text(`details is required for a draft, at most ${MAX.details} characters. Use the
|
|
227
|
+
if (!details || details.length > MAX.details) return text(`details is required for a draft, at most ${MAX.details} characters. Use the three labelled bullets: What happened, Repro, Evidence.`, true);
|
|
228
228
|
const area = args.area ? String(args.area).trim().slice(0, MAX.area) : undefined;
|
|
229
229
|
|
|
230
230
|
const client = clientString(getClientInfo?.(), version);
|
package/src/playbooks.js
ADDED
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
// src/playbooks.js, three reusable recipes served as MCP RESOURCES.
|
|
2
|
+
//
|
|
3
|
+
// WHY RESOURCES AND NOT TOOLS. The MCP specification says resources are
|
|
4
|
+
// "application-driven, with host applications determining how to incorporate
|
|
5
|
+
// context": a host shows them in a picker, a search box, or includes them at
|
|
6
|
+
// its own discretion. That is exactly the right primitive for a recipe. A
|
|
7
|
+
// recipe turned into a tool would be a tool whose job is to tell the model what
|
|
8
|
+
// to do, which is both the wrong shape and the thing the connector directory's
|
|
9
|
+
// compliance requirement asks tool descriptions not to be.
|
|
10
|
+
//
|
|
11
|
+
// WHERE THE COMPLIANCE LINE SITS, stated plainly because the two halves of this
|
|
12
|
+
// file are governed by different rules:
|
|
13
|
+
//
|
|
14
|
+
// RESOURCE METADATA (name, title, description, mimeType) is catalog metadata,
|
|
15
|
+
// the same class as a tool description, and it is audited by
|
|
16
|
+
// test/description-compliance.test.mjs under the same matcher. It states what
|
|
17
|
+
// the recipe is about and nothing more.
|
|
18
|
+
//
|
|
19
|
+
// RESOURCE CONTENTS are the document itself, fetched only when a host or a
|
|
20
|
+
// person asks for that exact URI by name. A recipe that could not name the
|
|
21
|
+
// calls it is a recipe for would be useless, so the contents do name tools.
|
|
22
|
+
// They carry no hidden or encoded text and no link off our own hosts, and the
|
|
23
|
+
// compliance test checks both of those over the contents too.
|
|
24
|
+
//
|
|
25
|
+
// NOTHING HERE IS PREFETCHED. A resource is read only on an explicit
|
|
26
|
+
// resources/read for its URI.
|
|
27
|
+
//
|
|
28
|
+
// EVERY RECIPE USES CALLS THAT ALREADY SHIP. No playbook depends on a capability
|
|
29
|
+
// this package does not already expose, so a recipe cannot go stale by pointing
|
|
30
|
+
// at something unbuilt.
|
|
31
|
+
|
|
32
|
+
export const PLAYBOOK_SCHEME = "playbook";
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* The three playbooks. `description` is catalog metadata and is held to the
|
|
36
|
+
* same standard as a tool description; `body` is the document.
|
|
37
|
+
*/
|
|
38
|
+
export const PLAYBOOKS = [
|
|
39
|
+
{
|
|
40
|
+
slug: "competitor-mention-watch",
|
|
41
|
+
name: "competitor_mention_watch",
|
|
42
|
+
title: "Competitor mention watch",
|
|
43
|
+
description:
|
|
44
|
+
"A recipe for standing up a continuous watch on competitor names across Reddit and routing every match to a channel, written against the monitor and webhook calls this server already exposes.",
|
|
45
|
+
body: `# Competitor mention watch
|
|
46
|
+
|
|
47
|
+
Continuous coverage of what Reddit says about a set of competitor names, delivered to a channel instead of read by hand.
|
|
48
|
+
|
|
49
|
+
## What this produces
|
|
50
|
+
|
|
51
|
+
One monitor per competitor name, each delivering to one webhook, so a match arrives with the post, the subreddit, the author and the permalink already attached.
|
|
52
|
+
|
|
53
|
+
## Steps
|
|
54
|
+
|
|
55
|
+
1. **Fix the name list.** One monitor per name beats one monitor with every name in it: deliveries stay attributable, and a noisy name can be paused on its own with \`reddit_monitor_update\` and \`active: false\`.
|
|
56
|
+
2. **Register the destination once.** \`reddit_monitor_webhook_create\` with the channel's incoming-webhook URL. A \`hooks.slack.com\` or \`discord.com/api/webhooks\` URL has its payload shape inferred from the host. The signing secret is returned once and never again, so store it with the webhook id.
|
|
57
|
+
3. **Create each watch.** \`reddit_set_watch\` with a sentence naming the competitor in quotes, and \`deliver_to\` set to the same URL. It compiles the sentence, reads the plan's capabilities, creates the monitor, registers and points the delivery target, and test-fires it in one call.
|
|
58
|
+
4. **Cut the false positives.** A brand name that is also an ordinary word produces noise. Two controls, both on \`reddit_monitor_update\`: \`exclude_terms\` drops matches containing a term, and \`search_in: ["title", "body"]\` stops a term matching only inside a URL slug. On a plan that carries it, \`min_relevance\` scores each match against the monitor's own keywords and records what it withheld rather than discarding it silently.
|
|
59
|
+
5. **Confirm it is actually watching.** \`reddit_monitor_health\` for each monitor. Read \`stream_liveness\` first: a feed that is never polled writes nothing to the poll log and looks identical to a quiet subreddit. Then \`coverage_24h\`. A status of \`unknown\` or \`partial\` means the question went unanswered, not that nothing was missed.
|
|
60
|
+
6. **See what landed.** \`reddit_monitor_deliveries\` returns the actual items sent, each with a relevance score, a sentiment reading and an intent tag.
|
|
61
|
+
|
|
62
|
+
## Reading a quiet monitor
|
|
63
|
+
|
|
64
|
+
A delivered count far below a matched count is usually the freshness gate, not a fault: \`suppressed_breakdown\` in monitor health splits \`ceiling\` (the daily cap) from \`stale\` (the item was already old when first seen). A \`stale\` withhold cannot be recovered by retrying.
|
|
65
|
+
|
|
66
|
+
## Backfill
|
|
67
|
+
|
|
68
|
+
A monitor is forward-looking from the moment it is created. For what was said before that, \`reddit_search\` with \`sort: "relevance"\` and a \`t\` window, and \`reddit_deep_comment_search\` for the comment bodies rather than their parent posts.
|
|
69
|
+
`,
|
|
70
|
+
},
|
|
71
|
+
{
|
|
72
|
+
slug: "subreddit-audit",
|
|
73
|
+
name: "subreddit_audit",
|
|
74
|
+
title: "Subreddit audit",
|
|
75
|
+
description:
|
|
76
|
+
"A recipe for profiling one subreddit before posting in it: size and activity, the rules as written, the moderator team, what the top posts look like and how fast the comment feed moves.",
|
|
77
|
+
body: `# Subreddit audit
|
|
78
|
+
|
|
79
|
+
A profile of one community, built before anything is posted into it, from the calls this server already exposes.
|
|
80
|
+
|
|
81
|
+
## Steps
|
|
82
|
+
|
|
83
|
+
1. **Size and shape.** \`reddit_subreddit_about\` returns subscribers, active users, creation date, type and the NSFW flag. Subscriber count alone says little: a community of 800,000 with a slow comment feed behaves nothing like one of 40,000 with a fast one.
|
|
84
|
+
2. **The rules as written.** \`reddit_subreddit_rules\` returns each rule with what it applies to (posts, comments or all) plus Reddit's site-wide rules. Most self-promotion bans live here in the subreddit's own words.
|
|
85
|
+
3. **The rules as enforced.** \`reddit_subreddit_wiki\` with \`page: "index"\`, and where it exists \`page: "rules"\`. A wiki commonly carries the longer version and the exceptions the short rule list leaves out.
|
|
86
|
+
4. **Who enforces them.** \`reddit_subreddit_moderators\` returns the team with each moderator's permissions and when they joined. A team of one, or a team whose newest member joined years ago, is a different moderation posture from an active rota.
|
|
87
|
+
5. **What does well.** \`reddit_subreddit_top\` with \`t: "month"\` and again with \`t: "year"\`. The month view is the current format; the year view is what endures.
|
|
88
|
+
6. **What is being posted now.** \`reddit_subreddit_posts\` with \`sort: "new"\` for the unfiltered intake, and \`sort: "rising"\` for what the community is picking up this hour.
|
|
89
|
+
7. **How fast the room moves.** \`reddit_subreddit_comments\` streams the newest comments across the whole subreddit. Two calls a few minutes apart give the real comment rate, which decides whether a post has hours of visibility or minutes.
|
|
90
|
+
8. **Whether posts survive.** \`reddit_post_visibility\` on a handful of the newest posts. A removed post still returns when fetched by id, so the post alone does not answer this. A verdict of \`undecidable\` is a real answer.
|
|
91
|
+
|
|
92
|
+
## Reading the result
|
|
93
|
+
|
|
94
|
+
A high subscriber count with a slow comment feed and a strict rule list is a read-only community. A moderate count with a fast feed and a short rule list is where a question gets answered.
|
|
95
|
+
|
|
96
|
+
## Related communities
|
|
97
|
+
|
|
98
|
+
\`reddit_search_communities\` on the same topic finds the smaller rooms where the same conversation happens with less competition.
|
|
99
|
+
`,
|
|
100
|
+
},
|
|
101
|
+
{
|
|
102
|
+
slug: "pain-point-mining",
|
|
103
|
+
name: "pain_point_mining",
|
|
104
|
+
title: "Pain-point mining",
|
|
105
|
+
description:
|
|
106
|
+
"A recipe for finding the problems people describe in their own words on Reddit, ranking them by how often they recur, and identifying who keeps raising them.",
|
|
107
|
+
body: `# Pain-point mining
|
|
108
|
+
|
|
109
|
+
The problems a market describes in its own words, taken from comment bodies rather than post titles.
|
|
110
|
+
|
|
111
|
+
## Why comments
|
|
112
|
+
|
|
113
|
+
A post title is written to attract attention. A comment is written to answer somebody. The sentence that names a real problem is almost always in a comment, which is why this recipe leans on comment search rather than post search.
|
|
114
|
+
|
|
115
|
+
## Steps
|
|
116
|
+
|
|
117
|
+
1. **Start from complaint-shaped language, not product names.** Phrases such as "I gave up on", "the worst part of", "is there anything that", "I still do this manually" find problems; a product name finds opinions about a product.
|
|
118
|
+
2. **Read the comments themselves.** \`reddit_deep_comment_search\` returns the matching comment bodies with their score, author and a comment-deep permalink. \`reddit_search_comments\` returns only the parent posts, which loses the sentence. Set \`limit\` to how many parent posts to expand, and \`sort: "relevance"\` with a \`t\` window so old high-score threads do not crowd out current ones.
|
|
119
|
+
3. **Find who keeps saying it.** The same call with \`group_by: "author"\` returns the distinct people ranked by how many of their comments matched, with the subreddits they matched in and their top comment. A problem three hundred people mention once is a different finding from one thirty people mention repeatedly.
|
|
120
|
+
4. **Scope it to a community when the general search is too noisy.** \`reddit_search\` with \`subreddit\` set narrows the matching set itself, which a sort change cannot do.
|
|
121
|
+
5. **Read the whole thread for the best hits.** \`reddit_post_comments\` with the permalink returns the post and its comment tree, which is where the workaround people settled on usually sits.
|
|
122
|
+
6. **Check the person is real.** \`reddit_user_profile\` for account age and karma, \`reddit_user_comments\` for whether this is a recurring subject for them or a one-off.
|
|
123
|
+
7. **Keep it running.** \`reddit_set_watch\` with the complaint phrase in quotes turns a one-off sweep into a standing feed, and \`kind\` set to comments matches where this language actually appears.
|
|
124
|
+
|
|
125
|
+
## Ranking what comes back
|
|
126
|
+
|
|
127
|
+
Three signals, in order: how many distinct authors raised it, whether the thread reached a workaround or stopped at the complaint, and whether it recurs across subreddits or lives in one. A problem that appears in several unrelated communities is a market, not a niche.
|
|
128
|
+
|
|
129
|
+
## A caution on counts
|
|
130
|
+
|
|
131
|
+
A search result page is bounded by \`limit\` and a null \`after\` does not mean the listing is complete. The response carries \`listing_status\`, and only \`complete\` means nothing is missing. A frequency count taken from a truncated page is a count of that page.
|
|
132
|
+
`,
|
|
133
|
+
},
|
|
134
|
+
];
|
|
135
|
+
|
|
136
|
+
export function playbookUri(slug) {
|
|
137
|
+
return `${PLAYBOOK_SCHEME}://${slug}`;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* Register every playbook on an McpServer. Called by createServer, which is
|
|
142
|
+
* what makes them part of the same catalog every caller receives.
|
|
143
|
+
*/
|
|
144
|
+
export function registerPlaybooks(server) {
|
|
145
|
+
for (const p of PLAYBOOKS) {
|
|
146
|
+
server.registerResource(
|
|
147
|
+
p.name,
|
|
148
|
+
playbookUri(p.slug),
|
|
149
|
+
{ title: p.title, description: p.description, mimeType: "text/markdown" },
|
|
150
|
+
async (uri) => ({
|
|
151
|
+
contents: [{ uri: uri.href, mimeType: "text/markdown", text: p.body }],
|
|
152
|
+
}),
|
|
153
|
+
);
|
|
154
|
+
}
|
|
155
|
+
return PLAYBOOKS.length;
|
|
156
|
+
}
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
// src/provenance.js, the local half of reddit_explain.
|
|
2
|
+
//
|
|
3
|
+
// WHAT A ROW'S PROVENANCE ACTUALLY IS ON THIS PRODUCT, measured rather than
|
|
4
|
+
// assumed, because the honest answer turned out to be the opposite of the
|
|
5
|
+
// obvious one.
|
|
6
|
+
//
|
|
7
|
+
// The plan this tool came from specified "cache age" as the field to surface.
|
|
8
|
+
// There is no cache to age. The redditapis.com read path holds no response
|
|
9
|
+
// cache for customer reads: the whole customer read path (routes/reddit.js and
|
|
10
|
+
// services/reddit/client.js on origin/main) carries exactly one `cache`
|
|
11
|
+
// mention and it is about which egress path to use, not about response bodies;
|
|
12
|
+
// there is no Redis, memcached, LRU or keyv dependency in either service. That
|
|
13
|
+
// was re-derived with a positive control in the 2026-09-27 implementation spec,
|
|
14
|
+
// and it is visible from this side too: no response this client receives
|
|
15
|
+
// carries a cache header, an age field or a fetched-at stamp. A "cache hit" and
|
|
16
|
+
// a "cache miss" are the same code: a live fetch.
|
|
17
|
+
//
|
|
18
|
+
// SO THE AGE THAT EXISTS IS REAL, AND IT IS NOT A CACHE AGE. A row an agent is
|
|
19
|
+
// holding was fetched at a particular instant by a particular call in this
|
|
20
|
+
// session, and it has been getting older ever since. That instant is something
|
|
21
|
+
// this process observed directly, so it can be reported without inventing
|
|
22
|
+
// anything: the ledger below records, for every call that succeeded, which tool
|
|
23
|
+
// made it, which endpoint it reached, when the response arrived, the upstream
|
|
24
|
+
// request id and how many bytes came back. reddit_explain reads that back.
|
|
25
|
+
//
|
|
26
|
+
// WHAT IT DELIBERATELY DOES NOT DO: it does not claim a cache age, and it does
|
|
27
|
+
// not restate a retention period. The retention table is published and is the
|
|
28
|
+
// emitting system for that fact; a number copied into a tool description drifts
|
|
29
|
+
// silently the day the table changes, so the description points at the page.
|
|
30
|
+
//
|
|
31
|
+
// SCOPE. The ledger is per server, which is per caller, for the same reason
|
|
32
|
+
// every other piece of state in this package is: served remotely, one process
|
|
33
|
+
// serves many callers, and a shared ledger would hand one caller's endpoints
|
|
34
|
+
// and request ids to another. It lives in memory only and starts empty.
|
|
35
|
+
|
|
36
|
+
export const LEDGER_CAP = 20;
|
|
37
|
+
|
|
38
|
+
/** A bounded, newest-first ledger of the calls one server has completed. */
|
|
39
|
+
export function createReadLedger({ cap = LEDGER_CAP, now = () => Date.now() } = {}) {
|
|
40
|
+
const entries = [];
|
|
41
|
+
return {
|
|
42
|
+
record(entry) {
|
|
43
|
+
if (!entry || typeof entry.tool !== "string") return;
|
|
44
|
+
entries.unshift({
|
|
45
|
+
tool: entry.tool,
|
|
46
|
+
endpoint: entry.path ?? null,
|
|
47
|
+
method: entry.method || "GET",
|
|
48
|
+
observed_at_ms: now(),
|
|
49
|
+
request_id: entry.requestId ?? null,
|
|
50
|
+
bytes: Number.isFinite(entry.bytes) ? entry.bytes : null,
|
|
51
|
+
});
|
|
52
|
+
if (entries.length > cap) entries.length = cap;
|
|
53
|
+
},
|
|
54
|
+
list() {
|
|
55
|
+
return entries.slice();
|
|
56
|
+
},
|
|
57
|
+
get size() {
|
|
58
|
+
return entries.length;
|
|
59
|
+
},
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// Stated once, used in the result. Each clause is a fact about this product's
|
|
64
|
+
// read path, not advice.
|
|
65
|
+
export const FRESHNESS = Object.freeze({
|
|
66
|
+
source: "reddit.com, fetched through api.redditapis.com at the moment the call ran",
|
|
67
|
+
response_cache: "none",
|
|
68
|
+
response_cache_detail:
|
|
69
|
+
"The redditapis.com read path holds no response cache for customer reads, so every call is served live and the age below is the age of this session's copy of the rows, not the age of a stored row.",
|
|
70
|
+
retention_policy_url: "https://www.redditapis.com/privacy-and-data-handling",
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Build the reddit_explain handler.
|
|
75
|
+
*
|
|
76
|
+
* @param {object} deps
|
|
77
|
+
* @param {() => Array} deps.listReads reads the server's ledger
|
|
78
|
+
* @param {() => number} [deps.now] injectable clock, for tests
|
|
79
|
+
*/
|
|
80
|
+
export function createExplainHandler({ listReads, now = () => Date.now() }) {
|
|
81
|
+
return async function explain(args = {}) {
|
|
82
|
+
const { tool, limit } = args;
|
|
83
|
+
const t = now();
|
|
84
|
+
let rows = listReads();
|
|
85
|
+
if (typeof tool === "string" && tool.length) rows = rows.filter((r) => r.tool === tool);
|
|
86
|
+
const n = Number.isFinite(limit) ? Math.max(1, Math.min(LEDGER_CAP, Math.trunc(limit))) : 5;
|
|
87
|
+
rows = rows.slice(0, n);
|
|
88
|
+
|
|
89
|
+
const calls = rows.map((r) => ({
|
|
90
|
+
tool: r.tool,
|
|
91
|
+
endpoint: r.endpoint,
|
|
92
|
+
method: r.method,
|
|
93
|
+
observed_at: new Date(r.observed_at_ms).toISOString(),
|
|
94
|
+
age_seconds: Math.max(0, Math.round((t - r.observed_at_ms) / 1000)),
|
|
95
|
+
request_id: r.request_id,
|
|
96
|
+
response_bytes: r.bytes,
|
|
97
|
+
}));
|
|
98
|
+
|
|
99
|
+
const payload = { calls, call_count: calls.length, ...FRESHNESS };
|
|
100
|
+
|
|
101
|
+
const head = calls.length
|
|
102
|
+
? calls
|
|
103
|
+
.map(
|
|
104
|
+
(c) =>
|
|
105
|
+
`${c.tool} reached ${c.method} ${c.endpoint} at ${c.observed_at}, ${c.age_seconds}s ago` +
|
|
106
|
+
`${c.request_id ? `, upstream request id ${c.request_id}` : ""}` +
|
|
107
|
+
`${c.response_bytes != null ? `, ${c.response_bytes} bytes` : ""}.`,
|
|
108
|
+
)
|
|
109
|
+
.join(" ")
|
|
110
|
+
: tool
|
|
111
|
+
? `No completed call by ${tool} is recorded in this session.`
|
|
112
|
+
: "No completed call is recorded in this session yet; the ledger starts empty and fills as tools run.";
|
|
113
|
+
|
|
114
|
+
return {
|
|
115
|
+
content: [
|
|
116
|
+
{
|
|
117
|
+
type: "text",
|
|
118
|
+
text: `${head} ${FRESHNESS.response_cache_detail} Retention by category is published at ${FRESHNESS.retention_policy_url}.\n\n${JSON.stringify(payload)}`,
|
|
119
|
+
},
|
|
120
|
+
],
|
|
121
|
+
structuredContent: payload,
|
|
122
|
+
};
|
|
123
|
+
};
|
|
124
|
+
}
|