redditapis-mcp 0.8.1 → 0.9.0
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 +52 -0
- package/package.json +5 -3
- package/src/server.js +60 -7
- package/src/tools.js +139 -125
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,57 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.9.0 (2026-10-02)
|
|
4
|
+
|
|
5
|
+
### Fixed
|
|
6
|
+
|
|
7
|
+
- **reddit_post_visibility works again.** Its path was written
|
|
8
|
+
`/api/reddit/post/:id/visibility`, which the client's path builder does not
|
|
9
|
+
fill, so every call sent a literal `:id` and was refused with HTTP 400 (it
|
|
10
|
+
had been broken since the tool shipped). It is now `{id}`, and a test fails
|
|
11
|
+
if any tool path carries an unfillable `:param`.
|
|
12
|
+
|
|
13
|
+
### Changed
|
|
14
|
+
|
|
15
|
+
- **Tool descriptions state product facts only; guidance moved to the server
|
|
16
|
+
instructions.** Every tool and parameter description now says what the tool
|
|
17
|
+
returns, what it takes, what it costs and what its errors mean, and nothing
|
|
18
|
+
else: no instructions to the model, no names of other tools, no hidden or
|
|
19
|
+
encoded text. That is the rule the Claude connector directory's compliance
|
|
20
|
+
attestation sets for tool descriptions. The guidance that was spread across
|
|
21
|
+
them (which search or listing tool fits which question, how to get the Reddit
|
|
22
|
+
session the home feed and the four private listings need, how to read a
|
|
23
|
+
partial listing or a monitor's health, what a monitor update replaces, and
|
|
24
|
+
the feedback drafting policy) now lives once in the server instructions,
|
|
25
|
+
which every MCP client hands its model with the tool list. Tool names, input
|
|
26
|
+
schemas, annotations and behaviour are unchanged. Measured with the new gate
|
|
27
|
+
on 0.8.1: 24 of 44 tools named another `reddit_*` token and every tool
|
|
28
|
+
carried at least one instruction-shaped phrase (most through shared
|
|
29
|
+
parameter text such as the pagination cursor); on 0.9.0 both are zero.
|
|
30
|
+
|
|
31
|
+
### Added
|
|
32
|
+
|
|
33
|
+
- **`createServer({ inlineCredentials: false })` for remote hosts.** The
|
|
34
|
+
default stays `true`, so a local `npx` install sees no change. With `false`,
|
|
35
|
+
every tool whose input takes the caller's Reddit cookies or session proxy is
|
|
36
|
+
not registered: `reddit_home_feed`, `reddit_user_upvoted`,
|
|
37
|
+
`reddit_user_saved`, `reddit_user_hidden` and `reddit_user_gilded`. They are
|
|
38
|
+
absent from `tools/list`, a call naming one fails without reaching the API,
|
|
39
|
+
and the server instructions drop the sentence that points at them. The test
|
|
40
|
+
is `takesInlineCredentials` in `src/tools.js`, which matches the known cookie
|
|
41
|
+
arguments and any credential-shaped argument name, so a future cookie
|
|
42
|
+
argument is hidden by default. `test/inline-credentials.test.mjs` proves the
|
|
43
|
+
hosted listing has zero such tools while the default keeps all five.
|
|
44
|
+
- **`test/description-compliance.test.mjs`, part of `npm test`.** It lists the
|
|
45
|
+
catalog the way a client receives it (`createServer` plus an in-memory MCP
|
|
46
|
+
client) and fails if any tool or parameter description names another tool,
|
|
47
|
+
matches a model-instruction pattern (should, do not, never, always, must,
|
|
48
|
+
use ... instead, second person, sentence-initial imperatives and similar), or
|
|
49
|
+
carries zero-width, bidi, tag or control characters or a base64- or
|
|
50
|
+
hex-looking run. It also checks that the server instructions still carry the
|
|
51
|
+
guidance that moved and stay within 2048 characters. Planted defects for all
|
|
52
|
+
three checks are asserted in the same run, so a matcher that stops catching
|
|
53
|
+
them fails rather than reporting a clean catalog.
|
|
54
|
+
|
|
3
55
|
## 0.8.1 (2026-09-30)
|
|
4
56
|
|
|
5
57
|
### Fixed
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "redditapis-mcp",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.9.0",
|
|
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/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
|
-
//
|
|
141
|
-
|
|
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
|
-
"
|
|
144
|
-
"
|
|
145
|
-
"
|
|
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
|
+
"four bullets (What happened, What the user said verbatim, Repro, Evidence), 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(
|
|
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
|
-
"
|
|
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
|
|
52
|
-
"
|
|
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.
|
|
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
|
|
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
|
-
"
|
|
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
|
|
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.
|
|
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
|
-
"
|
|
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
|
-
"
|
|
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
|
-
"
|
|
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
|
-
"
|
|
158
|
-
"
|
|
159
|
-
"
|
|
160
|
-
"capped per plan tier (
|
|
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:
|
|
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
|
|
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("
|
|
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
|
|
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
|
|
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
|
-
"
|
|
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
|
|
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'].
|
|
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
|
|
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
|
|
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("
|
|
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
|
|
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("
|
|
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
|
-
"
|
|
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,
|
|
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,
|
|
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
|
-
"
|
|
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
|
-
"
|
|
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
|
|
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
|
-
"
|
|
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
|
-
"
|
|
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/)
|
|
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("
|
|
280
|
-
max_score: z.number().int().optional().describe("
|
|
281
|
-
min_comments: z.number().int().optional().describe("
|
|
282
|
-
max_comments: z.number().int().optional().describe("
|
|
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("
|
|
286
|
-
locked: z.boolean().optional().describe("
|
|
287
|
-
stickied: z.boolean().optional().describe("
|
|
288
|
-
spoiler: z.boolean().optional().describe("
|
|
289
|
-
contest_mode: z.boolean().optional().describe("
|
|
290
|
-
sort_type: z.enum(["score", "num_comments", "created"]).optional().describe("Re-
|
|
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
|
|
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
|
-
"
|
|
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
|
-
"
|
|
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
|
-
"
|
|
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
|
-
"
|
|
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.
|
|
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.
|
|
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
|
-
"
|
|
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
|
-
"
|
|
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
|
-
"
|
|
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
|
-
"
|
|
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
|
-
"
|
|
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
|
-
"
|
|
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
|
-
"
|
|
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
|
-
"
|
|
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
|
-
"
|
|
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
|
-
"
|
|
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
|
-
"
|
|
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
|
|
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
|
-
"
|
|
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
|
|
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
|
-
"
|
|
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
|
|
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
|
-
"
|
|
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
|
|
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
|
-
"
|
|
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
|
-
"
|
|
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
|
-
"
|
|
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
|
-
"
|
|
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
|
-
"
|
|
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-
|
|
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
|
-
"
|
|
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
|
-
"
|
|
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
|
-
"
|
|
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
|
-
"
|
|
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.
|
|
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')
|
|
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
|
-
"
|
|
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
|
-
"
|
|
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,
|
|
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("
|
|
661
|
-
cadence_s: z.number().int().positive().optional().describe("New poll interval in seconds,
|
|
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
|
-
"
|
|
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:
|
|
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).
|
|
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("
|
|
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
|
-
"
|
|
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("
|
|
693
|
-
before: z.string().optional().describe("ISO 8601 timestamp cursor for pagination
|
|
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
|
-
"
|
|
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
|
|
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
|
|
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: "
|
|
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
|
-
"
|
|
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
|
|
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
|
-
"
|
|
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
|
-
"
|
|
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\":
|
|
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
|
|
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.
|
|
752
|
+
"Required for a draft. Free text, at most 8000 characters.",
|
|
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
|
|
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,
|
|
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
|
-
"
|
|
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.
|
|
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.
|
|
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,
|
|
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
|
|
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
|
-
"
|
|
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
|
-
"
|
|
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
|
|
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
|
*
|