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 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,12 +99,13 @@ 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
 
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
- ## Reads only, no session needed
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
- 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.
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. This server is read-only. All 22 tools read Reddit; writes are a separate authenticated surface and are not exposed here.
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.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.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, 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.
@@ -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 Reddit pagination cursor from the previous response's `after` field (a fullname like `t3_abc123`). Omit on the first call; pass it to fetch the next page.",
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. All reads (GET). Path params are {name}/{id} placeholders.
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 — the same post shape as the listing endpoints. An unknown id is simply absent from the result. Example: fullnames='t3_abc123,t3_def456'.",
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
+ }