redditapis-mcp 0.8.1 → 0.9.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 CHANGED
@@ -1,5 +1,67 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.9.1 (2026-10-02)
4
+
5
+ - Feedback reports no longer ask for the user's words: `reddit_feedback_send` `details` is three
6
+ bullets (What happened, Repro, Evidence), optionally after a one-line summary of the goal, and the
7
+ server instructions match. No quote of the conversation is collected (Connectors Directory rule
8
+ against extraneous conversation data).
9
+ - The description gate adds check (e) for conversation-data asks in descriptions and instructions,
10
+ and check (d) refuses external links: https on our own or example hosts only, bare hosts on real
11
+ TLDs, backslash authorities, emails, IP addresses and javascript:/data: schemes.
12
+
13
+ ## 0.9.0 (2026-10-02)
14
+
15
+ ### Fixed
16
+
17
+ - **reddit_post_visibility works again.** Its path was written
18
+ `/api/reddit/post/:id/visibility`, which the client's path builder does not
19
+ fill, so every call sent a literal `:id` and was refused with HTTP 400 (it
20
+ had been broken since the tool shipped). It is now `{id}`, and a test fails
21
+ if any tool path carries an unfillable `:param`.
22
+
23
+ ### Changed
24
+
25
+ - **Tool descriptions state product facts only; guidance moved to the server
26
+ instructions.** Every tool and parameter description now says what the tool
27
+ returns, what it takes, what it costs and what its errors mean, and nothing
28
+ else: no instructions to the model, no names of other tools, no hidden or
29
+ encoded text. That is the rule the Claude connector directory's compliance
30
+ attestation sets for tool descriptions. The guidance that was spread across
31
+ them (which search or listing tool fits which question, how to get the Reddit
32
+ session the home feed and the four private listings need, how to read a
33
+ partial listing or a monitor's health, what a monitor update replaces, and
34
+ the feedback drafting policy) now lives once in the server instructions,
35
+ which every MCP client hands its model with the tool list. Tool names, input
36
+ schemas, annotations and behaviour are unchanged. Measured with the new gate
37
+ on 0.8.1: 24 of 44 tools named another `reddit_*` token and every tool
38
+ carried at least one instruction-shaped phrase (most through shared
39
+ parameter text such as the pagination cursor); on 0.9.0 both are zero.
40
+
41
+ ### Added
42
+
43
+ - **`createServer({ inlineCredentials: false })` for remote hosts.** The
44
+ default stays `true`, so a local `npx` install sees no change. With `false`,
45
+ every tool whose input takes the caller's Reddit cookies or session proxy is
46
+ not registered: `reddit_home_feed`, `reddit_user_upvoted`,
47
+ `reddit_user_saved`, `reddit_user_hidden` and `reddit_user_gilded`. They are
48
+ absent from `tools/list`, a call naming one fails without reaching the API,
49
+ and the server instructions drop the sentence that points at them. The test
50
+ is `takesInlineCredentials` in `src/tools.js`, which matches the known cookie
51
+ arguments and any credential-shaped argument name, so a future cookie
52
+ argument is hidden by default. `test/inline-credentials.test.mjs` proves the
53
+ hosted listing has zero such tools while the default keeps all five.
54
+ - **`test/description-compliance.test.mjs`, part of `npm test`.** It lists the
55
+ catalog the way a client receives it (`createServer` plus an in-memory MCP
56
+ client) and fails if any tool or parameter description names another tool,
57
+ matches a model-instruction pattern (should, do not, never, always, must,
58
+ use ... instead, second person, sentence-initial imperatives and similar), or
59
+ carries zero-width, bidi, tag or control characters or a base64- or
60
+ hex-looking run. It also checks that the server instructions still carry the
61
+ guidance that moved and stay within 2048 characters. Planted defects for all
62
+ three checks are asserted in the same run, so a matcher that stops catching
63
+ them fails rather than reporting a clean catalog.
64
+
3
65
  ## 0.8.1 (2026-09-30)
4
66
 
5
67
  ### Fixed
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "redditapis-mcp",
3
- "version": "0.8.1",
3
+ "version": "0.9.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": {
@@ -31,8 +31,10 @@
31
31
  "scripts": {
32
32
  "start": "node src/index.js",
33
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/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
- "prepublishOnly": "node scripts/prepublish-tenant-check.mjs"
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",
35
+ "prepublishOnly": "node scripts/prepublish-tenant-check.mjs",
36
+ "postpublish": "node scripts/registry-drift.mjs --publish",
37
+ "check:registry-drift": "node scripts/registry-drift.mjs"
36
38
  },
37
39
  "dependencies": {
38
40
  "@modelcontextprotocol/sdk": "^1.29.0",
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 four labelled bullets: What happened, What the user said, Repro, Evidence.`, true);
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/server.js CHANGED
@@ -12,7 +12,7 @@
12
12
 
13
13
  import { createRequire } from "node:module";
14
14
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
15
- import { TOOLS, buildQuery, buildPath, buildBody, buildHeaders } from "./tools.js";
15
+ import { TOOLS, buildQuery, buildPath, buildBody, buildHeaders, takesInlineCredentials } from "./tools.js";
16
16
  import { createFeedbackHandler, sameFailure } from "./feedback.js";
17
17
 
18
18
  // Read from package.json rather than a hand-maintained literal -- this drifted
@@ -137,15 +137,51 @@ export function hintFor(status, path) {
137
137
  }
138
138
 
139
139
  // Standing instructions the client hands its model alongside the tool list.
140
- // This is the trigger list for feedback, in the place a model actually reads.
141
- export const INSTRUCTIONS =
140
+ //
141
+ // THIS IS THE ONLY PLACE GUIDANCE LIVES (0.9.0). Tool and parameter
142
+ // descriptions state product facts only: what a tool returns, its inputs, its
143
+ // cost, its errors. Which tool to use when, how to read a partial answer, how
144
+ // to get a Reddit session and the feedback policy are model guidance, and the
145
+ // protocol's place for that is the server instructions, so it is here and
146
+ // nowhere else. test/description-compliance.test.mjs fails if a description
147
+ // names another tool or instructs the model, and checks that this text still
148
+ // carries the guidance that moved. Kept within 2048 characters: Claude Code
149
+ // 2.1.287 truncates server instructions past that (its default
150
+ // CLAUDE_CODE_MAX_MCP_DESCRIPTION_LENGTH, read from the binary 2026-10-02).
151
+ //
152
+ // THE SESSION SENTENCE IS CONDITIONAL. It names the five tools that take the
153
+ // caller's Reddit cookies; a server built with inlineCredentials:false does not
154
+ // register them, and instructions that point at a tool the client cannot see
155
+ // would send the model looking for it. buildInstructions drops that sentence
156
+ // in that mode; INSTRUCTIONS is the default (local, all tools) text.
157
+ const SESSION_SENTENCE =
158
+ "reddit_home_feed and reddit_user_upvoted/saved/hidden/gilded need the user's own Reddit cookies (reddit_session, loid) from POST /api/reddit/login on the REST API, not an MCP tool. ";
159
+
160
+ export function buildInstructions({ inlineCredentials = true } = {}) {
161
+ return INSTRUCTIONS_HEAD + (inlineCredentials ? SESSION_SENTENCE : "") + INSTRUCTIONS_TAIL;
162
+ }
163
+
164
+ const INSTRUCTIONS_HEAD =
142
165
  "redditapis.com MCP server. Read tools cost credits per call (most $0.002); monitor, webhook and feedback tools are free. " +
143
- "If a redditapis tool call fails with an error other than 401/402/429 and the user has to work around it, if the user asks for something no redditapis tool covers, " +
144
- "if a documented field comes back empty or wrong, or if the user is clearly frustrated with a result, draft a report with reddit_feedback_send (action \"draft\"). " +
145
- "Drafting is local and silent; never send a draft unless the user names it after reviewing action \"list\". " +
166
+ "reddit_account_me shows the balance for free before a large batch; on a 402, give the user the error's top-up URL. " +
167
+ "reddit_search: sort='relevance' with a large `limit` for best matches; sort_type='score' re-orders that page; a quoted phrase or `subreddit` narrows it. " +
168
+ "reddit_deep_comment_search returns matching comments; reddit_search_comments only their posts. " +
169
+ "reddit_search_communities searches subreddits; reddit_subreddits_popular/new/default browse them. ";
170
+
171
+ const INSTRUCTIONS_TAIL =
172
+ "A null `after` with listing_status truncated or unknown is a partial answer: say so and widen the sort, timeframe or query. " +
173
+ "In monitor health read stream_liveness, then coverage_24h; unknown, partial or null is unanswered, not healthy. " +
174
+ "reddit_monitor_deliveries shows where a monitor's matches went. " +
175
+ "A monitor update replaces the whole filter, so resupply subreddit and kind. On a failed webhook test, relay its `hint`. " +
176
+ "Feedback: never send a draft unless the user names it after reviewing action \"list\"; drafting is local and silent. " +
177
+ "Draft with reddit_feedback_send (action \"draft\") when a call fails other than 401/402/429 and the user works around it, " +
178
+ "no tool covers the ask, a documented field is empty or wrong, or the user is frustrated: one per issue, not announced mid-task, " +
179
+ "three bullets (What happened, Repro, Evidence), optionally after a one-line goal summary in your words, never a quote; identifiers only, never payloads, secrets or personal names. " +
146
180
  "Before drafting a report that a parameter is IGNORED or a field is EMPTY, re-run the call with a distinctive value that could only match if the parameter was honoured, and with the phrase quoted; " +
147
181
  "if either comes back on topic the issue is ranking or matching, so title it that way and say what the control showed.";
148
182
 
183
+ export const INSTRUCTIONS = buildInstructions({ inlineCredentials: true });
184
+
149
185
  /**
150
186
  * Build one server for one caller.
151
187
  *
@@ -165,6 +201,11 @@ export const INSTRUCTIONS =
165
201
  * @param {(ms:number)=>Promise<void>} [opts.sleepImpl] injectable for tests
166
202
  * @param {number[]} [opts.retryDelaysMs] waits before each retry of a read that
167
203
  * hit a gateway failure (an API restart); default [3000, 8000]
204
+ * @param {boolean} [opts.inlineCredentials] default true. false leaves every
205
+ * tool whose input schema takes the caller's Reddit cookies or session
206
+ * proxy (takesInlineCredentials in tools.js) UNREGISTERED, and drops the
207
+ * instructions sentence that names them. A remote host passes false so a
208
+ * connected app is never asked to send a Reddit session through it.
168
209
  */
169
210
  export function createServer({
170
211
  apiKey,
@@ -175,7 +216,13 @@ export function createServer({
175
216
  authHeaders = null,
176
217
  sleepImpl = (ms) => new Promise((r) => setTimeout(r, ms)),
177
218
  retryDelaysMs = [3000, 8000],
219
+ inlineCredentials = true,
178
220
  } = {}) {
221
+ // A STRING "false" from an environment variable must not silently leave the
222
+ // cookie tools exposed on a hosted server (review 2026-10-02): booleans only.
223
+ if (typeof inlineCredentials !== "boolean") {
224
+ throw new TypeError(`inlineCredentials must be a boolean, got ${typeof inlineCredentials}`);
225
+ }
179
226
  const BASE_URL = String(baseUrl).replace(/\/+$/, "");
180
227
  const REQUEST_TIMEOUT_MS = Number.isFinite(timeoutMs) && timeoutMs > 0 ? timeoutMs : DEFAULT_TIMEOUT_MS;
181
228
 
@@ -293,7 +340,10 @@ export function createServer({
293
340
  }
294
341
  }
295
342
 
296
- const server = new McpServer({ name: "redditapis", version: VERSION }, { instructions: INSTRUCTIONS });
343
+ const server = new McpServer(
344
+ { name: "redditapis", version: VERSION },
345
+ { instructions: buildInstructions({ inlineCredentials: inlineCredentials !== false }) },
346
+ );
297
347
 
298
348
  // Handlers for tools that carry local: "<name>" in the catalog. A name the
299
349
  // catalog uses and this map lacks is a boot-time failure, never a silent
@@ -309,6 +359,9 @@ export function createServer({
309
359
  };
310
360
 
311
361
  for (const tool of TOOLS) {
362
+ // Filtered at registration, so a hidden tool is absent from tools/list and
363
+ // a tools/call naming it fails as an unknown tool, never reaching the API.
364
+ if (inlineCredentials === false && takesInlineCredentials(tool)) continue;
312
365
  const method = tool.method || "GET";
313
366
  const annotations = {
314
367
  title: tool.name,
package/src/tools.js CHANGED
@@ -40,7 +40,7 @@ import { z } from "zod";
40
40
  // Shared Zod input-schema fragments.
41
41
  const LIMIT = {
42
42
  limit: z.number().int().min(1).max(100).optional().describe(
43
- "Max items to return (1 to 100). The API clamps out-of-range values; endpoint default applies if omitted.",
43
+ "Maximum items returned, 1 to 100. The API clamps out-of-range values; the endpoint default applies when omitted.",
44
44
  ),
45
45
  };
46
46
  // Shared by every listing and search tool, so the rule about what an EMPTY
@@ -48,8 +48,8 @@ const LIMIT = {
48
48
  // where one of them would eventually be missed.
49
49
  const AFTER = {
50
50
  after: z.string().optional().describe(
51
- "Opaque pagination cursor. Pass back the previous response's `after` value EXACTLY as it was returned; the format is not stable and a hand-written Reddit fullname loses the paging depth the cursor carries. Omit on the first call. " +
52
- "When `after` comes back null there is no next page to request, and that does NOT reliably mean you have every item: Reddit often stops serving a busy listing long before it runs out. Read `listing_status` on that final response instead. It is `complete`, `truncated` or `unknown`, and ONLY `complete` means nothing is missing. Never report `truncated` or `unknown` to a user as the end of the data; say the answer is partial and widen across sorts, timeframes or search terms.",
51
+ "Opaque pagination cursor: the previous response's `after` value, exactly as it was returned. The format is not stable, and a hand-written Reddit fullname loses the paging depth the cursor carries. Absent on the opening page. " +
52
+ "A null `after` on a response means there is no next page; it does not by itself mean the listing is complete, because Reddit often stops serving a busy listing long before it runs out. That final response carries `listing_status`: `complete`, `truncated` or `unknown`, and only `complete` means nothing is missing.",
53
53
  ),
54
54
  };
55
55
  // Sort vocabularies differ by endpoint (they map to different Reddit listings):
@@ -61,7 +61,7 @@ const SORT_POSTS = {
61
61
  };
62
62
  const SORT_SEARCH = {
63
63
  sort: z.enum(["relevance", "hot", "top", "new", "comments"]).optional().describe(
64
- "Sort order for search. 'relevance' = best match (default), 'top' = highest score in the `t` window, 'new' = most recent, 'hot' = trending, 'comments' = most-discussed. Only 'relevance' weights how well a post matches; 'top', 'new' and 'comments' rank every loosely-matching post by that one number, so on a site-wide generic query 'top' returns viral posts that barely mention the terms. Prefer 'relevance', a quoted phrase, or a `subreddit` scope.",
64
+ "Sort order for search. 'relevance' = best match (default), 'top' = highest score in the `t` window, 'new' = most recent, 'hot' = trending, 'comments' = most-discussed. Only 'relevance' weights how well a post matches; 'top', 'new' and 'comments' rank every loosely-matching post by that one number, so on a site-wide generic query 'top' returns viral posts that barely mention the terms. A quoted phrase or a `subreddit` scope narrows the matching set itself.",
65
65
  ),
66
66
  };
67
67
  const SORT_USER = {
@@ -80,12 +80,12 @@ const TIME = {
80
80
  // broad 'relevance' query, so search gets a separate, search-accurate description.
81
81
  const TIME_SEARCH = {
82
82
  t: z.enum(["hour", "day", "week", "month", "year", "all"]).optional().describe(
83
- "Time window that bounds which posts the search returns, e.g. 'week' = only posts from the past week. Unlike a subreddit listing, search applies this to the 'relevance' and 'top' sorts too. When omitted, Reddit defaults to 'all', so a broad 'relevance' query surfaces old high-upvote posts that only loosely match. Pass 'week' or 'month' to keep results recent and on-topic.",
83
+ "Time window that bounds which posts the search returns, e.g. 'week' = only posts from the past week. Unlike a subreddit listing, search applies this to the 'relevance' and 'top' sorts too. When omitted, Reddit defaults to 'all', so a broad 'relevance' query surfaces old high-upvote posts that only loosely match; 'week' or 'month' keeps results recent and on topic.",
84
84
  ),
85
85
  };
86
86
  const NSFW = {
87
87
  nsfw: z.enum(["true", "false"]).optional().describe(
88
- "Set 'true' to include over-18 / NSFW results. Omit or 'false' to exclude them (default).",
88
+ "'true' includes over-18 / NSFW results; 'false' or omitted excludes them (default).",
89
89
  ),
90
90
  };
91
91
  const QUERY = {
@@ -102,13 +102,13 @@ const QUERY = {
102
102
  // for its flat POST-body cookie fields.
103
103
  const LISTING_COOKIES = {
104
104
  reddit_session: z.string().min(1).describe(
105
- "Reddit account session cookie. Obtain it by calling POST /api/reddit/login on the REST API first (not an MCP tool) and reusing the `reddit_session` cookie it returns. Required.",
105
+ "The Reddit account's session cookie, as returned by POST /api/reddit/login on the REST API (a REST call, not an MCP tool). Required.",
106
106
  ),
107
107
  loid: z.string().min(1).describe(
108
108
  "Reddit account loid cookie, from the same POST /api/reddit/login response. Required.",
109
109
  ),
110
110
  csrf_token: z.string().optional().describe(
111
- "Reddit CSRF cookie, from the same login response. Not required for a read (CSRF only guards state-changing calls) but harmless to pass if you have it.",
111
+ "Reddit CSRF cookie, from the same login response. Optional: a read is accepted without it (CSRF only guards state-changing calls).",
112
112
  ),
113
113
  };
114
114
 
@@ -121,13 +121,13 @@ const LISTING_COOKIES = {
121
121
  // for. The REST API accepts both.
122
122
  const SESSION_HEADERS_REQUIRED = {
123
123
  reddit_session: z.string().min(1).describe(
124
- "Your Reddit `reddit_session` cookie, from POST /api/reddit/login on the REST API (not an MCP tool). Sent as a header, never in the URL. Required.",
124
+ "The caller's Reddit session cookie, from POST /api/reddit/login on the REST API (a REST call, not an MCP tool). Sent as a request header, not in the URL. Required.",
125
125
  ),
126
126
  loid: z.string().min(1).describe(
127
- "Your Reddit `loid` cookie, from the same login response. Required, and must be sent together with reddit_session.",
127
+ "The caller's Reddit loid cookie, from the same login response. Required, together with the session cookie.",
128
128
  ),
129
129
  token_v2: z.string().optional().describe(
130
- "Your Reddit `token_v2` cookie, from the same login response. Optional.",
130
+ "The caller's Reddit token_v2 cookie, from the same login response. Optional.",
131
131
  ),
132
132
  proxy: z.string().optional().describe(
133
133
  "Optional proxy this read egresses through, so the request reaches Reddit from the IP this account normally acts from. http://user:pass@host:port or host:port. Pinned across retries.",
@@ -154,10 +154,10 @@ const SESSION_HEADERS_REQUIRED = {
154
154
  // with nothing anywhere reporting an error.
155
155
  const MONITOR_SUBREDDIT_DESC =
156
156
  "Subreddits to watch, without the r/ prefix (e.g. ['SaaS', 'startups']). 1 to 50. " +
157
- "OMIT this entirely (and set `q`) to watch ALL of Reddit for a keyword -- a monitor must be anchored by " +
158
- "either a subreddit list or a keyword, never neither. Do NOT pass ['all']: r/all is Reddit's site-wide " +
159
- "listing rather than a subreddit, so it is refused with 400 subreddit_reserved. Sitewide monitors are " +
160
- "capped per plan tier (see reddit_monitor_list's `slots`) and cover POSTS only.";
157
+ "Absent, with `q` set, the monitor watches ALL of Reddit for that keyword. A monitor is anchored by a " +
158
+ "subreddit list, a keyword or both; a request with neither is refused. ['all'] is refused with 400 " +
159
+ "subreddit_reserved, because r/all is Reddit's site-wide listing rather than a subreddit. Sitewide " +
160
+ "monitors are capped per plan tier (the `slots` object in the monitor list) and cover POSTS only.";
161
161
  const MONITOR_SUBREDDIT = {
162
162
  subreddit: z.array(z.string().min(1)).min(1).max(50).optional().describe(MONITOR_SUBREDDIT_DESC),
163
163
  };
@@ -166,52 +166,52 @@ const MONITOR_FILTER_FIELDS = {
166
166
  // because an agent that passes it alongside `subreddit` gets a 400 and needs
167
167
  // to know from the schema, not from the error, which of the two to drop.
168
168
  exclude_subreddits: z.array(z.string().min(1)).max(50).optional().describe(
169
- "Subreddits to SUPPRESS, without the r/ prefix (e.g. ['politics', 'AskReddit']). SITEWIDE MONITORS ONLY: pass this only when you have OMITTED `subreddit` and anchored the monitor with `q`. Passing it together with `subreddit` is rejected with a field-level 400 -- a monitor that names its subreddits should drop the unwanted name from that list instead. Up to 50, matched exactly like `subreddit` so 'r/Politics', '/r/politics' and 'politics' are one entry. This is the noise control for an all-of-Reddit keyword watch: it filters DELIVERY only, so it never changes what is polled, never frees quota, and never affects matching in any other subreddit. Independent of exclude_terms -- an item is dropped if either fires.",
169
+ "Subreddits to SUPPRESS, without the r/ prefix (e.g. ['politics', 'AskReddit']). SITEWIDE MONITORS ONLY: accepted when `subreddit` is absent and `q` anchors the monitor; sent together with `subreddit` it is rejected with a field-level 400. Up to 50, matched exactly like `subreddit`, so 'r/Politics', '/r/politics' and 'politics' are one entry. This is the noise control for an all-of-Reddit keyword watch. It filters DELIVERY only: it does not change what is polled, free quota, or affect matching in any other subreddit. Independent of exclude_terms; an item is dropped if either fires.",
170
170
  ),
171
171
  q: z.string().max(200).optional().describe(
172
- "Free-text keyword or phrase to match. Matched against the fields in `search_in` (default title+body+url). Omit to match every new post in the watched subreddits.",
172
+ "Free-text keyword or phrase to match, against the fields in `search_in` (default title+body+url). Absent, every new post in the watched subreddits matches.",
173
173
  ),
174
- author: z.string().max(200).optional().describe("Only match posts by this Reddit username (without u/)."),
174
+ author: z.string().max(200).optional().describe("Matches only posts by this Reddit username (without u/)."),
175
175
  exclude_terms: z.array(z.string().min(1).max(200)).max(50).optional().describe(
176
- "Posts containing any of these terms are suppressed even if they otherwise match. Use to cut noise (e.g. exclude 'giveaway' from a brand-mention monitor).",
176
+ "Posts containing any of these terms are suppressed even when they otherwise match, e.g. 'giveaway' excluded from a brand-mention monitor.",
177
177
  ),
178
178
  domain: z.array(z.string().min(1).max(200)).max(50).optional().describe(
179
- "Outbound link domains to watch for (e.g. ['example.com']). Matches the post's link URL, any URL inside a self-post/comment body, and a crosspost's original link, including one that only appeared in the original post's body. Exact-or-subdomain match only: 'example.com' matches 'blog.example.com' but never 'notexample.com'. Does NOT resolve shortened links (bit.ly, t.co).",
179
+ "Outbound link domains to watch for (e.g. ['example.com']). Matches the post's link URL, any URL inside a self-post/comment body, and a crosspost's original link, including one that only appeared in the original post's body. Exact-or-subdomain match only: 'example.com' matches 'blog.example.com' but not 'notexample.com'. Shortened links (bit.ly, t.co) are not resolved.",
180
180
  ),
181
181
  include_any: z.array(z.string().min(1).max(200)).max(50).optional().describe(
182
- "At least ONE of these terms must appear (OR match) for the post to qualify, on top of any `q`."
182
+ "A post qualifies when at least ONE of these terms appears (OR match), on top of any `q`."
183
183
  ),
184
184
  include_all: z.array(z.string().min(1).max(200)).max(50).optional().describe(
185
- "EVERY one of these terms must appear (AND match) for the post to qualify, on top of any `q`."
185
+ "A post qualifies only when EVERY one of these terms appears (AND match), on top of any `q`."
186
186
  ),
187
187
  search_in: z.array(z.enum(["title", "body", "url", "permalink"])).min(1).optional().describe(
188
- "Which fields keyword/term matching is scoped to. Default ['title', 'body', 'url']. Narrow to avoid false positives, e.g. a term that only appears in a URL slug matching a post that never mentions it in prose. All four resolve on comments as well as posts: on a comment, 'title' matches the title of the THREAD the comment sits under (a comment has no title of its own), which also applies through the default scope and can deliver every comment under a busy matching thread. Scope to ['body'] if you only want comments that say the term themselves.",
188
+ "Which fields keyword/term matching is scoped to. Default ['title', 'body', 'url']. A narrower scope avoids false positives, e.g. a term that only appears in a URL slug matching a post that does not mention it in prose. All four resolve on comments as well as posts: on a comment, 'title' matches the title of the THREAD the comment sits under (a comment has no title of its own), which also applies through the default scope and can deliver every comment under a busy matching thread. ['body'] limits comment matches to comments that contain the term themselves.",
189
189
  ),
190
190
  group: z.string().max(64).optional().describe(
191
- "Optional label to bundle multiple matches into one delivery instead of one webhook call per match. Omit for one delivery per matching item.",
191
+ "Optional label that bundles multiple matches into one delivery in place of one webhook call per match. Absent, each matching item is its own delivery.",
192
192
  ),
193
193
  kind: z.enum(["post", "comment", "both"]).optional().describe(
194
- "What to watch in the named subreddits: 'post' (default when omitted), 'comment', or 'both'. Comment monitoring requires a Growth, Pro or Scale plan -- on a lower tier this returns `comment_monitoring_requires_higher_tier` (402). Comments run roughly 7x the volume of posts, so expect proportionally more deliveries and check reddit_monitor_health's delivery ceiling before enabling it on a busy subreddit.",
194
+ "What to watch in the named subreddits: 'post' (default when omitted), 'comment', or 'both'. Comment monitoring requires a Growth, Pro or Scale plan; on a lower tier this returns `comment_monitoring_requires_higher_tier` (402). Comments run roughly 7x the volume of posts, so deliveries rise proportionally; the daily delivery ceiling per monitor is reported by monitor health.",
195
195
  ),
196
- min_score: z.number().int().optional().describe("Only match posts with at least this many upvotes."),
196
+ min_score: z.number().int().optional().describe("Matches only posts with at least this many upvotes."),
197
197
  min_relevance: z.number().int().min(0).max(100).optional().describe(
198
- "AI relevance floor, 0-100. 0 (the default) is off. Above 0, every match is scored by a language model against this monitor's own keywords and anything below the floor is NOT delivered -- it is recorded in your delivery history with status 'suppressed' and reason 'low_relevance', carrying its score and a one-line explanation, so you can always read what was filtered and why. Nothing is silently discarded. Rough calibration: 80-100 squarely on topic, 50-79 related but peripheral, 20-49 tangential, 0-19 the keyword is used in an unrelated sense. The comparison is inclusive, so a score equal to the floor is delivered. If scoring is unavailable the match is delivered UNSCORED rather than withheld. Requires a Growth, Pro or Scale plan -- on a lower tier this returns `ai_relevance_requires_higher_tier` (402). REJECTED with a field-level 400 on a monitor that has no q, include_any or include_all, because there would be no topic to score an item against and the floor could only ever admit everything.",
198
+ "AI relevance floor, 0-100. 0 (the default) is off. Above 0, every match is scored by a language model against this monitor's own keywords and anything below the floor is NOT delivered: it is recorded in the delivery history with status 'suppressed' and reason 'low_relevance', carrying its score and a one-line explanation, so what was filtered, and why, stays readable. Nothing is silently discarded. Rough calibration: 80-100 squarely on topic, 50-79 related but peripheral, 20-49 tangential, 0-19 the keyword is used in an unrelated sense. The comparison is inclusive, so a score equal to the floor is delivered. If scoring is unavailable the match is delivered UNSCORED rather than withheld. Requires a Growth, Pro or Scale plan; on a lower tier this returns `ai_relevance_requires_higher_tier` (402). REJECTED with a field-level 400 on a monitor that has no q, include_any or include_all, because there would be no topic to score an item against and the floor could only admit everything.",
199
199
  ),
200
- nsfw: z.boolean().optional().describe("Set false to EXCLUDE NSFW/over-18 posts. Omitted or true both mean NSFW is allowed through -- there is no exclude-by-default; you must explicitly pass false to filter it out. POSTS ONLY: nsfw=false is REJECTED with a field-level 400 when kind is 'comment' or 'both', because Reddit flags NSFW on a post and never on an individual comment, so there is no field to filter a comment on. Run a kind='post' monitor to keep NSFW filtering, and cut unwanted comment text with exclude_terms."),
200
+ nsfw: z.boolean().optional().describe("false EXCLUDES NSFW/over-18 posts. Omitted or true both let NSFW through; there is no exclude-by-default, and only an explicit false filters it. POSTS ONLY: nsfw=false is REJECTED with a field-level 400 when kind is 'comment' or 'both', because Reddit flags NSFW on a post and not on an individual comment, so there is no field to filter a comment on. NSFW filtering is available on kind='post' monitors, and exclude_terms filters comment text."),
201
201
  };
202
202
  // Per-monitor webhook targeting (task #66, migration 009). NOT a filter_spec
203
203
  // field -- stays top-level in the request body, same as cadence_s/active, so
204
204
  // it is deliberately absent from MONITOR_FILTER_FIELDS/filterSpecFields.
205
205
  const MONITOR_WEBHOOK_IDS = {
206
206
  webhook_ids: z.array(z.string().min(1)).max(20).optional().describe(
207
- "Restrict delivery to specific webhook(s) instead of every active webhook on the account. Pass id(s) from reddit_monitor_webhook_create/reddit_monitor_webhook_list. Omit (or pass an empty array) for the default: deliver to every active webhook you've registered. Every id must be a webhook you own -- returns `webhook_not_found` (400) otherwise.",
207
+ "Webhook ids that restrict this monitor's delivery to those webhooks. Omitted or an empty array means the default: delivery to every active webhook on the account. An id that is not a webhook on this account returns `webhook_not_found` (400).",
208
208
  ),
209
209
  };
210
210
  const MONITOR_ID = {
211
- id: z.string().min(1).describe("The monitor's id, from reddit_monitor_add's response or reddit_monitor_list."),
211
+ id: z.string().min(1).describe("The monitor's id, as returned when the monitor was created or listed."),
212
212
  };
213
213
  const WEBHOOK_ID = {
214
- id: z.string().min(1).describe("The webhook's id, from reddit_monitor_webhook_create's response or reddit_monitor_webhook_list."),
214
+ id: z.string().min(1).describe("The webhook's id, as returned when the webhook was created or listed."),
215
215
  };
216
216
 
217
217
  // The tool catalog. Path params are {name}/{id} placeholders. Monitor/webhook
@@ -222,7 +222,7 @@ export const TOOLS = [
222
222
  name: "reddit_subreddit_posts",
223
223
  path: "/api/reddit/posts",
224
224
  description:
225
- "List posts from a subreddit by sort order. Use this to read a community's feed: newest, hot/trending, top-of-week, rising, etc. Returns post title, author, score, comment count, and permalink, plus an `after` cursor for paging. When `after` comes back null the response carries `listing_status`: `complete` means no older posts and is only claimed when the whole run came back in under one page, `truncated` means Reddit's cap cut you off, and `unknown` means we cannot tell, so do NOT report `unknown` as the end of the data. A busy feed that Reddit simply stops serving reports `unknown`, not `complete`, so treat `unknown` as an incomplete answer and widen across sorts, timeframes or search rather than paging deeper. Example: subreddit='programming' sort='top' t='week'.",
225
+ "Lists posts from a subreddit by sort order: newest, hot, top of a time window, rising, controversial or best. Returns post title, author, score, comment count, and permalink, plus an `after` cursor for paging. When `after` comes back null the response carries `listing_status`: `complete` means no older posts and is only claimed when the whole run came back in under one page, `truncated` means Reddit's cap cut the listing off, and `unknown` means completeness could not be established. A busy feed that Reddit stops serving reports `unknown`, not `complete`. Example: subreddit='programming' sort='top' t='week'.",
226
226
  shape: {
227
227
  subreddit: z.string().min(1).describe(
228
228
  "Subreddit name WITHOUT the r/ prefix (e.g. 'programming', 'AskReddit'). Required.",
@@ -238,10 +238,10 @@ export const TOOLS = [
238
238
  path: "/api/reddit/comments/verify",
239
239
  method: "POST",
240
240
  description:
241
- "Check whether specific Reddit comments still EXIST and are publicly visible, in one batch of up to 100 ids. A READ despite being a POST (ids travel in the body because a hundred of them do not fit in a URL); it costs the same as any other read and changes nothing on Reddit. Use it to tell 'deleted by the author' from 'removed by a moderator' from 'still there', which a normal comment fetch cannot distinguish, and to re-check a list of comments you posted or collected earlier. Accepts bare ids and t1_-prefixed fullnames interchangeably. Returns one row per id, in the order you sent them, each with a status. Example: ids=['n5abcde','t1_n5fghij'].",
241
+ "Checks whether specific Reddit comments still EXIST and are publicly visible, in one batch of up to 100 ids. A READ despite being a POST (ids travel in the body because a hundred of them exceed a URL's length); it costs the same as any other read and changes nothing on Reddit. It distinguishes 'deleted by the author', 'removed by a moderator' and 'still there', which a normal comment fetch cannot. Accepts bare ids and t1_-prefixed fullnames interchangeably. Returns one row per id, in request order, each with a status. Example: ids=['n5abcde','t1_n5fghij'].",
242
242
  shape: {
243
243
  ids: z.array(z.string().min(1)).min(1).max(100).describe(
244
- "Comment ids to check, 1 to 100 per call. Bare id ('n5abcde') or fullname ('t1_n5abcde'), mixed freely. Reddit's own batch lookup caps at 100; split larger lists across calls. Required.",
244
+ "Comment ids to check, 1 to 100 per call. Bare id ('n5abcde') or fullname ('t1_n5abcde'), mixed freely. Reddit's own batch lookup caps at 100 per call. Required.",
245
245
  ),
246
246
  },
247
247
  },
@@ -250,7 +250,7 @@ export const TOOLS = [
250
250
  path: "/api/reddit/feed",
251
251
  sessionHeaders: true,
252
252
  description:
253
- "Read YOUR OWN Reddit home feed, the front page your subscriptions produce. Every other read tool here is served from a shared pool of accounts, so it cannot answer 'what is on my feed' -- this one sends your session instead. REQUIRES your own Reddit session: call POST /api/reddit/login on the REST API first (not an MCP tool) and pass the `reddit_session` and `loid` cookies it returns. Without them this returns 400, deliberately, because Reddit's logged-out front page is a different feed belonging to nobody rather than a thinner version of yours. Same post shape and `after` cursor as reddit_subreddit_posts. For a PUBLIC community feed use reddit_subreddit_posts instead. Example: sort='best' limit=25.",
253
+ "Reads the caller's own Reddit home feed, the front page their subscriptions produce. The other read tools are served from a shared pool of accounts and so cannot return a personal feed; this one sends the caller's session. Requires the caller's Reddit session cookies (session and loid) from POST /api/reddit/login on the REST API, which is not an MCP tool. Without them it returns 400, because Reddit's logged-out front page is a different feed belonging to nobody rather than a thinner version of the caller's. Same post shape and `after` cursor as the subreddit listing. Example: sort='best' limit=25.",
254
254
  shape: {
255
255
  ...SESSION_HEADERS_REQUIRED,
256
256
  sort: z.enum(["best", "hot", "new", "top", "rising", "controversial"]).optional().describe(
@@ -265,42 +265,36 @@ export const TOOLS = [
265
265
  name: "reddit_search",
266
266
  path: "/api/reddit/search",
267
267
  description:
268
- "Search Reddit posts across all of Reddit or within one subreddit. Returns matching posts with author, score, comments, permalink, and an `after` cursor. Use for topic/keyword research, brand monitoring, or finding discussions. Scope to a community with `subreddit`. Optional advanced filters narrow the results by minimum/maximum score, comment count, media type, and post flags, with an optional re-sort of the page. Because filters are applied to the returned page, the response then carries a `meta` object with page-completeness counts, so a filtered result is never mistaken for the whole set; paginate with `after` to filter more. SORT CAVEAT, measured 2026-09-11: `top`, `new` and `comments` order the MATCHING set by score, date or comment count alone, and Reddit matches loosely (image OCR, comments, and the word 'reddit' is in almost every big post), so a generic multi-word query with sort='top' and no subreddit returns the site-wide viral listing, not the topic. Even a distinctive term is ranked by score alone (q='pgvector' sort='top': 1 of 5 results was about pgvector, the rest were large posts that mention it once). For best-match-then-highest-score, fetch with sort='relevance' and a large `limit`, then sort_type='score' re-orders that returned page (it never reaches past the page), or quote the phrase (q='\"rust vs go\"'), or scope with `subreddit`. Example: q='rust vs go' sort='relevance' t='year' limit=100 sort_type='score'.",
268
+ "Searches Reddit posts across all of Reddit or within one subreddit. Returns matching posts with author, score, comments, permalink, and an `after` cursor. `subreddit` scopes the search to one community. Optional advanced filters narrow the results by minimum/maximum score, comment count, media type, and post flags, with an optional re-sort of the page. Filters apply to the returned page, so a filtered response carries a `meta` object with page-completeness counts, and the next page comes from `after`. SORT BEHAVIOUR, measured 2026-09-11: `top`, `new` and `comments` order the MATCHING set by score, date or comment count alone, and Reddit matches loosely (image OCR, comments, and the word 'reddit' is in almost every big post), so a generic multi-word query with sort='top' and no subreddit returns the site-wide viral listing, not the topic. Even a distinctive term is ranked by score alone (q='pgvector' sort='top': 1 of 5 results was about pgvector, the rest were large posts that mention it once). Best match then highest score comes from sort='relevance' with a large `limit`, where sort_type='score' re-orders that returned page (it does not reach past the page); a quoted phrase (q='\"rust vs go\"') or a `subreddit` scope also narrows the matching set. Example: q='rust vs go' sort='relevance' t='year' limit=100 sort_type='score'.",
269
269
  shape: {
270
270
  ...QUERY,
271
271
  subreddit: z.string().optional().describe(
272
- "Optional subreddit name (without r/) to restrict the search to one community. Omit to search all of Reddit.",
272
+ "Optional subreddit name (without r/) that restricts the search to one community. Absent, the search covers all of Reddit.",
273
273
  ),
274
274
  ...SORT_SEARCH,
275
275
  ...TIME_SEARCH,
276
276
  ...AFTER,
277
277
  ...NSFW,
278
278
  ...LIMIT,
279
- min_score: z.number().int().optional().describe("Keep only posts with score >= this (applied to the returned page)."),
280
- max_score: z.number().int().optional().describe("Keep only posts with score <= this."),
281
- min_comments: z.number().int().optional().describe("Keep only posts with comment count >= this."),
282
- max_comments: z.number().int().optional().describe("Keep only posts with comment count <= this."),
279
+ min_score: z.number().int().optional().describe("Keeps only posts with score >= this (applied to the returned page)."),
280
+ max_score: z.number().int().optional().describe("Keeps only posts with score <= this."),
281
+ min_comments: z.number().int().optional().describe("Keeps only posts with comment count >= this."),
282
+ max_comments: z.number().int().optional().describe("Keeps only posts with comment count <= this."),
283
283
  is_video: z.boolean().optional().describe("true = only video posts, false = only non-video."),
284
284
  is_self: z.boolean().optional().describe("true = only self/text posts, false = only link posts."),
285
- over_18: z.boolean().optional().describe("Filter the page by NSFW flag (distinct from nsfw, which controls inclusion in the search)."),
286
- locked: z.boolean().optional().describe("Filter by the locked flag."),
287
- stickied: z.boolean().optional().describe("Filter by the stickied flag."),
288
- spoiler: z.boolean().optional().describe("Filter by the spoiler flag."),
289
- contest_mode: z.boolean().optional().describe("Filter by the contest_mode flag."),
290
- sort_type: z.enum(["score", "num_comments", "created"]).optional().describe("Re-sort the filtered page (descending) by this field. Page-local: it re-orders only the posts this call returned, never the whole result set, so pair it with a large `limit`."),
285
+ over_18: z.boolean().optional().describe("Filters the page by NSFW flag (distinct from nsfw, which controls inclusion in the search)."),
286
+ locked: z.boolean().optional().describe("Filters by the locked flag."),
287
+ stickied: z.boolean().optional().describe("Filters by the stickied flag."),
288
+ spoiler: z.boolean().optional().describe("Filters by the spoiler flag."),
289
+ contest_mode: z.boolean().optional().describe("Filters by the contest_mode flag."),
290
+ sort_type: z.enum(["score", "num_comments", "created"]).optional().describe("Re-sorts the filtered page (descending) by this field. Page-local: it re-orders only the posts this call returned, not the whole result set, so its reach is bounded by `limit`."),
291
291
  },
292
292
  },
293
293
  {
294
294
  name: "reddit_post_visibility",
295
- path: "/api/reddit/post/:id/visibility",
296
- description:
297
- "Is a post still publicly visible, or did it quietly stop being so? A removed Reddit post still " +
298
- "returns when you fetch it by id, so asking the post does not answer this. This fetches the post " +
299
- "and then one page of its author's submitted listing and compares them. Returns a verdict of live, " +
300
- "not_visible or undecidable, a plain-language reason, and a confident flag. It deliberately never " +
301
- "says WHY a post is not visible: a moderator removal, an admin removal, a spam filter and an author " +
302
- "who has hidden their history are indistinguishable from outside. undecidable is a real answer, not " +
303
- "a failure. Two upstream calls, billed as one $0.004 dual read.",
295
+ path: "/api/reddit/post/{id}/visibility",
296
+ description:
297
+ "Is a post still publicly visible, or did it quietly stop being so? A removed Reddit post still returns when fetched by id, so the post alone does not answer this. This fetches the post and then one page of its author's submitted listing and compares them. Returns a verdict of live, not_visible or undecidable, a plain-language reason, and a confident flag. By design it does not say WHY a post is not visible: a moderator removal, an admin removal, a spam filter and an author who has hidden their history are indistinguishable from outside. undecidable is a real answer, not a failure. Two upstream calls, billed as one $0.004 dual read.",
304
298
  shape: {
305
299
  id: z.string().min(1).describe("Reddit post id, base36, with or without the t3_ prefix"),
306
300
  },
@@ -309,7 +303,7 @@ export const TOOLS = [
309
303
  name: "reddit_post_comments",
310
304
  path: "/api/reddit/comments",
311
305
  description:
312
- "Fetch a single post and its comment tree by permalink. Returns the post plus threaded comments (author, body, score, replies) and an `after` cursor. Use after finding a post via search/listing to read the full discussion. Pass the post's `permalink` from a prior result.",
306
+ "Fetches a single post and its comment tree by permalink. Returns the post plus threaded comments (author, body, score, replies) and an `after` cursor. The `permalink` is the one carried by any post result.",
313
307
  shape: {
314
308
  permalink: z.string().min(1).describe(
315
309
  "The post permalink path from a prior post result, e.g. '/r/programming/comments/abc123/some_title/'. Required.",
@@ -320,21 +314,21 @@ export const TOOLS = [
320
314
  name: "reddit_search_communities",
321
315
  path: "/api/reddit/search/communities",
322
316
  description:
323
- "Search for subreddits (communities) by name or topic. Returns matching subreddits with title, subscriber count, description, and NSFW flag. Use to discover where a topic is discussed before listing or searching its posts. Example: q='machine learning'.",
317
+ "Searches for subreddits (communities) by name or topic. Returns matching subreddits with title, subscriber count, description, and NSFW flag. Example: q='machine learning'.",
324
318
  shape: { ...QUERY, ...AFTER, ...NSFW, ...LIMIT },
325
319
  },
326
320
  {
327
321
  name: "reddit_search_comments",
328
322
  path: "/api/reddit/search/comments",
329
323
  description:
330
- "Search Reddit by COMMENT text. Reddit's comment search matches your keyword against comment bodies but returns the PARENT POSTS, not the individual comments, so each result is a post whose discussion mentions your query, carrying that post's title, selftext, score, and comment count. Use it to surface threads where a topic comes up in the replies that plain post-title search would miss. Reddit does not expose which specific comment matched or its text, so this returns posts, not comment bodies. For the actual comment bodies, use reddit_deep_comment_search. Example: q='best mechanical keyboard' sort='relevance' t='year'.",
324
+ "Searches Reddit by COMMENT text. Reddit's comment search matches the keyword against comment bodies but returns the PARENT POSTS, not the individual comments, so each result is a post whose discussion mentions the query, carrying that post's title, selftext, score, and comment count. It surfaces threads where a topic comes up in the replies, which a post-title search misses. Reddit does not expose which specific comment matched or its text, so the results are posts, not comment bodies. Example: q='best mechanical keyboard' sort='relevance' t='year'.",
331
325
  shape: { ...QUERY, ...SORT_SEARCH, ...TIME_SEARCH, ...AFTER, ...NSFW, ...LIMIT },
332
326
  },
333
327
  {
334
328
  name: "reddit_deep_comment_search",
335
329
  path: "/api/reddit/search/comments/deep",
336
330
  description:
337
- "Genuine comment search: returns the ACTUAL comments whose body matches your keyword, sorted by score (highest first), with body, score, author, a comment-deep permalink, and the parent post. Unlike reddit_search_comments (which returns the parent posts, a Reddit limitation), this fetches each matching post's comment tree and filters the comment bodies for you, so you get first-hand opinions and answers directly. Premium call (it fans out into several reads): `limit` sets how many parent POSTS to expand, 1-25 (default 5), not how many comments come back. To go deeper than one call, paginate: pass the response's `after` cursor back as `after` to expand the NEXT batch of parent posts. `max_comments` optionally caps how many comments come back (the top-scored are kept). Matching is on the visible comment text at word boundaries (link URLs are ignored), so a result always mentions your query where a reader can see it. Best-effort: a deleted or deeply-nested comment may be missed (meta.truncated flags when a tree was too deep). Set group_by='author' for the research mode that returns WHO is talking about your query (distinct people ranked by matching-comment count) instead of a flat comment list, capped by max_authors. Example: q='best mechanical keyboard' sort='relevance' t='year'.",
331
+ "Comment search that returns the ACTUAL comments whose body matches the keyword, sorted by score (highest first), with body, score, author, a comment-deep permalink, and the parent post. It fetches each matching post's comment tree and filters the comment bodies, so the results are first-hand opinions and answers rather than parent posts. Premium call (it fans out into several reads): `limit` sets how many parent POSTS to expand, 1-25 (default 5), not how many comments come back. The response's `after` cursor expands the NEXT batch of parent posts. `max_comments` optionally caps how many comments come back (the top-scored are kept). Matching is on the visible comment text at word boundaries (link URLs are ignored), so every result mentions the query where a reader can see it. Best-effort: a deleted or deeply-nested comment may be missed (meta.truncated flags when a tree was too deep). group_by='author' switches to a research mode that returns WHO is talking about the query (distinct people ranked by matching-comment count) in place of a flat comment list, capped by max_authors. Example: q='best mechanical keyboard' sort='relevance' t='year'.",
338
332
  shape: {
339
333
  ...QUERY,
340
334
  ...SORT_SEARCH,
@@ -342,16 +336,16 @@ export const TOOLS = [
342
336
  ...NSFW,
343
337
  ...AFTER,
344
338
  limit: z.number().int().min(1).max(25).optional().describe(
345
- "Number of parent POSTS to expand into their comment trees (1-25, default 5). Each is one upstream read, so higher = deeper coverage but slower and more expensive. To go past 25, paginate with `after`.",
339
+ "Number of parent POSTS to expand into their comment trees (1-25, default 5). Each is one upstream read, so higher = deeper coverage but slower and more expensive. Beyond 25, the `after` cursor pages to the next batch.",
346
340
  ),
347
341
  max_comments: z.number().int().min(1).optional().describe(
348
- "Optional cap on how many comments are returned; the highest-scored are kept. Omit to return every match. meta.capped is true when this trimmed the result.",
342
+ "Optional cap on how many comments are returned; the highest-scored are kept. Absent, every match is returned. meta.capped is true when this trimmed the result.",
349
343
  ),
350
344
  group_by: z.enum(["author"]).optional().describe(
351
- "Set to 'author' for the RESEARCH mode: instead of a flat comment list, return the distinct PEOPLE who mentioned your query, ranked by how many of their comments matched (then total score). Each author has comment_count, total_score, the subreddits they matched in, and their top comment. Omit for the normal comment list.",
345
+ "'author' selects the RESEARCH mode: the distinct PEOPLE who mentioned the query, ranked by how many of their comments matched (then total score), in place of a flat comment list. Each author has comment_count, total_score, the subreddits they matched in, and their top comment. Absent, the normal comment list is returned.",
352
346
  ),
353
347
  max_authors: z.number().int().min(1).optional().describe(
354
- "Only with group_by='author'. Optional cap on how many people are returned (the most prolific first). Omit to return everyone. meta.authors_capped is true when this trimmed the list.",
348
+ "Applies only with group_by='author'. Optional cap on how many people are returned (the most prolific first). Absent, every author is returned. meta.authors_capped is true when this trimmed the list.",
355
349
  ),
356
350
  },
357
351
  },
@@ -359,7 +353,7 @@ export const TOOLS = [
359
353
  name: "reddit_search_media",
360
354
  path: "/api/reddit/search/media",
361
355
  description:
362
- "Search Reddit posts filtered to media (images, video, gifs). Returns media posts with the media URL/type, author, score, and the post url. Use `kind` to narrow to a media type. Example: q='aurora borealis' kind='image'.",
356
+ "Searches Reddit posts filtered to media (images, video, gifs). Returns media posts with the media URL/type, author, score, and the post url. `kind` narrows the results to one media type. Example: q='aurora borealis' kind='image'.",
363
357
  shape: {
364
358
  ...QUERY,
365
359
  kind: z.enum(["image", "video", "gif", "all"]).optional().describe(
@@ -376,14 +370,14 @@ export const TOOLS = [
376
370
  name: "reddit_search_users",
377
371
  path: "/api/reddit/search/users",
378
372
  description:
379
- "Search for Reddit users (redditors) by name or keyword. Returns matching accounts with username, karma, and account age. Use to find a person's handle before fetching their profile or comments. Example: q='spez'.",
373
+ "Searches for Reddit users (redditors) by name or keyword. Returns matching accounts with username, karma, and account age. Example: q='spez'.",
380
374
  shape: { ...QUERY, ...AFTER, ...NSFW, ...LIMIT },
381
375
  },
382
376
  {
383
377
  name: "reddit_subreddit_top",
384
378
  path: "/api/reddit/sub/{name}/top",
385
379
  description:
386
- "Get the TOP posts of a subreddit for a time window. Shorthand for the highest-scoring posts of a community. Returns posts with score, author, comments, and permalink plus an `after` cursor. Example: name='science' t='month'.",
380
+ "Gets the TOP posts of a subreddit for a time window, the highest-scoring posts of a community. Returns posts with score, author, comments, and permalink plus an `after` cursor. Example: name='science' t='month'.",
387
381
  shape: {
388
382
  name: z.string().min(1).describe(
389
383
  "Subreddit name WITHOUT the r/ prefix (e.g. 'science'). Required (path parameter).",
@@ -397,7 +391,7 @@ export const TOOLS = [
397
391
  name: "reddit_post",
398
392
  path: "/api/reddit/post/{id}",
399
393
  description:
400
- "Fetch a single Reddit post by its id. Returns the full post object (title, author, score, text, permalink, subreddit, url). Use when you already have a post id and want its details. Example: id='abc123' (the base-36 id, no t3_ prefix).",
394
+ "Fetches a single Reddit post by its id. Returns the full post object (title, author, score, text, permalink, subreddit, url). Example: id='abc123' (the base-36 id, no t3_ prefix).",
401
395
  shape: {
402
396
  id: z.string().min(1).describe(
403
397
  "The post's base-36 id (e.g. 'abc123'), without the 't3_' fullname prefix. Required (path parameter).",
@@ -408,7 +402,7 @@ export const TOOLS = [
408
402
  name: "reddit_user_profile",
409
403
  path: "/api/reddit/user/{name}",
410
404
  description:
411
- "Fetch a Reddit user's public profile by username. Returns account info: username, id, karma (post + comment), account age, verified/employee flags, and avatar. Use to vet or summarize a redditor. Example: name='spez'.",
405
+ "Fetches a Reddit user's public profile by username. Returns account info: username, id, karma (post + comment), account age, verified/employee flags, and avatar. Example: name='spez'.",
412
406
  shape: {
413
407
  name: z.string().min(1).describe(
414
408
  "Reddit username WITHOUT the u/ prefix (e.g. 'spez'). Required (path parameter).",
@@ -419,7 +413,7 @@ export const TOOLS = [
419
413
  name: "reddit_user_achievements",
420
414
  path: "/api/reddit/user/{name}/achievements",
421
415
  description:
422
- "List a Reddit user's public achievements, the trophies shown on their reddit.com/user/<name>/achievements page. Returns each achievement's name, description, granted timestamp and icons, plus a count. An account with none returns an empty list rather than an error, so a zero count is a real answer. Use to gauge account age and standing (One-Year Club, Verified Email) or to check a moderator's history. Example: name='spez'.",
416
+ "Lists a Reddit user's public achievements, the trophies shown on their reddit.com/user/<name>/achievements page. Returns each achievement's name, description, granted timestamp and icons, plus a count. An account with none returns an empty list rather than an error, so a zero count is a real answer. Achievements such as One-Year Club and Verified Email reflect account age and standing. Example: name='spez'.",
423
417
  shape: {
424
418
  name: z.string().min(1).describe(
425
419
  "Reddit username WITHOUT the u/ prefix (e.g. 'spez'). Required (path parameter).",
@@ -430,7 +424,7 @@ export const TOOLS = [
430
424
  name: "reddit_user_comments",
431
425
  path: "/api/reddit/user/{name}/comments",
432
426
  description:
433
- "List a Reddit user's recent comments. Returns comments with body, score, subreddit, parent link, and timestamp plus an `after` cursor. Use to understand what a redditor talks about or to gather their opinions. Example: name='spez' sort='top'.",
427
+ "Lists a Reddit user's recent comments. Returns comments with body, score, subreddit, parent link, and timestamp plus an `after` cursor. Example: name='spez' sort='top'.",
434
428
  shape: {
435
429
  name: z.string().min(1).describe(
436
430
  "Reddit username WITHOUT the u/ prefix (e.g. 'spez'). Required (path parameter).",
@@ -444,7 +438,7 @@ export const TOOLS = [
444
438
  name: "reddit_user_submitted",
445
439
  path: "/api/reddit/user/{name}/submitted",
446
440
  description:
447
- "List a Reddit user's submitted POSTS (their post history, the sibling of reddit_user_comments). Returns posts with title, author, score, comment count, and permalink, plus an `after` cursor. Use to see what a redditor posts, not just what they comment on. Example: name='spez' sort='top'.",
441
+ "Lists a Reddit user's submitted POSTS (their post history, as distinct from their comments). Returns posts with title, author, score, comment count, and permalink, plus an `after` cursor. Example: name='spez' sort='top'.",
448
442
  shape: {
449
443
  name: z.string().min(1).describe(
450
444
  "Reddit username WITHOUT the u/ prefix (e.g. 'spez'). Required (path parameter).",
@@ -459,10 +453,10 @@ export const TOOLS = [
459
453
  name: "reddit_user_upvoted",
460
454
  path: "/api/reddit/user/{name}/upvoted",
461
455
  description:
462
- "List the posts and comments a Reddit account has UPVOTED. PRIVATE data -- Reddit only serves it to the account that owns it, so you must be logged in as `name` (see the `reddit_session`/`loid` args) or this returns 403. Mixed listing: each item is a post or a comment, tagged `kind`. Requires calling POST /api/reddit/login on the REST API first to get session cookies (not an MCP tool). Example: name='spez' (must match the logged-in account).",
456
+ "Lists the posts and comments a Reddit account has UPVOTED. PRIVATE data: Reddit serves it only to the account that owns it, so the request carries that account's Reddit session cookies (the session and loid arguments, from POST /api/reddit/login on the REST API, which is not an MCP tool) and returns 403 when `name` is a different account. Mixed listing: each item is a post or a comment, tagged `kind`. Example: name='spez' (the logged-in account).",
463
457
  shape: {
464
458
  name: z.string().min(1).describe(
465
- "Reddit username WITHOUT the u/ prefix. Must be the SAME account the supplied cookies belong to, or Reddit returns 403. Required (path parameter).",
459
+ "Reddit username WITHOUT the u/ prefix: the SAME account the supplied cookies belong to, or Reddit returns 403. Required (path parameter).",
466
460
  ),
467
461
  ...LISTING_COOKIES,
468
462
  ...SORT_USER,
@@ -475,10 +469,10 @@ export const TOOLS = [
475
469
  name: "reddit_user_saved",
476
470
  path: "/api/reddit/user/{name}/saved",
477
471
  description:
478
- "List the posts and comments a Reddit account has SAVED. PRIVATE data -- Reddit only serves it to the account that owns it, so you must be logged in as `name` (see the `reddit_session`/`loid` args) or this returns 403. Mixed listing: each item is a post or a comment, tagged `kind`. Requires calling POST /api/reddit/login on the REST API first to get session cookies (not an MCP tool). Example: name='spez' (must match the logged-in account).",
472
+ "Lists the posts and comments a Reddit account has SAVED. PRIVATE data: Reddit serves it only to the account that owns it, so the request carries that account's Reddit session cookies (the session and loid arguments, from POST /api/reddit/login on the REST API, which is not an MCP tool) and returns 403 when `name` is a different account. Mixed listing: each item is a post or a comment, tagged `kind`. Example: name='spez' (the logged-in account).",
479
473
  shape: {
480
474
  name: z.string().min(1).describe(
481
- "Reddit username WITHOUT the u/ prefix. Must be the SAME account the supplied cookies belong to, or Reddit returns 403. Required (path parameter).",
475
+ "Reddit username WITHOUT the u/ prefix: the SAME account the supplied cookies belong to, or Reddit returns 403. Required (path parameter).",
482
476
  ),
483
477
  ...LISTING_COOKIES,
484
478
  ...SORT_USER,
@@ -491,10 +485,10 @@ export const TOOLS = [
491
485
  name: "reddit_user_hidden",
492
486
  path: "/api/reddit/user/{name}/hidden",
493
487
  description:
494
- "List the posts and comments a Reddit account has HIDDEN. PRIVATE data -- Reddit only serves it to the account that owns it, so you must be logged in as `name` (see the `reddit_session`/`loid` args) or this returns 403. Mixed listing: each item is a post or a comment, tagged `kind`. Requires calling POST /api/reddit/login on the REST API first to get session cookies (not an MCP tool). Example: name='spez' (must match the logged-in account).",
488
+ "Lists the posts and comments a Reddit account has HIDDEN. PRIVATE data: Reddit serves it only to the account that owns it, so the request carries that account's Reddit session cookies (the session and loid arguments, from POST /api/reddit/login on the REST API, which is not an MCP tool) and returns 403 when `name` is a different account. Mixed listing: each item is a post or a comment, tagged `kind`. Example: name='spez' (the logged-in account).",
495
489
  shape: {
496
490
  name: z.string().min(1).describe(
497
- "Reddit username WITHOUT the u/ prefix. Must be the SAME account the supplied cookies belong to, or Reddit returns 403. Required (path parameter).",
491
+ "Reddit username WITHOUT the u/ prefix: the SAME account the supplied cookies belong to, or Reddit returns 403. Required (path parameter).",
498
492
  ),
499
493
  ...LISTING_COOKIES,
500
494
  ...SORT_USER,
@@ -507,10 +501,10 @@ export const TOOLS = [
507
501
  name: "reddit_user_gilded",
508
502
  path: "/api/reddit/user/{name}/gilded",
509
503
  description:
510
- "List the posts and comments a Reddit account has received an award (gold) on. PRIVATE data -- Reddit only serves it to the account that owns it, so you must be logged in as `name` (see the `reddit_session`/`loid` args) or this returns 403. Mixed listing: each item is a post or a comment, tagged `kind`. Requires calling POST /api/reddit/login on the REST API first to get session cookies (not an MCP tool). Example: name='spez' (must match the logged-in account).",
504
+ "Lists the posts and comments a Reddit account has received an award (gold) on. PRIVATE data: Reddit serves it only to the account that owns it, so the request carries that account's Reddit session cookies (the session and loid arguments, from POST /api/reddit/login on the REST API, which is not an MCP tool) and returns 403 when `name` is a different account. Mixed listing: each item is a post or a comment, tagged `kind`. Example: name='spez' (the logged-in account).",
511
505
  shape: {
512
506
  name: z.string().min(1).describe(
513
- "Reddit username WITHOUT the u/ prefix. Must be the SAME account the supplied cookies belong to, or Reddit returns 403. Required (path parameter).",
507
+ "Reddit username WITHOUT the u/ prefix: the SAME account the supplied cookies belong to, or Reddit returns 403. Required (path parameter).",
514
508
  ),
515
509
  ...LISTING_COOKIES,
516
510
  ...SORT_USER,
@@ -523,7 +517,7 @@ export const TOOLS = [
523
517
  name: "reddit_subreddit_comments",
524
518
  path: "/api/reddit/sub/{name}/comments",
525
519
  description:
526
- "Stream the NEWEST comments across an entire subreddit (Reddit's /r/<name>/comments feed), not one post's thread. Returns comments with body, author, score, subreddit, the parent post link, and timestamp, plus an `after` cursor. Poll it to catch new comments in a community as they are posted. Example: name='python'.",
520
+ "Streams the NEWEST comments across an entire subreddit (Reddit's /r/<name>/comments feed), not one post's thread. Returns comments with body, author, score, subreddit, the parent post link, and timestamp, plus an `after` cursor. Repeated calls surface new comments in a community as they are posted. Example: name='python'.",
527
521
  shape: {
528
522
  name: z.string().min(1).describe(
529
523
  "Subreddit name WITHOUT the r/ prefix (e.g. 'python'). Required (path parameter).",
@@ -536,7 +530,7 @@ export const TOOLS = [
536
530
  name: "reddit_subreddit_about",
537
531
  path: "/api/reddit/sub/{name}/about",
538
532
  description:
539
- "Fetch a subreddit's public metadata by name (Reddit's /r/<name>/about data). Returns the subreddit's title, public description, subscriber count, active-user count, creation timestamp, type, and NSFW flag. Use it to size or vet a community before listing or searching its posts. Example: name='python'.",
533
+ "Fetches a subreddit's public metadata by name (Reddit's /r/<name>/about data). Returns the subreddit's title, public description, subscriber count, active-user count, creation timestamp, type, and NSFW flag. Example: name='python'.",
540
534
  shape: {
541
535
  name: z.string().min(1).describe(
542
536
  "Subreddit name WITHOUT the r/ prefix (e.g. 'python'). Required (path parameter).",
@@ -547,7 +541,7 @@ export const TOOLS = [
547
541
  name: "reddit_subreddit_rules",
548
542
  path: "/api/reddit/sub/{name}/rules",
549
543
  description:
550
- "Fetch a subreddit's posting rules by name (Reddit's /r/<name>/about/rules data). Returns a `rules` list, each with name, description, what it applies to (posts, comments, or all), violation reason, priority, and creation date, plus a `site_rules` list of Reddit's site-wide rules. Use it to check a community's rules before posting or commenting. Example: name='python'.",
544
+ "Fetches a subreddit's posting rules by name (Reddit's /r/<name>/about/rules data). Returns a `rules` list, each with name, description, what it applies to (posts, comments, or all), violation reason, priority, and creation date, plus a `site_rules` list of Reddit's site-wide rules. Example: name='python'.",
551
545
  shape: {
552
546
  name: z.string().min(1).describe(
553
547
  "Subreddit name WITHOUT the r/ prefix (e.g. 'python'). Required (path parameter).",
@@ -558,7 +552,7 @@ export const TOOLS = [
558
552
  name: "reddit_subreddit_moderators",
559
553
  path: "/api/reddit/sub/{name}/moderators",
560
554
  description:
561
- "Fetch a subreddit's moderator team by name (Reddit's /r/<name>/about/moderators data). Returns a `moderators` list, each with `name`, `id`, `mod_permissions`, `flair_text`, and `added` (when they joined the mod team). Use it to see who moderates a community. Example: name='python'.",
555
+ "Fetches a subreddit's moderator team by name (Reddit's /r/<name>/about/moderators data). Returns a `moderators` list, each with `name`, `id`, `mod_permissions`, `flair_text`, and `added` (when they joined the mod team). Example: name='python'.",
562
556
  shape: {
563
557
  name: z.string().min(1).describe(
564
558
  "Subreddit name WITHOUT the r/ prefix (e.g. 'python'). Required (path parameter).",
@@ -569,7 +563,7 @@ export const TOOLS = [
569
563
  name: "reddit_subreddit_wiki",
570
564
  path: "/api/reddit/sub/{name}/wiki/{page}",
571
565
  description:
572
- "Fetch a subreddit's wiki page by name and page (Reddit's /r/<name>/wiki/<page> data). Returns a single object with `content_md` and `content_html`, a `may_revise` flag, and the last revision (`revision_id`, `revision_date`, `revised_by`, `reason`). Use it to read a community's wiki, such as its rules or FAQ. The page may be multi-segment, for example index, rules, or config/sidebar. Example: name='python', page='index'.",
566
+ "Fetches a subreddit's wiki page by name and page (Reddit's /r/<name>/wiki/<page> data). Returns a single object with `content_md` and `content_html`, a `may_revise` flag, and the last revision (`revision_id`, `revision_date`, `revised_by`, `reason`). A wiki commonly holds a community's rules or FAQ. The page may be multi-segment, for example index, rules, or config/sidebar. Example: name='python', page='index'.",
573
567
  shape: {
574
568
  name: z.string().min(1).describe(
575
569
  "Subreddit name WITHOUT the r/ prefix (e.g. 'python'). Required (path parameter).",
@@ -583,7 +577,7 @@ export const TOOLS = [
583
577
  name: "reddit_by_id",
584
578
  path: "/api/reddit/by_id/{fullnames}",
585
579
  description:
586
- "Bulk-fetch posts by their t3_ fullnames in ONE call (up to 100), instead of a request per post. Pass a comma-separated list of fullnames you already have from a search or listing to hydrate them. Returns posts with title, author, score, comment count, and permalink, the same post shape as the listing endpoints. The result is NOT always one-to-one with your request, so read `meta`: `listing_status` is `complete` only when every fullname came back, `truncated` is the boolean to branch on, and `missing_fullnames` names exactly which ids did not. Example: fullnames='t3_abc123,t3_def456'.",
580
+ "Bulk-fetches posts by their t3_ fullnames in ONE call (up to 100), in place of a request per post. Takes a comma-separated list of fullnames from a search or listing. Returns posts with title, author, score, comment count, and permalink, the same post shape as the listing endpoints. The result is not necessarily one-to-one with the request, and `meta` reports which: `listing_status` is `complete` only when every fullname came back, `truncated` is a boolean, and `missing_fullnames` names exactly which ids did not come back. Example: fullnames='t3_abc123,t3_def456'.",
587
581
  shape: {
588
582
  fullnames: z.string().min(1).describe(
589
583
  "Comma-separated post fullnames, each a t3_ prefix followed by the base-36 id (e.g. 't3_abc123,t3_def456'). Up to 100. Required (path parameter).",
@@ -594,21 +588,21 @@ export const TOOLS = [
594
588
  name: "reddit_subreddits_popular",
595
589
  path: "/api/reddit/subreddits/popular",
596
590
  description:
597
- "Browse the most-subscribed, trending subreddits right now, no keyword needed. Returns a `subreddits` list (each with name, title, subscriber count, description, type, and NSFW flag) plus an `after` cursor for paging. This BROWSES communities by popularity; use reddit_search_communities instead to SEARCH communities by keyword.",
591
+ "Browses the most-subscribed, trending subreddits right now, with no keyword. Returns a `subreddits` list (each with name, title, subscriber count, description, type, and NSFW flag) plus an `after` cursor for paging.",
598
592
  shape: { ...AFTER, ...LIMIT },
599
593
  },
600
594
  {
601
595
  name: "reddit_subreddits_new",
602
596
  path: "/api/reddit/subreddits/new",
603
597
  description:
604
- "Browse the newest subreddits, the communities most recently created, no keyword needed. Returns a `subreddits` list (each with name, title, subscriber count, description, type, and NSFW flag) plus an `after` cursor for paging. This BROWSES communities by recency; use reddit_search_communities instead to SEARCH communities by keyword.",
598
+ "Browses the newest subreddits, the communities most recently created, with no keyword. Returns a `subreddits` list (each with name, title, subscriber count, description, type, and NSFW flag) plus an `after` cursor for paging.",
605
599
  shape: { ...AFTER, ...LIMIT },
606
600
  },
607
601
  {
608
602
  name: "reddit_subreddits_default",
609
603
  path: "/api/reddit/subreddits/default",
610
604
  description:
611
- "Browse Reddit's default front-page set of subreddits, no keyword needed. Returns a `subreddits` list (each with name, title, subscriber count, description, type, and NSFW flag) plus an `after` cursor for paging. This BROWSES the default communities; use reddit_search_communities instead to SEARCH communities by keyword.",
605
+ "Browses Reddit's default front-page set of subreddits, with no keyword. Returns a `subreddits` list (each with name, title, subscriber count, description, type, and NSFW flag) plus an `after` cursor for paging.",
612
606
  shape: { ...AFTER, ...LIMIT },
613
607
  },
614
608
 
@@ -620,15 +614,15 @@ export const TOOLS = [
620
614
  write: true,
621
615
  filterSpecFields: ["subreddit", "exclude_subreddits", "kind", "q", "author", "exclude_terms", "domain", "include_any", "include_all", "search_in", "group", "min_score", "min_relevance", "nsfw"],
622
616
  description:
623
- "Create a new Reddit monitor: watch one or more subreddits, or ALL of Reddit, for new posts (or comments, via `kind`) matching a filter, and get every match delivered to a webhook you've registered with reddit_monitor_webhook_create. Omit `subreddit` and set `q` for a sitewide keyword monitor covering every subreddit at once (posts only). By default matches go to EVERY active webhook on your account; pass `webhook_ids` to route this monitor's matches to only specific webhook(s). Every redditapis.com account holds a free entitlement of ONE all-of-Reddit post watch at a 60s cadence (up to 10,000 deliveries a day), so no subscription is needed to create that monitor. Naming a `subreddit`, matching comments, a faster cadence and any additional watch require a paid plan. Needs at least one monitor slot free (see reddit_monitor_list's `slots`). Forward-looking only from the moment of creation, or from `baseline_item_id` if given -- it never backfills posts that already existed. Returns the created monitor (with its `id`) on success, or `subscription_required` (402) if the account holds no recognised entitlement at all, `subreddit_scope_requires_paid_plan` (402) if a free account named a `subreddit`, `monitor_slots_exhausted` (402) if the plan's slot limit is reached, `sitewide_slots_exhausted` (402) if the plan's separate sitewide cap is reached, `distinct_subreddit_limit_reached` (402) if the account already watches as many DIFFERENT subreddits as the plan covers (the limit counts distinct subreddits across all your monitors, not monitors, and the same subreddit in two monitors counts once; see reddit_monitor_list's `slots.distinct_subreddits_total`), `sitewide_comment_monitoring_not_available` (501) if a sitewide monitor asks for comments, `subreddit_reserved` (400) if `subreddit` names 'all', `subreddit_not_found` (400) if a named subreddit does not exist, or `webhook_not_found` (400) if a `webhook_ids` entry is not yours.",
617
+ "Creates a Reddit monitor: watches one or more subreddits, or ALL of Reddit, for new posts (or comments, via `kind`) matching a filter, and delivers every match to the account's registered webhooks. With `subreddit` absent and `q` set, it is a sitewide keyword monitor covering every subreddit at once (posts only). By default matches go to EVERY active webhook on the account; `webhook_ids` routes this monitor's matches to specific webhooks. Every redditapis.com account holds a free entitlement of ONE all-of-Reddit post watch at a 60s cadence (up to 10,000 deliveries a day), so that monitor needs no subscription. Naming a `subreddit`, matching comments, a faster cadence and any additional watch require a paid plan. Needs at least one free monitor slot (the `slots` object in the monitor list). Forward-looking only, from the moment of creation or from `baseline_item_id` if given; posts that already existed are not backfilled. Returns the created monitor (with its `id`) on success, or `subscription_required` (402) if the account holds no recognised entitlement at all, `subreddit_scope_requires_paid_plan` (402) if a free account named a `subreddit`, `monitor_slots_exhausted` (402) if the plan's slot limit is reached, `sitewide_slots_exhausted` (402) if the plan's separate sitewide cap is reached, `distinct_subreddit_limit_reached` (402) if the account already watches as many DIFFERENT subreddits as the plan covers (the limit counts distinct subreddits across all the account's monitors, not monitors, and the same subreddit in two monitors counts once; `slots.distinct_subreddits_total` in the monitor list), `sitewide_comment_monitoring_not_available` (501) if a sitewide monitor asks for comments, `subreddit_reserved` (400) if `subreddit` names 'all', `subreddit_not_found` (400) if a named subreddit does not exist, or `webhook_not_found` (400) if a `webhook_ids` entry is not on this account.",
624
618
  shape: {
625
619
  ...MONITOR_SUBREDDIT,
626
620
  ...MONITOR_FILTER_FIELDS,
627
621
  cadence_s: z.number().int().positive().optional().describe(
628
- "Requested poll interval in seconds. May only be SLOWER than the plan tier's floor, never faster -- a too-low value is silently clamped up to the tier's minimum. Omit to use the tier's default.",
622
+ "Requested poll interval in seconds. A value at or above the plan tier's floor is honoured; a lower value is silently clamped up to the tier's minimum. Absent, the tier's default applies.",
629
623
  ),
630
624
  baseline_item_id: z.string().optional().describe(
631
- "A Reddit post fullname (e.g. 't3_abc123') to use as the starting point instead of 'now' -- matching starts strictly after this item. Omit to start from the moment of creation.",
625
+ "A Reddit post fullname (e.g. 't3_abc123') used as the starting point in place of 'now': matching starts strictly after this item. Absent, matching starts from the moment of creation.",
632
626
  ),
633
627
  ...MONITOR_WEBHOOK_IDS,
634
628
  },
@@ -637,7 +631,7 @@ export const TOOLS = [
637
631
  name: "reddit_monitor_list",
638
632
  path: "/api/reddit/monitor/list",
639
633
  description:
640
- "List every monitor on the caller's account, with each one's filter, active state, and cadence, plus a `slots` object ({used, total, tier}) showing how many monitor slots are purchased vs in use. Reading your own list never requires an active plan (a lapsed subscription shows an empty or paused list, not an error). `webhook_ids` NULL DOES NOT MEAN THE MONITOR HAS NO DESTINATION: null is the default and means matches go to EVERY active webhook on the account, which is the normal healthy state. A non-empty array narrows delivery to just those webhook ids. Never report a monitor as having no delivery target on the strength of a null here -- to see where a monitor's matches actually went, read reddit_monitor_deliveries, whose rows carry the resolved `webhook_id`.",
634
+ "Lists every monitor on the caller's account, with each one's filter, active state, and cadence, plus a `slots` object ({used, total, tier}) showing how many monitor slots are purchased vs in use. Reading the list requires no active plan (a lapsed subscription shows an empty or paused list, not an error). A null `webhook_ids` is the default and means matches go to EVERY active webhook on the account, the normal healthy state, not a monitor without a destination; a non-empty array narrows delivery to those webhook ids. Where a monitor's matches actually went is recorded per delivery, as the resolved `webhook_id` on each delivery row.",
641
635
  shape: {},
642
636
  },
643
637
  {
@@ -647,18 +641,18 @@ export const TOOLS = [
647
641
  write: true,
648
642
  filterSpecFields: ["subreddit", "exclude_subreddits", "kind", "q", "author", "exclude_terms", "domain", "include_any", "include_all", "search_in", "group", "min_score", "min_relevance", "nsfw"],
649
643
  description:
650
- "Update an existing monitor: pause/resume it (`active`), change its poll interval (`cadence_s`), switch between posts and comments (`kind`), replace its filter entirely, or re-target which webhook(s) it delivers to (`webhook_ids`). IMPORTANT: if you pass ANY filter field (subreddit, q, kind, domain, etc.), it REPLACES the whole filter, it does not merge with the existing one -- resupply every field you want kept, including `subreddit` AND `kind` (omitting `kind` reverts that monitor to posts-only). Same rule for `webhook_ids`: passing it REPLACES the monitor's targeting outright (an empty array clears back to 'every active webhook'); omitting it entirely leaves the monitor's existing targeting untouched. Omit all filter/webhook_ids fields to change only `active`/`cadence_s`. Returns 404 `monitor_not_found` if the id does not exist or is not yours, or 400 `webhook_not_found` if a `webhook_ids` entry is not yours.",
644
+ "Updates an existing monitor: pauses or resumes it (`active`), changes its poll interval (`cadence_s`), switches between posts and comments (`kind`), replaces its filter entirely, or re-targets which webhooks it delivers to (`webhook_ids`). Any filter field (subreddit, q, kind, domain, etc.) REPLACES the whole filter: it does not merge with the existing one, so a field absent from the request is cleared, and a request without `kind` reverts the monitor to posts-only. `webhook_ids` likewise REPLACES the monitor's targeting outright (an empty array clears back to every active webhook); a request without it leaves the existing targeting untouched. A request with only `active` and/or `cadence_s` changes only those. Returns 404 `monitor_not_found` if the id does not exist or is not on this account, or 400 `webhook_not_found` if a `webhook_ids` entry is not on this account.",
651
645
  shape: {
652
646
  ...MONITOR_ID,
653
647
  ...{
654
648
  subreddit: z.array(z.string().min(1)).min(1).max(50).optional().describe(
655
649
  MONITOR_SUBREDDIT_DESC +
656
- " On an update, omitting this while passing another filter field makes the monitor SITEWIDE (the filter is replaced wholesale, not merged), so resupply it if you meant to keep the monitor scoped.",
650
+ " On an update, a request that carries another filter field without this one makes the monitor SITEWIDE (the filter is replaced wholesale, not merged).",
657
651
  ),
658
652
  },
659
653
  ...MONITOR_FILTER_FIELDS,
660
- active: z.boolean().optional().describe("Set false to pause the monitor (stops matching/delivering), true to resume it."),
661
- cadence_s: z.number().int().positive().optional().describe("New poll interval in seconds, same tier-floor clamping as reddit_monitor_add."),
654
+ active: z.boolean().optional().describe("false pauses the monitor (stops matching and delivering); true resumes it."),
655
+ cadence_s: z.number().int().positive().optional().describe("New poll interval in seconds, clamped to the tier floor the same way as at creation."),
662
656
  ...MONITOR_WEBHOOK_IDS,
663
657
  },
664
658
  },
@@ -669,28 +663,28 @@ export const TOOLS = [
669
663
  write: true,
670
664
  destructive: true,
671
665
  description:
672
- "Delete a monitor. There is no undo endpoint -- it stops matching immediately, its slot is freed for a new monitor, and it disappears from reddit_monitor_list. Its past deliveries are NOT erased: they remain queryable via reddit_monitor_deliveries (both scoped by monitor_id and in the aggregate, no-id view) forever. Returns 404 `monitor_not_found` if the id does not exist or is not yours.",
666
+ "Deletes a monitor. There is no undo endpoint: it stops matching immediately, its slot is freed for a new monitor, and it disappears from the monitor list. Its past deliveries are NOT erased; they remain queryable in the delivery history (both by monitor id and in the aggregate, no-id view) indefinitely. Returns 404 `monitor_not_found` if the id does not exist or is not on this account.",
673
667
  shape: { ...MONITOR_ID },
674
668
  },
675
669
  {
676
670
  name: "reddit_monitor_health",
677
671
  path: "/api/reddit/monitor/health",
678
672
  description:
679
- "Per-monitor health: whether it's active, its poll cadence, when it last matched something, and delivery counts for the last 24h (`delivered_24h`, `failed_24h`, `suppressed_24h`) plus whether its delivery ceiling has been hit (`ceiling_reached`) and what that ceiling is (`daily_delivery_ceiling`). `suppressed_24h` COUNTS TWO DIFFERENT THINGS AND `suppressed_breakdown` SPLITS THEM: `ceiling` (the monitor's daily cap was reached) and `stale` (the item was already older than the freshness window when we first saw it, so it was withheld rather than delivered as if it were new). A stale withhold is the usual reason a monitor's delivered count sits far below its matched count with no error anywhere, and it is the freshness gate working as intended -- nothing failed, the endpoint is fine, no limit on the plan rejected them, and retrying cannot recover the items. Do NOT read `suppressed_24h > 0` as 'this monitor is over its limit': `ceiling_reached` is the field that answers that, and a stale withhold deliberately does not set it. When `suppressed_breakdown.resolved` is false the counts do not add up to the total and `unresolved_reason` says why, so do not report a split off them; `ceiling_reached` stays conservative in that case rather than being cleared. THOSE COUNTS ONLY COVER POSTS WE FETCHED. A post we never fetched was never matched and leaves no row behind, so it is absent from all three counters rather than counted in any of them -- zeros there mean 'no matches recorded', never 'nothing was missed'. `coverage_24h` is the separate field that speaks to fetching, and its `status` is the one to read first: `complete` (every poll reached the point where the previous poll finished), `degraded` (at least one poll was truncated and posts were lost unrecoverably -- see `gaps[]`, `posts_in_window_at_least` and the estimated `posts_missed_estimate`), `partial` (nothing found, but not every kind of loss was checked -- see `unobserved_events`), or `unknown` (the check could not be run at all -- see `reason`). Do NOT report a monitor as healthy on `unknown` or `partial`, and do not treat a null count as zero: both mean the question went unanswered. `coverage_24h` is fed by the poll log and covers BOTH ways a poll loses posts unrecoverably (`listing_exhausted`, where Reddit stopped serving older posts, and `poll_overflow`, where the feed outran one poll's page budget); `observed_events` names what was actually checked, and a status of `complete` is only ever awarded when both were. WHAT `complete` DOES AND DOES NOT ESTABLISH: it means every poll that RAN got back to where the previous poll finished, AND that every feed was actually polled. The second half is `stream_liveness`, the field to read FIRST. `coverage_24h` is computed from the poll log, and a feed that is never polled writes nothing to that log, so it records no truncated poll and the coverage read comes back clean -- identical to a healthy monitor. `stream_liveness` reads a different source (the polling registry plus the per-feed last-successful-poll stamp), neither of which a poll that never ran can produce. Its `status` is `live` (every feed checked within three times its own interval), `degraded` (at least one is not: see `streams[]` for `never_polled`, `stalled` or `unregistered` per feed), or `unknown` (could not be established, including `streams_awaiting_first_poll` on a monitor created moments ago, which clears itself). `coverage_24h` can never read `complete` while this is anything but `live`. Do NOT report a monitor as healthy unless `stream_liveness.status` is `live`, and do not read a zero delivery count on a `never_polled` feed as a quiet subreddit: nothing was checked, so nothing could match. `gaps[]` IS A SAMPLE, NOT THE WHOLE LIST: it carries the most recent gaps only, `gaps_returned` says how many are in it, `gaps_truncated` says whether more exist, and `gap_events_24h` is always the true total. Count gaps from `gap_events_24h` and never from the length of `gaps[]`. FINALLY, `cadence` ANSWERS 'AM I BEING CHECKED AS OFTEN AS I PAY TO BE', which none of the fields above can. Cadence is a priced feature, so this is the field that says whether the monitor is being served the interval its plan sells. `promised_cadence_s` is the floor the account's CURRENT plan includes and `requested_cadence_s` is what this monitor is actually set to; `meets_entitlement` false means the monitor is set slower than the plan allows, which happens because a plan upgrade does NOT re-cadence monitors that already exist -- the fix is to set `cadence_s` on the monitor, and a deliberately slower cadence is also a legitimate choice. `last_checked_s_ago`, `freshness_ratio` and `within_margin` describe the slowest feed feeding this monitor, named in `slowest_stream`. READ `freshness_reading` BEFORE QUOTING ANY OF THEM: it is ONE INSTANTANEOUS SAMPLE, not an average and not a sustained verdict, so a single reading past the margin is not by itself proof of under-service. Every one of these is TRI-STATE and null NEVER means fine: a null `within_margin` or `meets_entitlement` means the question could not be answered, and `unknown_reason` says why. Do not report a monitor as on-cadence on a null.",
673
+ "Per-monitor health: active state, poll cadence, when it last matched something, and delivery counts for the last 24h (`delivered_24h`, `failed_24h`, `suppressed_24h`), plus whether its delivery ceiling has been hit (`ceiling_reached`) and what that ceiling is (`daily_delivery_ceiling`). `suppressed_24h` COUNTS TWO DIFFERENT THINGS AND `suppressed_breakdown` SPLITS THEM: `ceiling` (the monitor's daily cap was reached) and `stale` (the item was already older than the freshness window when first seen, so it was withheld rather than delivered as if it were new). A stale withhold is the usual reason a monitor's delivered count sits far below its matched count with no error anywhere; it is the freshness gate working as intended, not a failure or a plan limit, and retrying cannot recover the items. `ceiling_reached` is the field that indicates the daily cap was hit, and a stale withhold does not set it. When `suppressed_breakdown.resolved` is false the split does not add up to the total and `unresolved_reason` says why; `ceiling_reached` stays conservative in that case rather than being cleared. THOSE COUNTS COVER ONLY POSTS THAT WERE FETCHED. A post that was not fetched was not matched and leaves no row behind, so it is absent from all three counters; zeros there mean no matches were recorded, not that nothing was missed. `coverage_24h` is the separate field that speaks to fetching. Its `status` is `complete` (every poll reached the point where the previous poll finished), `degraded` (at least one poll was truncated and posts were lost unrecoverably; detail in `gaps[]`, `posts_in_window_at_least` and the estimated `posts_missed_estimate`), `partial` (nothing found, but not every kind of loss was checked; detail in `unobserved_events`), or `unknown` (the check could not be run at all; detail in `reason`). `unknown`, `partial` and a null count each mean the question went unanswered. `coverage_24h` is fed by the poll log and covers BOTH ways a poll loses posts unrecoverably (`listing_exhausted`, where Reddit stopped serving older posts, and `poll_overflow`, where the feed outran one poll's page budget); `observed_events` names what was actually checked, and a status of `complete` is awarded only when both were. WHAT `complete` ESTABLISHES: every poll that RAN got back to where the previous poll finished, AND every feed was actually polled. The second half comes from `stream_liveness`. A feed that is not polled writes nothing to the poll log, so on the poll log alone it looks identical to a healthy monitor; `stream_liveness` reads a different source (the polling registry plus the per-feed last-successful-poll stamp), neither of which a poll that did not run can produce. Its `status` is `live` (every feed checked within three times its own interval), `degraded` (at least one is not: `streams[]` marks each feed `never_polled`, `stalled` or `unregistered`), or `unknown` (could not be established, including `streams_awaiting_first_poll` on a monitor created moments ago, which clears itself). `coverage_24h` reads `complete` only while `stream_liveness` is `live`. A zero delivery count on a `never_polled` feed means nothing was checked, not a quiet subreddit. `gaps[]` IS A SAMPLE, NOT THE WHOLE LIST: it carries the most recent gaps only, `gaps_returned` says how many are in it, `gaps_truncated` says whether more exist, and `gap_events_24h` is the true total. `cadence` reports whether the monitor is served the interval its plan sells, which none of the fields above can. `promised_cadence_s` is the floor the account's CURRENT plan includes and `requested_cadence_s` is what this monitor is actually set to; `meets_entitlement` false means the monitor is set slower than the plan allows, which happens because a plan upgrade does not re-cadence monitors that already exist (setting `cadence_s` on the monitor changes it, and a deliberately slower cadence is also a legitimate choice). `last_checked_s_ago`, `freshness_ratio` and `within_margin` describe the slowest feed feeding this monitor, named in `slowest_stream`; `freshness_reading` marks them as ONE INSTANTANEOUS SAMPLE, not an average and not a sustained verdict, so a single reading past the margin is not by itself proof of under-service. These fields are TRI-STATE: a null `within_margin` or `meets_entitlement` means the question could not be answered, and `unknown_reason` says why.",
680
674
  shape: { ...MONITOR_ID },
681
675
  },
682
676
  {
683
677
  name: "reddit_monitor_deliveries",
684
678
  path: "/api/reddit/monitor/deliveries",
685
679
  description:
686
- "Delivery history: the actual Reddit posts a monitor's webhook has received (or attempted), newest first, including the real post content (title, subreddit, permalink, author). Answers 'what did I actually get sent', not just 'how many' (see reddit_monitor_health for counts). Every delivered item also carries `payload.items[].enrichment`: a `relevance.score` (0-1, how much of THIS monitor's own keyword criteria the item matched -- not a model's confidence), a `sentiment` (`polarity` -1 to 1 plus a positive/negative/mixed/neutral `label`), and an `intent.tag` (question, recommendation_request, complaint, promotion, praise, or discussion). All three are deterministic keyword/lexicon/rule heuristics computed at no extra cost -- each carries its own `method` field and NONE of them is a machine-learning or LLM call, so do not describe a score here as ML-derived. Omit `id` to aggregate history across every monitor you own.",
680
+ "Delivery history: the actual Reddit posts a monitor's webhook has received (or attempted), newest first, including the real post content (title, subreddit, permalink, author). It records what was sent, not only how many (the counts are in monitor health). Every delivered item also carries `payload.items[].enrichment`: a `relevance.score` (0-1, how much of THIS monitor's own keyword criteria the item matched; not a model's confidence), a `sentiment` (`polarity` -1 to 1 plus a positive/negative/mixed/neutral `label`), and an `intent.tag` (question, recommendation_request, complaint, promotion, praise, or discussion). All three are deterministic keyword, lexicon and rule heuristics computed at no extra cost; each carries its own `method` field and none of them is a machine-learning or LLM call. Without `id`, the history aggregates across every monitor on the account.",
687
681
  shape: {
688
- id: z.string().optional().describe("Narrow to one monitor's history. Omit to aggregate across every monitor you own."),
682
+ id: z.string().optional().describe("Narrows to one monitor's history. Absent, the history aggregates across every monitor on the account."),
689
683
  status: z.enum(["pending", "delivered", "failed", "dead", "suppressed"]).optional().describe(
690
- "Filter to one delivery status. 'dead' = retries exhausted, gave up. 'suppressed' = matched but deliberately not sent, and `payload.suppressed.reason` says which of the two reasons applied: 'delivery_ceiling' (the monitor's daily cap) or 'stale_item' (the item was already older than the freshness window when we first saw it, so it was withheld rather than delivered as if it were new). A 'stale_item' row is NOT a fault and was NOT rejected by any plan limit, and retrying cannot recover it; `payload.suppressed` carries the age and the threshold. Omit for all statuses.",
684
+ "Filters to one delivery status. 'dead' = retries exhausted, gave up. 'suppressed' = matched but deliberately not sent, and `payload.suppressed.reason` says which of the two reasons applied: 'delivery_ceiling' (the monitor's daily cap) or 'stale_item' (the item was already older than the freshness window when first seen, so it was withheld rather than delivered as if it were new). A 'stale_item' row is NOT a fault and was NOT rejected by any plan limit, and retrying cannot recover it; `payload.suppressed` carries the age and the threshold. Absent, every status is returned.",
691
685
  ),
692
- limit: z.number().int().min(1).max(200).optional().describe("Max rows to return, 1 to 200. Default 50."),
693
- before: z.string().optional().describe("ISO 8601 timestamp cursor for pagination -- pass the `created_at` of the oldest row from the previous page to fetch older deliveries."),
686
+ limit: z.number().int().min(1).max(200).optional().describe("Maximum rows returned, 1 to 200. Default 50."),
687
+ before: z.string().optional().describe("ISO 8601 timestamp cursor for pagination: the `created_at` of the oldest row from the previous page returns older deliveries."),
694
688
  },
695
689
  },
696
690
  {
@@ -699,18 +693,18 @@ export const TOOLS = [
699
693
  method: "POST",
700
694
  write: true,
701
695
  description:
702
- "Register a delivery target for monitors to send matches to. Requires an active monitoring plan (a webhook with no plan could never receive anything). Returns the webhook with its signing `secret` SHOWN ONCE -- store it immediately, it is never returned again by reddit_monitor_webhook_list. HTTPS only; the URL is re-validated (including a fresh DNS check) at every delivery, not just at creation.",
696
+ "Registers a delivery target for monitors to send matches to. Requires an active monitoring plan (a webhook with no plan could not receive anything). Returns the webhook with its signing `secret` SHOWN ONCE: the webhook list does not return it again. HTTPS only; the URL is re-validated (including a fresh DNS check) at every delivery, not just at creation.",
703
697
  shape: {
704
- url: z.string().url().describe("HTTPS URL to deliver matches to. Must be publicly reachable HTTPS, no embedded credentials, no loopback/private/link-local address."),
698
+ url: z.string().url().describe("HTTPS URL that receives matches. Accepted: a publicly reachable HTTPS URL with no embedded credentials and no loopback, private or link-local address."),
705
699
  kind: z.enum(["webhook", "slack", "discord", "email"]).optional().describe(
706
- "Payload shape to send. PREFER OMITTING THIS: for a hooks.slack.com or discord.com/api/webhooks URL the kind is inferred from the host, and the response reports what it inferred in `kind_inferred_from`. 'slack'/'discord' format as native incoming-webhook messages; 'webhook' sends redditapis' generic signed JSON envelope and is the fallback only for a host we do not recognise; 'email' is not yet a real delivery transport. Passing 'webhook' for a Slack or Discord URL does NOT force the generic envelope (that combination can never deliver -- Slack answers 400 invalid_payload); the host wins and `kind_corrected_from` says so. Passing one SPECIFIC kind for a different platform's host (e.g. 'discord' with a hooks.slack.com URL) is refused with `webhook_kind_mismatch` (400) rather than stored.",
700
+ "Payload shape. Absent, the kind is inferred from the host for a hooks.slack.com or discord.com/api/webhooks URL, and the response reports the inference in `kind_inferred_from`. 'slack'/'discord' format as native incoming-webhook messages; 'webhook' sends the generic signed JSON envelope and is the fallback for an unrecognised host; 'email' is not yet a real delivery transport. 'webhook' with a Slack or Discord URL does NOT force the generic envelope (that combination cannot deliver: Slack answers 400 invalid_payload); the host wins and `kind_corrected_from` says so. A SPECIFIC kind for a different platform's host (e.g. 'discord' with a hooks.slack.com URL) is refused with `webhook_kind_mismatch` (400) rather than stored.",
707
701
  ),
708
702
  },
709
703
  },
710
704
  {
711
705
  name: "reddit_monitor_webhook_list",
712
706
  path: "/api/reddit/monitor/webhook/list",
713
- description: "List every webhook registered on the caller's account. Never returns the signing secret (shown once, at creation, by reddit_monitor_webhook_create).",
707
+ description: "Lists every webhook registered on the caller's account. The signing secret is not included; it is shown once, at creation.",
714
708
  shape: {},
715
709
  },
716
710
  {
@@ -719,7 +713,7 @@ export const TOOLS = [
719
713
  method: "POST",
720
714
  write: true,
721
715
  description:
722
- "Send a one-off test delivery to a registered webhook (rate-limited to 10/min) so you can confirm it's wired up correctly before waiting for a real match. Uses the webhook's `kind` to format the test payload the same way a real delivery would. On failure the response carries `reason` and `status` plus TWO fields that say what to actually do: `hint`, our sentence naming the fix (most often that the target's `kind` does not match its host, which no test can succeed through), and `detail`, a bounded, sanitised copy of what the destination itself replied. Report `hint` to the user rather than the bare `reason` -- `http_error` with a 400 names no field, no value and no remedy. Returns 404 `webhook_not_found` if the id does not exist or is not yours, or `webhook_url_rejected` if the URL fails re-validation (e.g. now resolves to a private address).",
716
+ "Sends a one-off test delivery to a registered webhook (rate-limited to 10/min), confirming it is wired up correctly before a real match arrives. The test payload is formatted by the webhook's `kind`, the same way a real delivery would be. On failure the response carries `reason` and `status`, plus `hint`, a sentence naming the fix (most often that the target's `kind` does not match its host, which no test can succeed through), and `detail`, a bounded, sanitised copy of what the destination itself replied. `hint` carries the remedy; a bare `reason` such as `http_error` with a 400 names no field, no value and no remedy. Returns 404 `webhook_not_found` if the id does not exist or is not on this account, or `webhook_url_rejected` if the URL fails re-validation (e.g. now resolves to a private address).",
723
717
  shape: { ...WEBHOOK_ID },
724
718
  },
725
719
  {
@@ -729,7 +723,7 @@ export const TOOLS = [
729
723
  write: true,
730
724
  destructive: true,
731
725
  description:
732
- "Permanently delete a webhook. Any monitor still pointing at it will fail to deliver until repointed at a different webhook -- this does NOT cascade-delete or pause the monitors using it. Cannot be undone. Returns 404 `webhook_not_found` if the id does not exist or is not yours.",
726
+ "Permanently deletes a webhook. A monitor still pointing at it fails to deliver until it is repointed at a different webhook: deletion does NOT cascade-delete or pause the monitors using it. Cannot be undone. Returns 404 `webhook_not_found` if the id does not exist or is not on this account.",
733
727
  shape: { ...WEBHOOK_ID },
734
728
  },
735
729
 
@@ -743,28 +737,28 @@ export const TOOLS = [
743
737
  local: "feedback",
744
738
  localArgs: ["action", "ids"],
745
739
  description:
746
- "Report a product problem or gap in redditapis.com to its team from inside this session, the way Claude Code's own feedback tool works: a report is DRAFTED to a local queue first (action \"draft\", the default) and SENT only after the user reviews it. Drafting sends nothing, needs no confirmation, and should not be announced mid-task. WHEN TO DRAFT, only at high-signal moments: a redditapis tool call failed with an error that was not a missing key (401), credits (402) or a rate limit (429), and the user had to work around it; the user asked for something no redditapis tool covers; a documented field came back empty or wrong; the user was clearly frustrated with a result. One draft per distinct issue, never twice for the same one. FORMAT for details, four labelled bullets in this order: 'What happened:' observed vs expected, exact error text if short. 'What the user said:' quoted verbatim, or 'user did not comment'. 'Repro:' the minimal call that reproduces it. 'Evidence:' tool name, endpoint, HTTP status, request id (the last failing call is attached automatically where you leave a gap). Facts only: no guessing, no API keys or secrets, no personal names. REVIEW: when the user asks to see or send feedback, call action \"list\", then action \"send\" with ONLY the draft ids the user named in their own message, or action \"discard\". Sending posts each draft to POST /feedback (free, not metered) and returns a server id that reddit_feedback_get can check later.",
740
+ "Reports a product problem or gap in redditapis.com to its team, through a local draft queue. action \"draft\" (the default) writes a report to a queue on this machine and sends nothing; it makes no network call. action \"list\" returns the pending drafts with their ids. action \"send\" posts the named draft ids to POST /feedback (free, not metered) and returns a server id per report. action \"discard\" drops the named drafts. The queue holds at most 10 drafts; a draft past that is refused. A draft takes `type`, a one-line `title` (at most 120 characters), `details` (at most 8000 characters) and optional `area` and `evidence`; evidence keys left out (tool, endpoint, HTTP status, request id) are filled from the last failing call in this session, and the MCP version and client name are attached to every report.",
747
741
  shape: {
748
742
  action: z.enum(["draft", "list", "send", "discard"]).optional().describe(
749
- "What to do. \"draft\" (default) queues a new report locally and sends nothing. \"list\" shows the pending drafts with their ids. \"send\" posts the drafts named in ids to redditapis.com; use it only for ids the user named. \"discard\" drops the drafts named in ids.",
743
+ "\"draft\" (default) queues a new report locally and sends nothing. \"list\" returns the pending drafts with their ids. \"send\" posts the drafts named in `ids` to redditapis.com. \"discard\" drops the drafts named in `ids`.",
750
744
  ),
751
745
  type: z.enum(["bug", "idea", "missing_capability"]).optional().describe(
752
- "Required for a draft. \"bug\": a tool or endpoint misbehaved. \"idea\": a change that would have made the task easier. \"missing_capability\": the user needed something no tool provides.",
746
+ "Required for a draft. \"bug\": a tool or endpoint misbehaved. \"idea\": a change that would have made the task easier. \"missing_capability\": a needed capability that no tool provides.",
753
747
  ),
754
748
  title: z.string().max(120).optional().describe(
755
- "Required for a draft. One specific line, at most 120 characters, naming the tool or endpoint and the defect, e.g. \"reddit_post_comments returns 502 when the post is deleted\".",
749
+ "Required for a draft. One line, at most 120 characters, naming the tool or endpoint and the defect, e.g. \"GET /api/reddit/comments returns 502 when the post is deleted\".",
756
750
  ),
757
751
  details: z.string().max(8000).optional().describe(
758
- "Required for a draft. At most 8000 characters, four labelled bullets in order: What happened, What the user said (verbatim), Repro, Evidence.",
752
+ "Required for a draft. At most 8000 characters: three labelled bullets, What happened, Repro, Evidence, optionally preceded by a one-line summary of the goal. No quotes of the conversation.",
759
753
  ),
760
754
  area: z.string().max(80).optional().describe(
761
755
  "Optional. The endpoint or feature the report is about, e.g. \"posts/comments\" or \"monitoring\". At most 80 characters.",
762
756
  ),
763
757
  evidence: z.record(z.string(), z.unknown()).optional().describe(
764
- "Optional identifiers only, never payloads: {tool, endpoint, status, request_id}. Whatever you leave out is filled from the last failing call in this session; mcp_version and client are always attached.",
758
+ "Optional identifiers, {tool, endpoint, status, request_id}, at most 4096 bytes serialized. Keys left out are filled from the last failing call in this session; mcp_version and client are attached to every report.",
765
759
  ),
766
760
  ids: z.array(z.string()).optional().describe(
767
- "For action \"send\" or \"discard\": the draft ids to act on, exactly as shown by action \"list\" and named by the user.",
761
+ "For action \"send\" or \"discard\": the draft ids to act on, as returned by action \"list\".",
768
762
  ),
769
763
  },
770
764
  },
@@ -772,19 +766,19 @@ export const TOOLS = [
772
766
  name: "reddit_feedback_list",
773
767
  path: "/feedback",
774
768
  description:
775
- "List the feedback reports this account has sent, newest first, with each one's current status. Use it to RECOVER A LOST ID: the server id is returned only once, when a report is sent, so this is the way back to a report whose id was not kept. Also the way to answer \"did that report actually land\" and \"has the team looked at it yet\". Optionally filter by status or type, and page with the cursor from a previous response. Free per call, never metered. Returns {feedback: [...], count, limit, next_cursor}; page by passing next_cursor back as cursor until it is null; an account that has filed nothing gets an empty list and a 200, not an error. Note this lists SENT reports on the server, which is different from reddit_feedback_send action=\"list\", which shows unsent local drafts on this machine.",
769
+ "Lists the feedback reports this account has sent, newest first, with each one's current status. The server id is returned only once, when a report is sent, so this listing is how a report whose id was not kept is found again; it also shows whether a report landed and whether the team has acted on it. Optional filters by status or type; paging uses the cursor from a previous response. Free per call, not metered. Returns {feedback: [...], count, limit, next_cursor}; pages continue while next_cursor is non-null, and an account that has filed nothing gets an empty list and a 200, not an error. It lists SENT reports on the server, which differ from the unsent local drafts on this machine.",
776
770
  shape: {
777
771
  status: z.enum(["new", "triaged", "shipped", "declined"]).optional().describe(
778
- "Optional. Show only reports in this state. Omit for all of them.",
772
+ "Optional. Restricts the list to reports in this state; absent, every state.",
779
773
  ),
780
774
  type: z.enum(["bug", "idea", "missing_capability"]).optional().describe(
781
- "Optional. Show only reports of this kind. Omit for all of them.",
775
+ "Optional. Restricts the list to reports of this kind; absent, every kind.",
782
776
  ),
783
777
  cursor: z.string().optional().describe(
784
- "Optional. The next_cursor from a previous response, to fetch the page after it. Keyset paging on (created_at, id), so a report filed while you page cannot make a row repeat or be skipped. A cursor this endpoint did not issue is a 400, never an empty page.",
778
+ "Optional. The next_cursor from a previous response, for the page after it. Keyset paging on (created_at, id), so a report filed during paging cannot make a row repeat or be skipped. A cursor this endpoint did not issue is a 400, not an empty page.",
785
779
  ),
786
780
  limit: z.number().int().min(1).max(100).optional().describe(
787
- "Optional. How many to return, 1 to 100 (default 25). Newest first.",
781
+ "Optional. How many are returned, 1 to 100 (default 25). Newest first.",
788
782
  ),
789
783
  },
790
784
  },
@@ -805,17 +799,17 @@ export const TOOLS = [
805
799
  name: "reddit_account_me",
806
800
  path: "/account/me",
807
801
  description:
808
- "How much credit this API key has left, before spending any. Returns the account's remaining credit balance and usage totals. FREE: this call is not metered and never costs a credit, so call it whenever you are about to run something expensive rather than guessing. Use it to decide whether a planned batch fits in the remaining balance, and to tell the user how much is left if a call returns 402. A 402 from any other tool means the balance is exhausted; its response carries a top-up URL to give the user.",
802
+ "Returns the remaining credit balance and usage totals of the account behind this API key. Free: not metered, and costs no credit. A 402 from a metered call means the balance is exhausted; that response carries a top-up URL.",
809
803
  shape: {},
810
804
  },
811
805
  {
812
806
  name: "reddit_feedback_get",
813
807
  path: "/feedback/{id}",
814
808
  description:
815
- "Check the status of a feedback report this account sent earlier (the server id returned by reddit_feedback_send action \"send\"): status new, triaged, shipped or declined, the team's response text if any, and updated_at, which moves only when the team acts on it. Free per call. 404 if the id is not on this account.",
809
+ "Returns the status of a feedback report this account sent earlier, by its server id: status new, triaged, shipped or declined, the team's response text if any, and updated_at, which moves only when the team acts on it. Free per call. 404 if the id is not on this account.",
816
810
  shape: {
817
811
  id: z.string().min(1).describe(
818
- "The server id of a sent report, as returned by reddit_feedback_send action \"send\" (a UUID). Not a local draft id.",
812
+ "The server id of a sent report (a UUID), as returned when the report was sent. Not a local draft id.",
819
813
  ),
820
814
  },
821
815
  },
@@ -846,6 +840,26 @@ export const SESSION_ARG_TO_HEADER = {
846
840
  proxy: "x-reddit-proxy",
847
841
  };
848
842
 
843
+ // ── inline credentials ──────────────────────────────────────────────────────
844
+ //
845
+ // A tool takes INLINE CREDENTIALS when its input schema has an argument that
846
+ // carries the caller's own Reddit session: a cookie, a token, or the proxy the
847
+ // session egresses through (proxy URLs embed user:pass). createServer leaves
848
+ // such tools unregistered when built with inlineCredentials:false, the mode a
849
+ // remote host uses so a connected app never pipes a Reddit session through it.
850
+ //
851
+ // Two tests, either one hides a tool, so the check fails CLOSED: the exact arg
852
+ // names the header transport knows, and a name pattern that catches a future
853
+ // credential-shaped argument added without updating that map.
854
+ export const CREDENTIAL_ARGS = Object.freeze(Object.keys(SESSION_ARG_TO_HEADER));
855
+ // Matched on whole underscore-separated words, so `author` or `max_authors` is not
856
+ // mistaken for `auth`.
857
+ const CREDENTIAL_NAME = /(^|_)(cookies?|session|token|password|passwd|secret|credentials?|csrf|loid|proxy|auth|authorization|bearer|api_key|apikey)(_|$)/i;
858
+
859
+ export function takesInlineCredentials(tool) {
860
+ return Object.keys(tool?.shape || {}).some((k) => CREDENTIAL_ARGS.includes(k) || CREDENTIAL_NAME.test(k));
861
+ }
862
+
849
863
  /**
850
864
  * Split a tool's args into headers and everything else.
851
865
  *