redditapis-mcp 0.1.12 → 0.2.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 +32 -0
- package/README.md +47 -9
- package/package.json +5 -3
- package/src/index.js +25 -11
- package/src/tools.js +324 -7
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,37 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.2.0
|
|
4
|
+
- **10 new monitor/webhook management tools** (task #43), the first WRITES this
|
|
5
|
+
catalog has ever carried. Unlike the reddit comment/vote/DM writes this MCP
|
|
6
|
+
still excludes, these configure the caller's OWN redditapis.com account (an
|
|
7
|
+
alerting subscription), never Reddit itself:
|
|
8
|
+
- **`reddit_monitor_add`** / **`reddit_monitor_list`** / **`reddit_monitor_update`** /
|
|
9
|
+
**`reddit_monitor_remove`** — create, list, update (pause/resume/re-filter/
|
|
10
|
+
re-cadence), and permanently delete a monitor. Requires an active plan to
|
|
11
|
+
create/update (monitoring has no free tier); reading your own list never does.
|
|
12
|
+
- **`reddit_monitor_health`** — per-monitor delivered/failed/suppressed counts and
|
|
13
|
+
whether the delivery ceiling has been hit.
|
|
14
|
+
- **`reddit_monitor_deliveries`** — the actual Reddit posts a monitor's webhook
|
|
15
|
+
has received, not just counts (task #47, the feature the operator asked for
|
|
16
|
+
directly: "can they check which all posts their webhook has received").
|
|
17
|
+
- **`reddit_monitor_webhook_create`** / **`reddit_monitor_webhook_list`** /
|
|
18
|
+
**`reddit_monitor_webhook_test`** / **`reddit_monitor_webhook_delete`** —
|
|
19
|
+
register a delivery target (secret shown once), list registered webhooks
|
|
20
|
+
(secret never re-shown), send a one-off test delivery, and permanently
|
|
21
|
+
delete one.
|
|
22
|
+
- All 10 verified live end to end against production (real create/update/
|
|
23
|
+
pause/resume/remove/re-add round trips, a real signed test delivery), not
|
|
24
|
+
just unit-tested against a mock.
|
|
25
|
+
- `remove`/`webhook_delete` are marked `destructive: true`; every write is
|
|
26
|
+
`write: true`; every other tool remains a pure, `readOnlyHint: true` read.
|
|
27
|
+
- Fixed two pre-existing drifts found while touching this file: the reported
|
|
28
|
+
`VERSION` was hardcoded `"0.1.0"` while the package had shipped up to
|
|
29
|
+
0.1.12 for months (now read live from package.json, so it can't drift
|
|
30
|
+
again), and `test/tools.test.mjs` / `test/smoke.mjs` both hardcoded stale
|
|
31
|
+
tool counts (12 and 11) against an actual catalog of 22 -- neither test
|
|
32
|
+
file had been updated as the catalog grew.
|
|
33
|
+
- Brings the tool count to 32 (22 reads + 10 monitor/webhook writes).
|
|
34
|
+
|
|
3
35
|
## 0.1.12
|
|
4
36
|
- reddit_search gains advanced filters applied to the returned page (the pullpush
|
|
5
37
|
filter-power model): min_score/max_score, min_comments/max_comments, is_video,
|
package/README.md
CHANGED
|
@@ -4,13 +4,13 @@
|
|
|
4
4
|
[](https://www.npmjs.com/package/redditapis-mcp)
|
|
5
5
|
[](./LICENSE)
|
|
6
6
|
|
|
7
|
-
Official **Model Context Protocol** server for [redditapis.com](https://www.redditapis.com), the Reddit API as native tools for Claude, Cursor, Windsurf, and any MCP client. It turns Reddit reads (search, subreddit listings, comment trees, user profiles, community metadata) into typed tools your agent can call directly.
|
|
7
|
+
Official **Model Context Protocol** server for [redditapis.com](https://www.redditapis.com), the Reddit API as native tools for Claude, Cursor, Windsurf, and any MCP client. It turns Reddit reads (search, subreddit listings, comment trees, user profiles, community metadata) into typed tools your agent can call directly, plus (since 0.2.0) managing your own redditapis.com monitors and webhooks.
|
|
8
8
|
|
|
9
|
-
Ask your agent to search Reddit for a topic, read a community's top posts of the week, pull a user's comment history, surface the redditors talking about a product, or read a subreddit's rules before you engage, and it calls the API for you. Every tool maps to a REST endpoint at `https://api.redditapis.com`; the server holds no state and forwards your API key on each call.
|
|
9
|
+
Ask your agent to search Reddit for a topic, read a community's top posts of the week, pull a user's comment history, surface the redditors talking about a product, or read a subreddit's rules before you engage, and it calls the API for you. It can also set up a monitor that watches a subreddit for new posts matching a filter and delivers them to a webhook, then check what it's actually delivered. Every tool maps to a REST endpoint at `https://api.redditapis.com`; the server holds no state and forwards your API key on each call.
|
|
10
10
|
|
|
11
11
|
## Quick start
|
|
12
12
|
|
|
13
|
-
No install needed. Run with `npx`. You need one thing: an API key from **[redditapis.com](https://www.redditapis.com)**. Reads work with just that key, so there is no account login or session step to set up.
|
|
13
|
+
No install needed. Run with `npx`. You need one thing: an API key from **[redditapis.com](https://www.redditapis.com)**. Reads work with just that key, so there is no account login or session step to set up. Monitor/webhook management additionally requires an active monitoring plan (monitoring has no free tier).
|
|
14
14
|
|
|
15
15
|
## Setup
|
|
16
16
|
|
|
@@ -99,12 +99,13 @@ Authentication is a Bearer token: the server sends `Authorization: Bearer <REDDI
|
|
|
99
99
|
|
|
100
100
|
## Tools
|
|
101
101
|
|
|
102
|
-
|
|
102
|
+
32 tools: 22 reads plus 10 monitor/webhook management tools. Reddit writes (posting, commenting, voting, DMs) remain a separate authenticated surface and are intentionally out of scope here -- monitor/webhook tools configure your OWN redditapis.com account (an alerting subscription), never Reddit itself. Every read works with just your API key; the 6 monitor/webhook writes additionally need an active monitoring plan (see Monitoring below).
|
|
103
103
|
|
|
104
104
|
A few conventions across the catalog:
|
|
105
105
|
|
|
106
106
|
- Subreddit names go in **without** the `r/` prefix, and usernames **without** the `u/` prefix. `reddit_subreddit_posts` takes its community as `subreddit`; the `/sub/{name}/...` tools take it as `name`.
|
|
107
|
-
- Listing and search tools return an `after` cursor. Pass it back as `after` to fetch the next page. `limit` accepts 1 to 100 (the API clamps out-of-range values).
|
|
107
|
+
- Listing and search tools return an `after` cursor. Pass it back as `after` to fetch the next page, exactly as it was returned. `limit` accepts 1 to 100 (the API clamps out-of-range values).
|
|
108
|
+
- When `after` comes back `null` there is no next page to ask for. That does not always mean you have every item: Reddit often stops serving a busy listing long before it runs out. The final response also carries `listing_status`, which reads `complete`, `truncated` or `unknown`. Only `complete` means nothing is missing. Treat the other two as a partial answer and widen across sorts, timeframes or search terms rather than paging deeper.
|
|
108
109
|
- `t` (`hour`, `day`, `week`, `month`, `year`, `all`) sets the time window; on subreddit listings it applies to the `top` and `controversial` sorts, and on search it bounds the whole result set.
|
|
109
110
|
|
|
110
111
|
### Search and discovery
|
|
@@ -154,6 +155,23 @@ A few conventions across the catalog:
|
|
|
154
155
|
| `reddit_subreddits_new` | `GET /api/reddit/subreddits/new` | Browse the newest subreddits, most recently created first. |
|
|
155
156
|
| `reddit_subreddits_default` | `GET /api/reddit/subreddits/default` | Browse Reddit's default front-page set of subreddits. |
|
|
156
157
|
|
|
158
|
+
### Monitoring: manage your own monitors and webhooks
|
|
159
|
+
|
|
160
|
+
v1 monitors are **subreddit-scoped, posts-only** (no all-of-Reddit keyword watch, no comment monitoring yet). Creating or updating a monitor or webhook needs an active plan; reading your own list/health/deliveries never does.
|
|
161
|
+
|
|
162
|
+
| Tool | Endpoint | What it does |
|
|
163
|
+
|---|---|---|
|
|
164
|
+
| `reddit_monitor_add` | `POST /api/reddit/monitor/add` | Create a monitor: subreddits to watch plus an optional filter (keyword, author, domain, include/exclude terms, min score, NSFW). Forward-looking only from creation (or from `baseline_item_id`). |
|
|
165
|
+
| `reddit_monitor_list` | `GET /api/reddit/monitor/list` | List every monitor on your account, plus `slots` ({used, total, tier}). |
|
|
166
|
+
| `reddit_monitor_update` | `POST /api/reddit/monitor/update` | Pause/resume (`active`), re-cadence, or replace a monitor's filter. Passing any filter field REPLACES the whole filter -- resupply everything you want kept. |
|
|
167
|
+
| `reddit_monitor_remove` | `POST /api/reddit/monitor/remove` | Permanently delete a monitor. Cannot be undone. |
|
|
168
|
+
| `reddit_monitor_health` | `GET /api/reddit/monitor/health` | Per-monitor delivered/failed/suppressed counts (last 24h), `suppressed_breakdown` splitting those suppressions into `ceiling` and `stale` reasons, and whether the delivery ceiling specifically has been hit. |
|
|
169
|
+
| `reddit_monitor_deliveries` | `GET /api/reddit/monitor/deliveries` | The actual posts delivered (or attempted), newest first, with real content -- not just counts. Omit `id` to aggregate across every monitor you own. |
|
|
170
|
+
| `reddit_monitor_webhook_create` | `POST /api/reddit/monitor/webhook/create` | Register a delivery target (`webhook`/`slack`/`discord`). Returns a signing secret shown ONCE. |
|
|
171
|
+
| `reddit_monitor_webhook_list` | `GET /api/reddit/monitor/webhook/list` | List your webhooks. Never returns the secret. |
|
|
172
|
+
| `reddit_monitor_webhook_test` | `POST /api/reddit/monitor/webhook/test` | Send a one-off test delivery to confirm a webhook is wired up correctly. |
|
|
173
|
+
| `reddit_monitor_webhook_delete` | `POST /api/reddit/monitor/webhook/delete` | Permanently delete a webhook. Does not cascade-pause monitors still pointing at it. |
|
|
174
|
+
|
|
157
175
|
## Usage examples
|
|
158
176
|
|
|
159
177
|
### Research a topic across Reddit
|
|
@@ -210,15 +228,35 @@ t: "year"
|
|
|
210
228
|
|
|
211
229
|
Then `reddit_subreddit_rules` with `{ name: "webdev" }`, and finally `reddit_post_comments` with the `permalink` from a search result, for example `{ permalink: "/r/webdev/comments/abc123/some_title/" }`, to pull the post and its comment tree.
|
|
212
230
|
|
|
213
|
-
|
|
231
|
+
### Set up brand monitoring and check what came in
|
|
232
|
+
|
|
233
|
+
> "Watch r/SaaS and r/startups for mentions of my product, send matches to my Slack, and show me what's come in so far."
|
|
234
|
+
|
|
235
|
+
The agent calls `reddit_monitor_webhook_create` with:
|
|
236
|
+
|
|
237
|
+
```
|
|
238
|
+
url: "https://hooks.slack.com/services/..."
|
|
239
|
+
kind: "slack"
|
|
240
|
+
```
|
|
241
|
+
|
|
242
|
+
Then `reddit_monitor_add` with:
|
|
243
|
+
|
|
244
|
+
```
|
|
245
|
+
subreddit: ["SaaS", "startups"]
|
|
246
|
+
q: "my product name"
|
|
247
|
+
```
|
|
248
|
+
|
|
249
|
+
Later, `reddit_monitor_deliveries` with `{ id: "<monitor id from the add response>" }` returns the actual matching posts sent so far, or `reddit_monitor_health` for just the counts.
|
|
250
|
+
|
|
251
|
+
## No session needed for reads; monitor/webhook writes need an active plan
|
|
214
252
|
|
|
215
|
-
|
|
253
|
+
Reads need nothing but your API key -- no account linking, login, or cookie step. Reddit write actions (posting, commenting, voting, sending DMs) are handled by a separate authenticated surface outside this package and remain deliberately not exposed here, so an agent using this server can never post, vote, or DM as you on Reddit. Monitor/webhook management tools are a different kind of write: they configure your OWN redditapis.com account (an alerting subscription) and require an active monitoring plan for anything that creates or changes state (`reddit_monitor_add`/`update`/`remove`, `reddit_monitor_webhook_create`/`test`/`delete`); reading your own list, health, or delivery history never does.
|
|
216
254
|
|
|
217
255
|
## Troubleshooting
|
|
218
256
|
|
|
219
257
|
**`HTTP 401 (invalid or missing API key)`** Check that `REDDITAPIS_KEY` is set correctly in your MCP client config and matches the key from [redditapis.com](https://www.redditapis.com).
|
|
220
258
|
|
|
221
|
-
**`HTTP 402 (insufficient credits)`** Top up your account at [redditapis.com](https://www.redditapis.com).
|
|
259
|
+
**`HTTP 402 (insufficient credits)`** Top up your account at [redditapis.com](https://www.redditapis.com). For a monitor/webhook write specifically, a 402 body of `subscription_required` means there is no active monitoring plan (monitoring has no free tier); `monitor_slots_exhausted` means the plan's monitor slot limit is already in use -- `reddit_monitor_list`'s `slots` field shows used vs total.
|
|
222
260
|
|
|
223
261
|
**`HTTP 403 (access forbidden)`** The subreddit or user may be private, banned, or quarantined, or your plan may not include this endpoint.
|
|
224
262
|
|
|
@@ -248,7 +286,7 @@ npm start # run the stdio server (needs REDDITAPIS_KEY)
|
|
|
248
286
|
|
|
249
287
|
**Do I need a Reddit developer account?** No. Get an API key at [redditapis.com](https://www.redditapis.com); there is no application or approval step.
|
|
250
288
|
|
|
251
|
-
**Can it post, comment, or vote?** No.
|
|
289
|
+
**Can it post, comment, or vote?** No. All 22 Reddit-facing tools read Reddit; posting, commenting, voting, and DMs are a separate authenticated surface and are not exposed here. The other 10 tools manage your OWN redditapis.com monitors/webhooks, which is a write, but never a write to Reddit itself.
|
|
252
290
|
|
|
253
291
|
**Which clients are supported?** Claude Desktop, Claude Code, Cursor, Windsurf, and VS Code (Copilot agent mode), or any Model Context Protocol client.
|
|
254
292
|
|
package/package.json
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "redditapis-mcp",
|
|
3
|
-
"version": "0.1
|
|
4
|
-
"
|
|
3
|
+
"version": "0.2.1",
|
|
4
|
+
"mcpName": "io.github.redditapis/redditapis-mcp",
|
|
5
|
+
"description": "Official MCP server for redditapis.com, the Reddit API (subreddit listings, post + comment + community + user + media search, comment trees, top posts, user profiles) plus monitor/webhook management (create/list/update/remove a monitor, register/test/delete a webhook, read delivery history) as native tools for Claude, Cursor, and any MCP client.",
|
|
5
6
|
"repository": {
|
|
6
7
|
"type": "git",
|
|
7
8
|
"url": "git+https://github.com/redditapis/redditapis-mcp.git"
|
|
@@ -25,7 +26,8 @@
|
|
|
25
26
|
"scripts": {
|
|
26
27
|
"start": "node src/index.js",
|
|
27
28
|
"check": "node --check src/index.js && node --check src/tools.js",
|
|
28
|
-
"test": "node test/tools.test.mjs"
|
|
29
|
+
"test": "node test/tools.test.mjs",
|
|
30
|
+
"prepublishOnly": "node scripts/prepublish-tenant-check.mjs"
|
|
29
31
|
},
|
|
30
32
|
"dependencies": {
|
|
31
33
|
"@modelcontextprotocol/sdk": "^1.0.0",
|
package/src/index.js
CHANGED
|
@@ -3,8 +3,10 @@
|
|
|
3
3
|
//
|
|
4
4
|
// Exposes the Reddit API as native MCP tools for Claude, Cursor, and any MCP
|
|
5
5
|
// client: subreddit listings, post + comment search, community/user/media/comment
|
|
6
|
-
// search, a post's comment tree, subreddit top posts,
|
|
7
|
-
//
|
|
6
|
+
// search, a post's comment tree, subreddit top posts, user profile/comments, and
|
|
7
|
+
// (since 0.2.0) managing your own redditapis.com monitors and webhooks -- create/
|
|
8
|
+
// list/update/remove a monitor, register/list/test/delete a webhook, and read
|
|
9
|
+
// delivery history. Each tool is a thin, typed wrapper over a REST endpoint at
|
|
8
10
|
// https://api.redditapis.com. The server holds no state and forwards your API
|
|
9
11
|
// key on every call. The tool catalog lives in ./tools.js.
|
|
10
12
|
//
|
|
@@ -16,16 +18,20 @@
|
|
|
16
18
|
//
|
|
17
19
|
// Run: npx -y redditapis-mcp@latest (stdio transport)
|
|
18
20
|
|
|
21
|
+
import { createRequire } from "node:module";
|
|
19
22
|
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
20
23
|
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
21
|
-
import { TOOLS, buildQuery, buildPath } from "./tools.js";
|
|
24
|
+
import { TOOLS, buildQuery, buildPath, buildBody } from "./tools.js";
|
|
22
25
|
|
|
23
26
|
const API_KEY = process.env.REDDITAPIS_KEY || process.env.REDDIT_APIS_KEY;
|
|
24
27
|
const BASE_URL = (
|
|
25
28
|
process.env.REDDITAPIS_BASE_URL || "https://api.redditapis.com"
|
|
26
29
|
).replace(/\/+$/, "");
|
|
27
30
|
const REQUEST_TIMEOUT_MS = Number(process.env.REDDITAPIS_TIMEOUT_MS || 30000);
|
|
28
|
-
|
|
31
|
+
// Read from package.json rather than a hand-maintained literal -- this drifted
|
|
32
|
+
// silently to "0.1.0" while the published package reached 0.1.12, so every
|
|
33
|
+
// User-Agent and MCP server handshake understated its own version for months.
|
|
34
|
+
const { version: VERSION } = createRequire(import.meta.url)("../package.json");
|
|
29
35
|
|
|
30
36
|
if (!API_KEY) {
|
|
31
37
|
console.error(
|
|
@@ -34,12 +40,15 @@ if (!API_KEY) {
|
|
|
34
40
|
process.exit(1);
|
|
35
41
|
}
|
|
36
42
|
|
|
37
|
-
// REST call. Resolves any {param} path placeholders from args,
|
|
38
|
-
// the query string
|
|
43
|
+
// REST call. Resolves any {param} path placeholders from args, and for GET
|
|
44
|
+
// sends the rest as a query string; for POST it sends the rest as a JSON body
|
|
45
|
+
// (see buildBody -- `tool` is passed through only to read its
|
|
46
|
+
// `filterSpecFields`). Forwards the API key as a Bearer token (redditapis.com
|
|
39
47
|
// authenticates via `Authorization: Bearer <key>` only).
|
|
40
|
-
async function callEndpoint(pathTemplate, args, method = "GET") {
|
|
48
|
+
async function callEndpoint(pathTemplate, args, method = "GET", tool = null) {
|
|
41
49
|
const { path, rest } = buildPath(pathTemplate, args);
|
|
42
|
-
const
|
|
50
|
+
const isWrite = method !== "GET";
|
|
51
|
+
const q = isWrite ? "" : buildQuery(rest);
|
|
43
52
|
const url = `${BASE_URL}${path}${q ? `?${q}` : ""}`;
|
|
44
53
|
|
|
45
54
|
const headers = {
|
|
@@ -47,11 +56,16 @@ async function callEndpoint(pathTemplate, args, method = "GET") {
|
|
|
47
56
|
accept: "application/json",
|
|
48
57
|
"user-agent": `reddit-mcp/${VERSION}`,
|
|
49
58
|
};
|
|
59
|
+
let requestBody;
|
|
60
|
+
if (isWrite) {
|
|
61
|
+
headers["content-type"] = "application/json";
|
|
62
|
+
requestBody = JSON.stringify(buildBody(tool || {}, rest));
|
|
63
|
+
}
|
|
50
64
|
|
|
51
65
|
const ctrl = new AbortController();
|
|
52
66
|
const timer = setTimeout(() => ctrl.abort(), REQUEST_TIMEOUT_MS);
|
|
53
67
|
try {
|
|
54
|
-
const res = await fetch(url, { method, headers, signal: ctrl.signal });
|
|
68
|
+
const res = await fetch(url, { method, headers, body: requestBody, signal: ctrl.signal });
|
|
55
69
|
const body = await res.text();
|
|
56
70
|
if (!res.ok) {
|
|
57
71
|
const hint =
|
|
@@ -85,14 +99,14 @@ for (const tool of TOOLS) {
|
|
|
85
99
|
const method = tool.method || "GET";
|
|
86
100
|
const annotations = {
|
|
87
101
|
title: tool.name,
|
|
88
|
-
readOnlyHint: !tool.write,
|
|
102
|
+
readOnlyHint: !tool.write,
|
|
89
103
|
destructiveHint: Boolean(tool.destructive),
|
|
90
104
|
openWorldHint: true,
|
|
91
105
|
};
|
|
92
106
|
server.registerTool(
|
|
93
107
|
tool.name,
|
|
94
108
|
{ description: tool.description, inputSchema: tool.shape, annotations },
|
|
95
|
-
async (args) => callEndpoint(tool.path, args, method),
|
|
109
|
+
async (args) => callEndpoint(tool.path, args, method, tool),
|
|
96
110
|
);
|
|
97
111
|
}
|
|
98
112
|
|
package/src/tools.js
CHANGED
|
@@ -5,9 +5,22 @@
|
|
|
5
5
|
// Each tool maps 1:1 to a REST endpoint at https://api.redditapis.com/api/reddit.
|
|
6
6
|
// Tool arg names map 1:1 to endpoint query params, EXCEPT path params written as
|
|
7
7
|
// {name}/{id} in a tool's `path` (e.g. /api/reddit/sub/{name}/top), which are
|
|
8
|
-
// interpolated into the URL and removed from the query string.
|
|
9
|
-
//
|
|
10
|
-
//
|
|
8
|
+
// interpolated into the URL and removed from the query string.
|
|
9
|
+
//
|
|
10
|
+
// WRITES: reddit writes (comment/vote/DM -- posting AS the customer to Reddit
|
|
11
|
+
// itself) are a separate authenticated surface and remain intentionally out of
|
|
12
|
+
// scope here, that boundary is unchanged. Monitor/webhook management (2026-08-11,
|
|
13
|
+
// task #43) is a DIFFERENT kind of write: it configures the customer's OWN
|
|
14
|
+
// redditapis.com account (an alerting subscription), the same risk class as
|
|
15
|
+
// reading their own usage -- it never posts, votes, or DMs anywhere. Those tools
|
|
16
|
+
// are marked `write: true` (and `destructive: true` for remove/delete), which
|
|
17
|
+
// index.js turns into MCP's `readOnlyHint`/`destructiveHint` annotations so a
|
|
18
|
+
// client can surface a confirmation before calling them.
|
|
19
|
+
//
|
|
20
|
+
// BODY-BEARING TOOLS: a POST tool's args become a JSON request body instead of
|
|
21
|
+
// a query string (see buildBody below). `filterSpecFields` on a tool declares
|
|
22
|
+
// which of its args nest under `filter_spec` in that body; everything else in
|
|
23
|
+
// `shape` stays top-level (id, cadence_s, active, url, kind, ...).
|
|
11
24
|
import { z } from "zod";
|
|
12
25
|
|
|
13
26
|
// Shared Zod input-schema fragments.
|
|
@@ -16,9 +29,13 @@ const LIMIT = {
|
|
|
16
29
|
"Max items to return (1 to 100). The API clamps out-of-range values; endpoint default applies if omitted.",
|
|
17
30
|
),
|
|
18
31
|
};
|
|
32
|
+
// Shared by every listing and search tool, so the rule about what an EMPTY
|
|
33
|
+
// cursor means is stated once rather than pasted into a dozen tool descriptions
|
|
34
|
+
// where one of them would eventually be missed.
|
|
19
35
|
const AFTER = {
|
|
20
36
|
after: z.string().optional().describe(
|
|
21
|
-
"Opaque
|
|
37
|
+
"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. " +
|
|
38
|
+
"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.",
|
|
22
39
|
),
|
|
23
40
|
};
|
|
24
41
|
// Sort vocabularies differ by endpoint (they map to different Reddit listings):
|
|
@@ -62,14 +79,105 @@ const QUERY = {
|
|
|
62
79
|
"Search query text. Supports Reddit search syntax (e.g. `subreddit:webdev`, `author:spez`, `\"exact phrase\"`, `title:...`).",
|
|
63
80
|
),
|
|
64
81
|
};
|
|
82
|
+
// Auth-scoped private-listing cookie fragment (upvoted/saved/hidden/gilded).
|
|
83
|
+
// These four are READS, but of PRIVATE data Reddit only serves to the account
|
|
84
|
+
// that owns it, so -- unlike every other read tool above -- they need the
|
|
85
|
+
// caller's own Reddit session cookies, obtained from POST /api/reddit/login
|
|
86
|
+
// (a REST call, not an MCP tool here). GET can't carry cookies as a body, so
|
|
87
|
+
// they travel as flat query-string args, same convention routes/vote.js uses
|
|
88
|
+
// for its flat POST-body cookie fields.
|
|
89
|
+
const LISTING_COOKIES = {
|
|
90
|
+
reddit_session: z.string().min(1).describe(
|
|
91
|
+
"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.",
|
|
92
|
+
),
|
|
93
|
+
loid: z.string().min(1).describe(
|
|
94
|
+
"Reddit account loid cookie, from the same POST /api/reddit/login response. Required.",
|
|
95
|
+
),
|
|
96
|
+
csrf_token: z.string().optional().describe(
|
|
97
|
+
"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.",
|
|
98
|
+
),
|
|
99
|
+
};
|
|
100
|
+
|
|
101
|
+
// ── monitoring filter_spec fragments, shared by monitor_add and monitor_update ──
|
|
102
|
+
// v1 is SUBREDDIT-SCOPED ONLY (all-Reddit keyword monitoring is not available),
|
|
103
|
+
// matching apps/api/src/lib/monitor-filter.js's own validation exactly, so a
|
|
104
|
+
// tool call fails fast in the client rather than round-tripping a 400.
|
|
105
|
+
//
|
|
106
|
+
// THE "POSTS ONLY" HALF OF THIS COMMENT WAS STALE AND COST A REAL GAP. It said
|
|
107
|
+
// comment monitoring was "validated but rejected server-side", which stopped
|
|
108
|
+
// being true when comment monitoring shipped (migrations 009/010, PRs
|
|
109
|
+
// #532/#537/#541). Because `kind` was never added here, the capability was
|
|
110
|
+
// unreachable from MCP entirely: verified live 2026-08-13 by setting
|
|
111
|
+
// filter_spec.kind="both" over raw REST and watching real comment deliveries
|
|
112
|
+
// land within two minutes, on a tier the pricing page sells as "posts and
|
|
113
|
+
// comments". A stale comment is a defect report, not documentation.
|
|
114
|
+
// SITEWIDE (2026-08-13). `subreddit` is OPTIONAL now: omit it and pass `q` to
|
|
115
|
+
// watch ALL of Reddit for that keyword. Leaving it required here would repeat
|
|
116
|
+
// the exact failure the comment above describes -- a shipped, sold capability
|
|
117
|
+
// that no MCP client can reach because one schema field was never relaxed,
|
|
118
|
+
// with nothing anywhere reporting an error.
|
|
119
|
+
const MONITOR_SUBREDDIT_DESC =
|
|
120
|
+
"Subreddits to watch, without the r/ prefix (e.g. ['SaaS', 'startups']). 1 to 50. " +
|
|
121
|
+
"OMIT this entirely (and set `q`) to watch ALL of Reddit for a keyword -- a monitor must be anchored by " +
|
|
122
|
+
"either a subreddit list or a keyword, never neither. Do NOT pass ['all']: r/all is Reddit's site-wide " +
|
|
123
|
+
"listing rather than a subreddit, so it is refused with 400 subreddit_reserved. Sitewide monitors are " +
|
|
124
|
+
"capped per plan tier (see reddit_monitor_list's `slots`) and cover POSTS only.";
|
|
125
|
+
const MONITOR_SUBREDDIT = {
|
|
126
|
+
subreddit: z.array(z.string().min(1)).min(1).max(50).optional().describe(MONITOR_SUBREDDIT_DESC),
|
|
127
|
+
};
|
|
128
|
+
const MONITOR_FILTER_FIELDS = {
|
|
129
|
+
q: z.string().max(200).optional().describe(
|
|
130
|
+
"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.",
|
|
131
|
+
),
|
|
132
|
+
author: z.string().max(200).optional().describe("Only match posts by this Reddit username (without u/)."),
|
|
133
|
+
exclude_terms: z.array(z.string().min(1).max(200)).max(50).optional().describe(
|
|
134
|
+
"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).",
|
|
135
|
+
),
|
|
136
|
+
domain: z.array(z.string().min(1).max(200)).max(50).optional().describe(
|
|
137
|
+
"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).",
|
|
138
|
+
),
|
|
139
|
+
include_any: z.array(z.string().min(1).max(200)).max(50).optional().describe(
|
|
140
|
+
"At least ONE of these terms must appear (OR match) for the post to qualify, on top of any `q`."
|
|
141
|
+
),
|
|
142
|
+
include_all: z.array(z.string().min(1).max(200)).max(50).optional().describe(
|
|
143
|
+
"EVERY one of these terms must appear (AND match) for the post to qualify, on top of any `q`."
|
|
144
|
+
),
|
|
145
|
+
search_in: z.array(z.enum(["title", "body", "url", "permalink"])).min(1).optional().describe(
|
|
146
|
+
"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.",
|
|
147
|
+
),
|
|
148
|
+
group: z.string().max(64).optional().describe(
|
|
149
|
+
"Optional label to bundle multiple matches into one delivery instead of one webhook call per match. Omit for one delivery per matching item.",
|
|
150
|
+
),
|
|
151
|
+
kind: z.enum(["post", "comment", "both"]).optional().describe(
|
|
152
|
+
"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.",
|
|
153
|
+
),
|
|
154
|
+
min_score: z.number().int().optional().describe("Only match posts with at least this many upvotes."),
|
|
155
|
+
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."),
|
|
156
|
+
};
|
|
157
|
+
// Per-monitor webhook targeting (task #66, migration 009). NOT a filter_spec
|
|
158
|
+
// field -- stays top-level in the request body, same as cadence_s/active, so
|
|
159
|
+
// it is deliberately absent from MONITOR_FILTER_FIELDS/filterSpecFields.
|
|
160
|
+
const MONITOR_WEBHOOK_IDS = {
|
|
161
|
+
webhook_ids: z.array(z.string().min(1)).max(20).optional().describe(
|
|
162
|
+
"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.",
|
|
163
|
+
),
|
|
164
|
+
};
|
|
165
|
+
const MONITOR_ID = {
|
|
166
|
+
id: z.string().min(1).describe("The monitor's id, from reddit_monitor_add's response or reddit_monitor_list."),
|
|
167
|
+
};
|
|
168
|
+
const WEBHOOK_ID = {
|
|
169
|
+
id: z.string().min(1).describe("The webhook's id, from reddit_monitor_webhook_create's response or reddit_monitor_webhook_list."),
|
|
170
|
+
};
|
|
65
171
|
|
|
66
|
-
// The tool catalog.
|
|
172
|
+
// The tool catalog. Path params are {name}/{id} placeholders. Monitor/webhook
|
|
173
|
+
// tools additionally carry `write`/`destructive` and, for POST tools whose
|
|
174
|
+
// body nests a filter_spec, `filterSpecFields` (see buildBody below).
|
|
67
175
|
export const TOOLS = [
|
|
68
176
|
{
|
|
69
177
|
name: "reddit_subreddit_posts",
|
|
70
178
|
path: "/api/reddit/posts",
|
|
71
179
|
description:
|
|
72
|
-
"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. Example: subreddit='programming' sort='top' t='week'.",
|
|
180
|
+
"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'.",
|
|
73
181
|
shape: {
|
|
74
182
|
subreddit: z.string().min(1).describe(
|
|
75
183
|
"Subreddit name WITHOUT the r/ prefix (e.g. 'programming', 'AskReddit'). Required.",
|
|
@@ -248,6 +356,70 @@ export const TOOLS = [
|
|
|
248
356
|
...LIMIT,
|
|
249
357
|
},
|
|
250
358
|
},
|
|
359
|
+
{
|
|
360
|
+
name: "reddit_user_upvoted",
|
|
361
|
+
path: "/api/reddit/user/{name}/upvoted",
|
|
362
|
+
description:
|
|
363
|
+
"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).",
|
|
364
|
+
shape: {
|
|
365
|
+
name: z.string().min(1).describe(
|
|
366
|
+
"Reddit username WITHOUT the u/ prefix. Must be the SAME account the supplied cookies belong to, or Reddit returns 403. Required (path parameter).",
|
|
367
|
+
),
|
|
368
|
+
...LISTING_COOKIES,
|
|
369
|
+
...SORT_USER,
|
|
370
|
+
...TIME,
|
|
371
|
+
...AFTER,
|
|
372
|
+
...LIMIT,
|
|
373
|
+
},
|
|
374
|
+
},
|
|
375
|
+
{
|
|
376
|
+
name: "reddit_user_saved",
|
|
377
|
+
path: "/api/reddit/user/{name}/saved",
|
|
378
|
+
description:
|
|
379
|
+
"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).",
|
|
380
|
+
shape: {
|
|
381
|
+
name: z.string().min(1).describe(
|
|
382
|
+
"Reddit username WITHOUT the u/ prefix. Must be the SAME account the supplied cookies belong to, or Reddit returns 403. Required (path parameter).",
|
|
383
|
+
),
|
|
384
|
+
...LISTING_COOKIES,
|
|
385
|
+
...SORT_USER,
|
|
386
|
+
...TIME,
|
|
387
|
+
...AFTER,
|
|
388
|
+
...LIMIT,
|
|
389
|
+
},
|
|
390
|
+
},
|
|
391
|
+
{
|
|
392
|
+
name: "reddit_user_hidden",
|
|
393
|
+
path: "/api/reddit/user/{name}/hidden",
|
|
394
|
+
description:
|
|
395
|
+
"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).",
|
|
396
|
+
shape: {
|
|
397
|
+
name: z.string().min(1).describe(
|
|
398
|
+
"Reddit username WITHOUT the u/ prefix. Must be the SAME account the supplied cookies belong to, or Reddit returns 403. Required (path parameter).",
|
|
399
|
+
),
|
|
400
|
+
...LISTING_COOKIES,
|
|
401
|
+
...SORT_USER,
|
|
402
|
+
...TIME,
|
|
403
|
+
...AFTER,
|
|
404
|
+
...LIMIT,
|
|
405
|
+
},
|
|
406
|
+
},
|
|
407
|
+
{
|
|
408
|
+
name: "reddit_user_gilded",
|
|
409
|
+
path: "/api/reddit/user/{name}/gilded",
|
|
410
|
+
description:
|
|
411
|
+
"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).",
|
|
412
|
+
shape: {
|
|
413
|
+
name: z.string().min(1).describe(
|
|
414
|
+
"Reddit username WITHOUT the u/ prefix. Must be the SAME account the supplied cookies belong to, or Reddit returns 403. Required (path parameter).",
|
|
415
|
+
),
|
|
416
|
+
...LISTING_COOKIES,
|
|
417
|
+
...SORT_USER,
|
|
418
|
+
...TIME,
|
|
419
|
+
...AFTER,
|
|
420
|
+
...LIMIT,
|
|
421
|
+
},
|
|
422
|
+
},
|
|
251
423
|
{
|
|
252
424
|
name: "reddit_subreddit_comments",
|
|
253
425
|
path: "/api/reddit/sub/{name}/comments",
|
|
@@ -312,7 +484,7 @@ export const TOOLS = [
|
|
|
312
484
|
name: "reddit_by_id",
|
|
313
485
|
path: "/api/reddit/by_id/{fullnames}",
|
|
314
486
|
description:
|
|
315
|
-
"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
|
|
487
|
+
"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'.",
|
|
316
488
|
shape: {
|
|
317
489
|
fullnames: z.string().min(1).describe(
|
|
318
490
|
"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).",
|
|
@@ -340,6 +512,127 @@ export const TOOLS = [
|
|
|
340
512
|
"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.",
|
|
341
513
|
shape: { ...AFTER, ...LIMIT },
|
|
342
514
|
},
|
|
515
|
+
|
|
516
|
+
// ── monitoring: manage the caller's own account, never posts/votes/DMs Reddit itself ──
|
|
517
|
+
{
|
|
518
|
+
name: "reddit_monitor_add",
|
|
519
|
+
path: "/api/reddit/monitor/add",
|
|
520
|
+
method: "POST",
|
|
521
|
+
write: true,
|
|
522
|
+
filterSpecFields: ["subreddit", "kind", "q", "author", "exclude_terms", "domain", "include_any", "include_all", "search_in", "group", "min_score", "nsfw"],
|
|
523
|
+
description:
|
|
524
|
+
"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.",
|
|
525
|
+
shape: {
|
|
526
|
+
...MONITOR_SUBREDDIT,
|
|
527
|
+
...MONITOR_FILTER_FIELDS,
|
|
528
|
+
cadence_s: z.number().int().positive().optional().describe(
|
|
529
|
+
"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.",
|
|
530
|
+
),
|
|
531
|
+
baseline_item_id: z.string().optional().describe(
|
|
532
|
+
"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.",
|
|
533
|
+
),
|
|
534
|
+
...MONITOR_WEBHOOK_IDS,
|
|
535
|
+
},
|
|
536
|
+
},
|
|
537
|
+
{
|
|
538
|
+
name: "reddit_monitor_list",
|
|
539
|
+
path: "/api/reddit/monitor/list",
|
|
540
|
+
description:
|
|
541
|
+
"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`.",
|
|
542
|
+
shape: {},
|
|
543
|
+
},
|
|
544
|
+
{
|
|
545
|
+
name: "reddit_monitor_update",
|
|
546
|
+
path: "/api/reddit/monitor/update",
|
|
547
|
+
method: "POST",
|
|
548
|
+
write: true,
|
|
549
|
+
filterSpecFields: ["subreddit", "kind", "q", "author", "exclude_terms", "domain", "include_any", "include_all", "search_in", "group", "min_score", "nsfw"],
|
|
550
|
+
description:
|
|
551
|
+
"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.",
|
|
552
|
+
shape: {
|
|
553
|
+
...MONITOR_ID,
|
|
554
|
+
...{
|
|
555
|
+
subreddit: z.array(z.string().min(1)).min(1).max(50).optional().describe(
|
|
556
|
+
MONITOR_SUBREDDIT_DESC +
|
|
557
|
+
" 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.",
|
|
558
|
+
),
|
|
559
|
+
},
|
|
560
|
+
...MONITOR_FILTER_FIELDS,
|
|
561
|
+
active: z.boolean().optional().describe("Set false to pause the monitor (stops matching/delivering), true to resume it."),
|
|
562
|
+
cadence_s: z.number().int().positive().optional().describe("New poll interval in seconds, same tier-floor clamping as reddit_monitor_add."),
|
|
563
|
+
...MONITOR_WEBHOOK_IDS,
|
|
564
|
+
},
|
|
565
|
+
},
|
|
566
|
+
{
|
|
567
|
+
name: "reddit_monitor_remove",
|
|
568
|
+
path: "/api/reddit/monitor/remove",
|
|
569
|
+
method: "POST",
|
|
570
|
+
write: true,
|
|
571
|
+
destructive: true,
|
|
572
|
+
description:
|
|
573
|
+
"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.",
|
|
574
|
+
shape: { ...MONITOR_ID },
|
|
575
|
+
},
|
|
576
|
+
{
|
|
577
|
+
name: "reddit_monitor_health",
|
|
578
|
+
path: "/api/reddit/monitor/health",
|
|
579
|
+
description:
|
|
580
|
+
"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.",
|
|
581
|
+
shape: { ...MONITOR_ID },
|
|
582
|
+
},
|
|
583
|
+
{
|
|
584
|
+
name: "reddit_monitor_deliveries",
|
|
585
|
+
path: "/api/reddit/monitor/deliveries",
|
|
586
|
+
description:
|
|
587
|
+
"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). Omit `id` to aggregate history across every monitor you own.",
|
|
588
|
+
shape: {
|
|
589
|
+
id: z.string().optional().describe("Narrow to one monitor's history. Omit to aggregate across every monitor you own."),
|
|
590
|
+
status: z.enum(["pending", "delivered", "failed", "dead", "suppressed"]).optional().describe(
|
|
591
|
+
"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.",
|
|
592
|
+
),
|
|
593
|
+
limit: z.number().int().min(1).max(200).optional().describe("Max rows to return, 1 to 200. Default 50."),
|
|
594
|
+
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."),
|
|
595
|
+
},
|
|
596
|
+
},
|
|
597
|
+
{
|
|
598
|
+
name: "reddit_monitor_webhook_create",
|
|
599
|
+
path: "/api/reddit/monitor/webhook/create",
|
|
600
|
+
method: "POST",
|
|
601
|
+
write: true,
|
|
602
|
+
description:
|
|
603
|
+
"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.",
|
|
604
|
+
shape: {
|
|
605
|
+
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."),
|
|
606
|
+
kind: z.enum(["webhook", "slack", "discord", "email"]).optional().describe(
|
|
607
|
+
"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.",
|
|
608
|
+
),
|
|
609
|
+
},
|
|
610
|
+
},
|
|
611
|
+
{
|
|
612
|
+
name: "reddit_monitor_webhook_list",
|
|
613
|
+
path: "/api/reddit/monitor/webhook/list",
|
|
614
|
+
description: "List every webhook registered on the caller's account. Never returns the signing secret (shown once, at creation, by reddit_monitor_webhook_create).",
|
|
615
|
+
shape: {},
|
|
616
|
+
},
|
|
617
|
+
{
|
|
618
|
+
name: "reddit_monitor_webhook_test",
|
|
619
|
+
path: "/api/reddit/monitor/webhook/test",
|
|
620
|
+
method: "POST",
|
|
621
|
+
write: true,
|
|
622
|
+
description:
|
|
623
|
+
"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).",
|
|
624
|
+
shape: { ...WEBHOOK_ID },
|
|
625
|
+
},
|
|
626
|
+
{
|
|
627
|
+
name: "reddit_monitor_webhook_delete",
|
|
628
|
+
path: "/api/reddit/monitor/webhook/delete",
|
|
629
|
+
method: "POST",
|
|
630
|
+
write: true,
|
|
631
|
+
destructive: true,
|
|
632
|
+
description:
|
|
633
|
+
"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.",
|
|
634
|
+
shape: { ...WEBHOOK_ID },
|
|
635
|
+
},
|
|
343
636
|
];
|
|
344
637
|
|
|
345
638
|
// Turn tool args into a URL query string. Skips undefined/null/empty values.
|
|
@@ -366,3 +659,27 @@ export function buildPath(template, args) {
|
|
|
366
659
|
for (const [k, v] of Object.entries(args || {})) if (!used.has(k)) rest[k] = v;
|
|
367
660
|
return { path, rest };
|
|
368
661
|
}
|
|
662
|
+
|
|
663
|
+
// Turn a POST tool's args into the JSON body the REST endpoint expects. A field
|
|
664
|
+
// named in `tool.filterSpecFields` nests under `filter_spec`; everything else
|
|
665
|
+
// stays top-level (id, cadence_s, active, url, kind, ...). Skips undefined so
|
|
666
|
+
// an omitted optional arg is genuinely absent from the body, never sent as
|
|
667
|
+
// `null` -- monitor-handlers.js tells "field not provided" apart from "field
|
|
668
|
+
// explicitly cleared" (e.g. updateMonitor only replaces filter_spec when at
|
|
669
|
+
// least one filter field is present at all).
|
|
670
|
+
export function buildBody(tool, args) {
|
|
671
|
+
const filterFields = new Set(tool.filterSpecFields || []);
|
|
672
|
+
const body = {};
|
|
673
|
+
let filterSpec = null;
|
|
674
|
+
for (const [k, v] of Object.entries(args || {})) {
|
|
675
|
+
if (v === undefined) continue;
|
|
676
|
+
if (filterFields.has(k)) {
|
|
677
|
+
filterSpec = filterSpec || {};
|
|
678
|
+
filterSpec[k] = v;
|
|
679
|
+
} else {
|
|
680
|
+
body[k] = v;
|
|
681
|
+
}
|
|
682
|
+
}
|
|
683
|
+
if (filterSpec) body.filter_spec = filterSpec;
|
|
684
|
+
return body;
|
|
685
|
+
}
|