redditapis-mcp 0.9.1 → 0.10.2

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 CHANGED
@@ -1,5 +1,190 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.10.2 (2026-10-14)
4
+
5
+ ### The 408 upstream_timeout read gets the same one retry
6
+
7
+ The API now answers `408 {"error":"upstream_timeout"}` when a read did not complete in
8
+ time (the body says it was not billed and names `retry_after`); before this release that
9
+ case was a 500 and the model saw it as a failed tool call. A read answered that way is
10
+ retried once after its `retry_after` (clamped 1..5 s), exactly like the 503
11
+ `upstream_unavailable` retry from 0.10.1, and a 408 that is not retried carries a hint
12
+ naming the retry. A 408 with any other error value, and a 408 on a write, are never
13
+ retried.
14
+
15
+ ## 0.10.1 (2026-10-14)
16
+
17
+ ### One bounded retry on the API's transient 503, and a clearer 429
18
+
19
+ A read refused by the API with `503 {"error":"upstream_unavailable"}` (the body says it
20
+ was not billed and names `retry_after`) is now retried once after that many seconds
21
+ (clamped 1..5) before the error reaches the model; measured on 2026-10-14, a per-read
22
+ refusal of about 1 in 3 showed up as failed tool calls that one retry turns into slow
23
+ successes. Writes are never retried, any other 503 is never retried, and the gateway
24
+ retries are unchanged. The wait is clamped to 5 s, so the API's 60 s `retry_after`
25
+ (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
26
+ message names) and wait the `retry_after`, matching the API's
27
+ `too_many_concurrent_reads` answer.
28
+
29
+ ## 0.10.0 (2026-10-02)
30
+
31
+ ### The watch compiler refuses rather than guesses
32
+
33
+ Seventeen defects were found in this compiler across three review rounds. Every
34
+ single one had the same signature: it consumed part of the description, emitted
35
+ a filter anyway, and said nothing. The worst produced a monitor whose keyword
36
+ was a single `"` character.
37
+
38
+ Rounds one and two were both fixed by writing better rules, and both shipped a
39
+ suite that pinned the sentences just fixed and could not fail on an input nobody
40
+ had thought of. Round two's rewrite broke nine inputs round one handled. More
41
+ rules was never going to end.
42
+
43
+ **So the contract is inverted.** Every character of a description must be
44
+ claimed by exactly one thing: a subreddit, a quoted phrase, a keyword, an
45
+ exclusion, a score clause, a delivery clause, or a word with no filter content.
46
+ Anything left over, or counted twice, and the compiler returns **no filter at
47
+ all** and names the words it could not place. A description that both watches
48
+ for and excludes the same phrase is refused too, because that monitor could
49
+ never deliver.
50
+
51
+ That inverts the failure mode from "a silently wrong monitor that looks fine" to
52
+ "a refusal that says why", which for this feature is the only safe direction:
53
+ every failure here is invisible, and the caller otherwise gets a monitor id, a
54
+ green test delivery and then silence or a firehose.
55
+
56
+ Also fixed in the same pass, all found by the change above or by the corpus:
57
+ quoted phrases are now taken out of the sentence **before** anything else reads
58
+ it, so a marker word, a subreddit or a kind word inside your own quotes is just
59
+ text (`for "ignore list"`, `for "except this"`, `for "send to production"` were
60
+ all wrecked by this); the match kind is read from the original word positions
61
+ rather than a string with substrings deleted, which had turned `posts and
62
+ comments for "post"` into a comments-only watch; a subreddit inside a keyword or
63
+ exclusion clause ends that clause instead of being swallowed into the term; and
64
+ a bare multi-word keyword is reported in `notes`, because an unquoted keyword is
65
+ a free-text run and absorbs whatever follows it.
66
+
67
+ **The suite that could not see any of this has been replaced, not extended.**
68
+ `test/watch-invariants.test.mjs` generates 28,511 sentences from pieces chosen
69
+ to collide (marker words inside quotes, subreddits inside quotes, markers before
70
+ the main clause, unicode quotes, conjunctions) and asserts seven structural
71
+ invariants that hold for every input, listed or not. Measured against the two
72
+ previous heads with the same corpus: 20,970 violations on the first, 45,072 on
73
+ the second, 0 now.
74
+
75
+ ### Fixed before release, after the first review
76
+
77
+ The watch compiler matched every field with its own regular expression over the
78
+ **whole** sentence, so two clauses read the same words and one of them was
79
+ wrong. Four HIGH defects, one cause, each one silent:
80
+
81
+ - `for pricing except "giveaway"` put the EXCLUDED phrase into `q`, so `q` and
82
+ `exclude_terms[0]` were the same string and the monitor could never deliver,
83
+ while the caller got a monitor id, a green test delivery, and then silence.
84
+ - `for posts about pricing`, the most natural sentence there is, watched for the
85
+ literal phrase "posts about pricing".
86
+ - `for posts with at least 5 upvotes about pricing` dropped the keyword
87
+ entirely, leaving a firehose of every post over the score floor.
88
+ - `for "comment moderation"` read the word *comment* out of the caller's own
89
+ keyword and switched the watch to comments only, missing every post.
90
+ - A pasted `reddit.com/r/X` link was not read as a subreddit, so the watch went
91
+ site-wide and consumed the account's one free site-wide slot.
92
+
93
+ The sentence is now cut into clauses **once** and every field reads only its own
94
+ span; the match kind is derived last, from what is left after the subreddits,
95
+ the quoted spans and the derived terms are removed, so a keyword can never set
96
+ it. Patching the pairs one at a time had already produced three green suites
97
+ over four live defects, which is why the fix is the structure and not another
98
+ rule.
99
+
100
+ **And a lossy compile is no longer silent.** Every one of the above reported
101
+ `notes: []`. When the compiler drops or rewrites something the description
102
+ carries, it says so, and that is asserted per case.
103
+
104
+ Three more in the same family, all "unknown reported as fine":
105
+
106
+ - The webhook signing secret was read only to set a boolean and then thrown
107
+ away, while the result asserted the secret was in it. The API returns that
108
+ secret exactly once and the webhook list never returns it again, so a watch
109
+ set up this way left its owner permanently unable to verify a delivery
110
+ signature, and told them otherwise. It is now returned as `delivery.secret`,
111
+ and the sentence is tied to the value rather than to a flag about it.
112
+ - A test delivery whose body could not be read counted as a SUCCESS
113
+ (`ok !== false`). It is now tri-state, and an unreadable answer says the
114
+ target is unconfirmed.
115
+ - When the created monitor came back without an id, the re-point was skipped
116
+ with no note while the result still reported a registered target.
117
+
118
+ ### Added
119
+
120
+ - **`reddit_set_watch`: one call where standing up a watch took three.** It compiles a plain-words
121
+ description into a monitor filter, reads the account's own plan capabilities, creates the monitor,
122
+ then registers, points and test-fires an optional `deliver_to` webhook. The compiler is a set of
123
+ regular expressions, not a model, so the same sentence always compiles to the same filter, and
124
+ everything it derived comes back in `understood` and `compiled_filter` for a person to check. It
125
+ reads `r/Name` subreddits, quoted phrases as keyword terms (two or more become an any-of set),
126
+ `comments` / `posts and comments` as the match kind, terms after `except` or `ignoring` as
127
+ exclusions, and `at least N upvotes` as a score floor.
128
+ - **The two rules that need no round trip are checked locally.** A description anchored by neither
129
+ a subreddit nor a keyword is refused with zero network calls, and `r/all` is dropped as Reddit's
130
+ site-wide listing rather than sent to earn a `subreddit_reserved` 400.
131
+ - **Plan awareness is a field read, not a guess.** `GET /api/reddit/monitor/list` is free and its
132
+ `slots` object states the account's capabilities outright (`scoped_allowed`, `comments_allowed`,
133
+ `sitewide_allowed`, the cadence floor and the distinct-subreddit allowance). On a plan without
134
+ subreddit-scoped watches, a description that names subreddits and also carries a keyword is
135
+ built over every subreddit instead, and the swap is reported; a description with no keyword has
136
+ no equivalent, so it goes as written and the API answers for itself. An unreadable `slots`
137
+ object rewrites nothing.
138
+ - **Ordered for the failure mode.** The monitor is created before the webhook, because a monitor
139
+ with no `webhook_ids` still delivers to every active webhook on the account, so a watch survives
140
+ a failed webhook registration. The reverse order would strand a webhook pointing at nothing when
141
+ the monitor hits a slot limit.
142
+ - **Three playbooks as MCP resources**, at `playbook://competitor-mention-watch`,
143
+ `playbook://subreddit-audit` and `playbook://pain-point-mining`. Resources rather than tools
144
+ because the protocol makes resources application-driven: a host reads one only when it is asked
145
+ for by URI, and nothing is prefetched. Each is built only from calls this package already exposes,
146
+ and a test checks every tool each playbook names against the live catalog, so a recipe cannot
147
+ quietly start pointing at something that does not exist.
148
+ - **`reddit_explain`: where the rows you are holding came from.** Per completed call, the tool that
149
+ made it, the endpoint, when the response arrived, how many seconds old that copy now is, the
150
+ upstream request id and the response size. It reaches no endpoint: the ledger is in memory, holds
151
+ the 20 most recent calls, starts empty and is scoped to one server, so one caller's endpoints and
152
+ request ids never reach another's.
153
+ - **It reports no cache age, because there is no cache.** The read path holds no response cache for
154
+ customer reads, so every call is served live and the age that exists is the age of this
155
+ session's copy of the rows, not of a stored row. The result says `response_cache: "none"` rather
156
+ than implying a freshness guarantee nothing provides, and it points at the published retention
157
+ table instead of restating a figure that would drift the day the table changes.
158
+
159
+ ### Changed
160
+
161
+ - The README's monitoring note said monitors were "subreddit-scoped, posts-only" with "no
162
+ all-of-Reddit keyword watch, no comment monitoring yet", and that a webhook needs an active plan.
163
+ All three have been untrue since 2026-08-13 and the note ships in the npm tarball. It now
164
+ describes the free entitlement, site-wide and comment watching, and the full `slots` object.
165
+ - The catalog test allows a `null` path for a local tool that reaches no endpoint, and for nothing
166
+ else; a red test proves a non-local tool with no path, and a foreign prefix with or without a
167
+ local handler, are still refused.
168
+ - The description gate audits resource metadata under the full matcher, and resource bodies for
169
+ hidden text and external links. Full instruction phrasing stays OFF bodies deliberately: a
170
+ description is injected at connect time and nobody chose it, a resource is read only when its URI
171
+ is asked for, and a recipe that could not name the calls it is a recipe for would be useless.
172
+ - **A claim about the body gate was withdrawn, because it was measured and was false.** Its
173
+ "adversarial steering" check shipped with five planted defects that were its own five regular
174
+ expressions restated in English. An independent corpus of ten attacks, written attacks-first with
175
+ the patterns never consulted, is caught **0 of 10**. That number is now asserted in the test, so
176
+ the limit cannot rot into a capability nobody re-measured. The check is kept as a mechanical
177
+ tripwire for literal subversion and encoded payloads, and the control that actually fits the risk
178
+ is now in place: each playbook body's content hash is pinned, so no body can change without the
179
+ test failing and a human reading the diff.
180
+
181
+ ### No tool was added for the credit balance
182
+
183
+ `reddit_account_me` already returns `credits_remaining`, `credits_used` and `total_requests`, is
184
+ free, is not metered and is already named in the server instructions. A second tool over the same
185
+ endpoint would be a second name for one job and one more entry competing for attention in the
186
+ catalog, so there is none.
187
+
3
188
  ## 0.9.1 (2026-10-02)
4
189
 
5
190
  - Feedback reports no longer ask for the user's words: `reddit_feedback_send` `details` is three
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
- 44 tools: 30 Reddit reads, 10 monitor/webhook management tools, 3 feedback tools, and 1 account tool (`reddit_account_me`). 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.
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
- v1 monitors are **subreddit-scoped, posts-only** (no all-of-Reddit keyword watch, no comment monitoring yet). Creating or updating a monitor or webhook needs an active plan; reading your own list/health/deliveries never does.
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
 
@@ -281,6 +317,8 @@ Reads need nothing but your API key -- no account linking, login, or cookie step
281
317
 
282
318
  **`HTTP 429 (rate limited)`** Wait a few seconds and retry, or reduce request frequency. For bulk work, space out calls and raise `REDDITAPIS_TIMEOUT_MS`.
283
319
 
320
+ **`HTTP 408 (upstream_timeout)`** The read did not finish in time and was not billed. The server already retried once after `retry_after`; call the tool again shortly. A `503 upstream_unavailable` on a read gets the same one retry.
321
+
284
322
  **`Request failed: timed out after 30000ms`** The default timeout is 30 seconds. For large `reddit_deep_comment_search` or paginated fetches, set `REDDITAPIS_TIMEOUT_MS` higher (for example `60000`).
285
323
 
286
324
  **Tools do not appear in Claude / Cursor** Ensure `npx` is on your PATH and Node.js 18+ is installed (`node --version`). Check your MCP client logs for startup errors.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "redditapis-mcp",
3
- "version": "0.9.1",
3
+ "version": "0.10.2",
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"
@@ -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
+ }