redditapis-mcp 0.1.12 → 0.2.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 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
  [![npm downloads](https://img.shields.io/npm/dm/redditapis-mcp)](https://www.npmjs.com/package/redditapis-mcp)
5
5
  [![license](https://img.shields.io/npm/l/redditapis-mcp)](./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,7 +99,7 @@ Authentication is a Bearer token: the server sends `Authorization: Bearer <REDDI
99
99
 
100
100
  ## Tools
101
101
 
102
- 22 tools, all reads. This server is **read-only** by design. Reddit writes (posting, commenting, voting, DMs) are a separate authenticated surface and are intentionally out of scope here, so every tool below works with just your API key, with no account login or session step.
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
 
@@ -154,6 +154,23 @@ A few conventions across the catalog:
154
154
  | `reddit_subreddits_new` | `GET /api/reddit/subreddits/new` | Browse the newest subreddits, most recently created first. |
155
155
  | `reddit_subreddits_default` | `GET /api/reddit/subreddits/default` | Browse Reddit's default front-page set of subreddits. |
156
156
 
157
+ ### Monitoring: manage your own monitors and webhooks
158
+
159
+ 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.
160
+
161
+ | Tool | Endpoint | What it does |
162
+ |---|---|---|
163
+ | `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`). |
164
+ | `reddit_monitor_list` | `GET /api/reddit/monitor/list` | List every monitor on your account, plus `slots` ({used, total, tier}). |
165
+ | `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. |
166
+ | `reddit_monitor_remove` | `POST /api/reddit/monitor/remove` | Permanently delete a monitor. Cannot be undone. |
167
+ | `reddit_monitor_health` | `GET /api/reddit/monitor/health` | Per-monitor delivered/failed/suppressed counts (last 24h) and whether the delivery ceiling has been hit. |
168
+ | `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. |
169
+ | `reddit_monitor_webhook_create` | `POST /api/reddit/monitor/webhook/create` | Register a delivery target (`webhook`/`slack`/`discord`). Returns a signing secret shown ONCE. |
170
+ | `reddit_monitor_webhook_list` | `GET /api/reddit/monitor/webhook/list` | List your webhooks. Never returns the secret. |
171
+ | `reddit_monitor_webhook_test` | `POST /api/reddit/monitor/webhook/test` | Send a one-off test delivery to confirm a webhook is wired up correctly. |
172
+ | `reddit_monitor_webhook_delete` | `POST /api/reddit/monitor/webhook/delete` | Permanently delete a webhook. Does not cascade-pause monitors still pointing at it. |
173
+
157
174
  ## Usage examples
158
175
 
159
176
  ### Research a topic across Reddit
@@ -210,15 +227,35 @@ t: "year"
210
227
 
211
228
  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
229
 
213
- ## Reads only, no session needed
230
+ ### Set up brand monitoring and check what came in
231
+
232
+ > "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."
233
+
234
+ The agent calls `reddit_monitor_webhook_create` with:
235
+
236
+ ```
237
+ url: "https://hooks.slack.com/services/..."
238
+ kind: "slack"
239
+ ```
240
+
241
+ Then `reddit_monitor_add` with:
242
+
243
+ ```
244
+ subreddit: ["SaaS", "startups"]
245
+ q: "my product name"
246
+ ```
247
+
248
+ 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.
249
+
250
+ ## No session needed for reads; monitor/webhook writes need an active plan
214
251
 
215
- Because this server exposes reads and nothing that writes, there is no account linking, login, or cookie step: your API key alone authorizes every call. Reddit write actions (posting, commenting, voting, sending DMs) are handled by a separate authenticated surface outside this package and are deliberately not exposed here, so an agent using this server cannot take an action on any account.
252
+ 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
253
 
217
254
  ## Troubleshooting
218
255
 
219
256
  **`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
257
 
221
- **`HTTP 402 (insufficient credits)`** Top up your account at [redditapis.com](https://www.redditapis.com).
258
+ **`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
259
 
223
260
  **`HTTP 403 (access forbidden)`** The subreddit or user may be private, banned, or quarantined, or your plan may not include this endpoint.
224
261
 
@@ -248,7 +285,7 @@ npm start # run the stdio server (needs REDDITAPIS_KEY)
248
285
 
249
286
  **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
287
 
251
- **Can it post, comment, or vote?** No. This server is read-only. All 22 tools read Reddit; writes are a separate authenticated surface and are not exposed here.
288
+ **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
289
 
253
290
  **Which clients are supported?** Claude Desktop, Claude Code, Cursor, Windsurf, and VS Code (Copilot agent mode), or any Model Context Protocol client.
254
291
 
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "redditapis-mcp",
3
- "version": "0.1.12",
4
- "description": "Official MCP server for redditapis.com, the Reddit API (subreddit listings, post + comment + community + user + media search, comment trees, top posts, user profiles) as native tools for Claude, Cursor, and any MCP client.",
3
+ "version": "0.2.0",
4
+ "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
5
  "repository": {
6
6
  "type": "git",
7
7
  "url": "git+https://github.com/redditapis/redditapis-mcp.git"
@@ -25,7 +25,8 @@
25
25
  "scripts": {
26
26
  "start": "node src/index.js",
27
27
  "check": "node --check src/index.js && node --check src/tools.js",
28
- "test": "node test/tools.test.mjs"
28
+ "test": "node test/tools.test.mjs",
29
+ "prepublishOnly": "node scripts/prepublish-tenant-check.mjs"
29
30
  },
30
31
  "dependencies": {
31
32
  "@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, and user profile/comments.
7
- // Each tool is a thin, typed wrapper over a REST endpoint at
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
- const VERSION = "0.1.0";
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, sends the rest as
38
- // the query string, and forwards the API key as a Bearer token (redditapis.com
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 q = buildQuery(rest);
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, // every tool here is a read
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. Every tool here
9
- // is a READ (GET); reddit writes (comment/vote/DM) are a separate authenticated
10
- // surface and are intentionally out of scope for this reads MCP.
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.
@@ -63,7 +76,52 @@ const QUERY = {
63
76
  ),
64
77
  };
65
78
 
66
- // The tool catalog. All reads (GET). Path params are {name}/{id} placeholders.
79
+ // ── monitoring filter_spec fragments, shared by monitor_add and monitor_update ──
80
+ // v1 is SUBREDDIT-SCOPED ONLY (all-Reddit keyword monitoring is not available)
81
+ // and POSTS ONLY (comment monitoring is validated but rejected server-side) --
82
+ // both match apps/api/src/lib/monitor-filter.js's own validation exactly, so a
83
+ // tool call fails fast in the client rather than round-tripping a 400.
84
+ const MONITOR_SUBREDDIT = {
85
+ subreddit: z.array(z.string().min(1)).min(1).max(50).describe(
86
+ "Subreddits to watch, without the r/ prefix (e.g. ['SaaS', 'startups']). REQUIRED, 1 to 50. All-of-Reddit monitoring is not available in v1 -- every monitor must scope to specific subreddits.",
87
+ ),
88
+ };
89
+ const MONITOR_FILTER_FIELDS = {
90
+ q: z.string().max(200).optional().describe(
91
+ "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.",
92
+ ),
93
+ author: z.string().max(200).optional().describe("Only match posts by this Reddit username (without u/)."),
94
+ exclude_terms: z.array(z.string().min(1).max(200)).max(50).optional().describe(
95
+ "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).",
96
+ ),
97
+ domain: z.array(z.string().min(1).max(200)).max(50).optional().describe(
98
+ "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 (validated but not yet populated by the live poller) a crosspost's original link. Exact-or-subdomain match only: 'example.com' matches 'blog.example.com' but never 'notexample.com'. Does NOT resolve shortened links (bit.ly, t.co).",
99
+ ),
100
+ include_any: z.array(z.string().min(1).max(200)).max(50).optional().describe(
101
+ "At least ONE of these terms must appear (OR match) for the post to qualify, on top of any `q`."
102
+ ),
103
+ include_all: z.array(z.string().min(1).max(200)).max(50).optional().describe(
104
+ "EVERY one of these terms must appear (AND match) for the post to qualify, on top of any `q`."
105
+ ),
106
+ search_in: z.array(z.enum(["title", "body", "url", "permalink"])).min(1).optional().describe(
107
+ "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.",
108
+ ),
109
+ group: z.string().max(64).optional().describe(
110
+ "Optional label to bundle multiple matches into one delivery instead of one webhook call per match. Omit for one delivery per matching item.",
111
+ ),
112
+ min_score: z.number().int().optional().describe("Only match posts with at least this many upvotes."),
113
+ nsfw: z.boolean().optional().describe("Include NSFW/over-18 posts. Default false (excluded)."),
114
+ };
115
+ const MONITOR_ID = {
116
+ id: z.string().min(1).describe("The monitor's id, from reddit_monitor_add's response or reddit_monitor_list."),
117
+ };
118
+ const WEBHOOK_ID = {
119
+ id: z.string().min(1).describe("The webhook's id, from reddit_monitor_webhook_create's response or reddit_monitor_webhook_list."),
120
+ };
121
+
122
+ // The tool catalog. Path params are {name}/{id} placeholders. Monitor/webhook
123
+ // tools additionally carry `write`/`destructive` and, for POST tools whose
124
+ // body nests a filter_spec, `filterSpecFields` (see buildBody below).
67
125
  export const TOOLS = [
68
126
  {
69
127
  name: "reddit_subreddit_posts",
@@ -340,6 +398,120 @@ export const TOOLS = [
340
398
  "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
399
  shape: { ...AFTER, ...LIMIT },
342
400
  },
401
+
402
+ // ── monitoring: manage the caller's own account, never posts/votes/DMs Reddit itself ──
403
+ {
404
+ name: "reddit_monitor_add",
405
+ path: "/api/reddit/monitor/add",
406
+ method: "POST",
407
+ write: true,
408
+ filterSpecFields: ["subreddit", "q", "author", "exclude_terms", "domain", "include_any", "include_all", "search_in", "group", "min_score", "nsfw"],
409
+ description:
410
+ "Create a new Reddit monitor: watch one or more subreddits for new posts matching a filter, and get every match delivered to a webhook you've registered with reddit_monitor_webhook_create. Requires an active redditapis.com monitoring plan (monitoring has no free tier) and 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 there is no active plan, `monitor_slots_exhausted` (402) if the plan's slot limit is reached, or `subreddit_not_found` (400) if a named subreddit does not exist.",
411
+ shape: {
412
+ ...MONITOR_SUBREDDIT,
413
+ ...MONITOR_FILTER_FIELDS,
414
+ cadence_s: z.number().int().positive().optional().describe(
415
+ "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.",
416
+ ),
417
+ baseline_item_id: z.string().optional().describe(
418
+ "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.",
419
+ ),
420
+ },
421
+ },
422
+ {
423
+ name: "reddit_monitor_list",
424
+ path: "/api/reddit/monitor/list",
425
+ description:
426
+ "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).",
427
+ shape: {},
428
+ },
429
+ {
430
+ name: "reddit_monitor_update",
431
+ path: "/api/reddit/monitor/update",
432
+ method: "POST",
433
+ write: true,
434
+ filterSpecFields: ["subreddit", "q", "author", "exclude_terms", "domain", "include_any", "include_all", "search_in", "group", "min_score", "nsfw"],
435
+ description:
436
+ "Update an existing monitor: pause/resume it (`active`), change its poll interval (`cadence_s`), or replace its filter entirely. IMPORTANT: if you pass ANY filter field (subreddit, q, domain, etc.), it REPLACES the whole filter, it does not merge with the existing one -- resupply every field you want kept, including `subreddit`. Omit all filter fields to change only `active`/`cadence_s` and leave the filter untouched. Returns 404 `monitor_not_found` if the id does not exist or is not yours.",
437
+ shape: {
438
+ ...MONITOR_ID,
439
+ ...{ subreddit: MONITOR_SUBREDDIT.subreddit.optional().describe(MONITOR_SUBREDDIT.subreddit.description + " Required if you are replacing the filter at all.") },
440
+ ...MONITOR_FILTER_FIELDS,
441
+ active: z.boolean().optional().describe("Set false to pause the monitor (stops matching/delivering), true to resume it."),
442
+ cadence_s: z.number().int().positive().optional().describe("New poll interval in seconds, same tier-floor clamping as reddit_monitor_add."),
443
+ },
444
+ },
445
+ {
446
+ name: "reddit_monitor_remove",
447
+ path: "/api/reddit/monitor/remove",
448
+ method: "POST",
449
+ write: true,
450
+ destructive: true,
451
+ description:
452
+ "Permanently delete a monitor. This cannot be undone -- its filter and delivery history stop growing (past deliveries remain queryable via reddit_monitor_deliveries) and its slot is freed for a new monitor. Returns 404 `monitor_not_found` if the id does not exist or is not yours.",
453
+ shape: { ...MONITOR_ID },
454
+ },
455
+ {
456
+ name: "reddit_monitor_health",
457
+ path: "/api/reddit/monitor/health",
458
+ description:
459
+ "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`). Use this to check whether a monitor is silently starved before assuming it just has nothing to report.",
460
+ shape: { ...MONITOR_ID },
461
+ },
462
+ {
463
+ name: "reddit_monitor_deliveries",
464
+ path: "/api/reddit/monitor/deliveries",
465
+ description:
466
+ "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.",
467
+ shape: {
468
+ id: z.string().optional().describe("Narrow to one monitor's history. Omit to aggregate across every monitor you own."),
469
+ status: z.enum(["pending", "delivered", "failed", "dead", "suppressed"]).optional().describe(
470
+ "Filter to one delivery status. 'dead' = retries exhausted, gave up. 'suppressed' = matched but deliberately not sent (e.g. delivery ceiling). Omit for all statuses.",
471
+ ),
472
+ limit: z.number().int().min(1).max(200).optional().describe("Max rows to return, 1 to 200. Default 50."),
473
+ 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."),
474
+ },
475
+ },
476
+ {
477
+ name: "reddit_monitor_webhook_create",
478
+ path: "/api/reddit/monitor/webhook/create",
479
+ method: "POST",
480
+ write: true,
481
+ description:
482
+ "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.",
483
+ shape: {
484
+ 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."),
485
+ kind: z.enum(["webhook", "slack", "discord", "email"]).optional().describe(
486
+ "Payload shape to send. 'slack'/'discord' format as native incoming-webhook messages; 'webhook' (default) sends redditapis' generic signed JSON envelope; 'email' is not yet a real delivery transport.",
487
+ ),
488
+ },
489
+ },
490
+ {
491
+ name: "reddit_monitor_webhook_list",
492
+ path: "/api/reddit/monitor/webhook/list",
493
+ description: "List every webhook registered on the caller's account. Never returns the signing secret (shown once, at creation, by reddit_monitor_webhook_create).",
494
+ shape: {},
495
+ },
496
+ {
497
+ name: "reddit_monitor_webhook_test",
498
+ path: "/api/reddit/monitor/webhook/test",
499
+ method: "POST",
500
+ write: true,
501
+ description:
502
+ "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. 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).",
503
+ shape: { ...WEBHOOK_ID },
504
+ },
505
+ {
506
+ name: "reddit_monitor_webhook_delete",
507
+ path: "/api/reddit/monitor/webhook/delete",
508
+ method: "POST",
509
+ write: true,
510
+ destructive: true,
511
+ description:
512
+ "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.",
513
+ shape: { ...WEBHOOK_ID },
514
+ },
343
515
  ];
344
516
 
345
517
  // Turn tool args into a URL query string. Skips undefined/null/empty values.
@@ -366,3 +538,27 @@ export function buildPath(template, args) {
366
538
  for (const [k, v] of Object.entries(args || {})) if (!used.has(k)) rest[k] = v;
367
539
  return { path, rest };
368
540
  }
541
+
542
+ // Turn a POST tool's args into the JSON body the REST endpoint expects. A field
543
+ // named in `tool.filterSpecFields` nests under `filter_spec`; everything else
544
+ // stays top-level (id, cadence_s, active, url, kind, ...). Skips undefined so
545
+ // an omitted optional arg is genuinely absent from the body, never sent as
546
+ // `null` -- monitor-handlers.js tells "field not provided" apart from "field
547
+ // explicitly cleared" (e.g. updateMonitor only replaces filter_spec when at
548
+ // least one filter field is present at all).
549
+ export function buildBody(tool, args) {
550
+ const filterFields = new Set(tool.filterSpecFields || []);
551
+ const body = {};
552
+ let filterSpec = null;
553
+ for (const [k, v] of Object.entries(args || {})) {
554
+ if (v === undefined) continue;
555
+ if (filterFields.has(k)) {
556
+ filterSpec = filterSpec || {};
557
+ filterSpec[k] = v;
558
+ } else {
559
+ body[k] = v;
560
+ }
561
+ }
562
+ if (filterSpec) body.filter_spec = filterSpec;
563
+ return body;
564
+ }