Skip to content

Commit 7e349d1

Browse files
committed
feat(tools): add moderation and topic views
Add an opt-in moderation toolset for authenticated review queue triage and safely preflighted reviewable actions. Expand topic discovery with rich metadata and authoritative top and hot views while keeping search available by default.
1 parent 60084a0 commit 7e349d1

19 files changed

Lines changed: 1130 additions & 72 deletions

CHANGELOG.md

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,18 @@
44

55
### Features
66

7+
* Enrich topic discovery and add authoritative top/hot views to `discourse_filter_topics`
8+
- Preserve existing filtered calls while adding top periods and defining hot exactly as Discourse's daily top score
9+
- Return uniform rich topic metadata with null-safe fields and pagination totals/continuation only when authoritative
10+
- Keep `search` default-on and both search tools available in read-only mode
11+
* Add the opt-in `moderation` toolset for Discourse's review queue
12+
- Inspect queue count, reviewables, high-priority reviewable-topic aggregation, full bounded context, score explanations, and dynamic available actions
13+
- Distinguish pending reviewable totals from individual score/flag records and mark the topic aggregation as non-exhaustive
14+
- Support staff and category moderators through authenticated reads while leaving Guardian permissions authoritative
15+
- Add one write-gated action tool with fresh-action preflight, optimistic-version checks, explicit confirmation, contracted fields, and structured moderation errors
16+
- Mark reads and destructive actions accurately in MCP metadata, make strict-schema numeric placeholders safe, and normalize statuses/count units
17+
- Serialize concurrent moderation mutations, pace high-volume reads, route prefixed UI action IDs through their authoritative `server_action`, and report ambiguous post-PUT failures without encouraging blind retries
18+
719
* Use Discourse's device authorization flow when generating User API Keys
820
- Show a short browser activation code and poll for approval automatically
921
- Use RSA-OAEP encryption and validate the response nonce

README.md

Lines changed: 21 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -95,7 +95,7 @@ The server registers tools under the MCP server name `@discourse/mcp`. Choose a
9595
- `silent`: No logging output
9696
- `--show_emails` (default: false). includes emails in user tools. Requires admin access
9797
- `--tools_mode <auto|discourse_api_only|tool_exec_api>` (default: auto)
98-
- `--toolsets <name[,name...]>`: Expose selected built-in domains. Omit for the default catalog (all non-opt-in domains); use `--toolsets all` to include opt-in workflows and AI administration domains. See [Built-in toolsets](#built-in-toolsets).
98+
- `--toolsets <name[,name...]>`: Expose selected built-in domains. Omit for the default catalog (all non-opt-in domains); use `--toolsets all` to include opt-in moderation, workflows, and AI administration domains. See [Built-in toolsets](#built-in-toolsets).
9999
- `--site <url>`: Tether MCP to a single site and hide `discourse_select_site`.
100100
- `--default-search <prefix>`: Unconditionally prefix every search query (e.g., `tag:ai order:latest`).
101101
- `--max-read-length <number>`: Maximum characters returned for post content (default 50000). Applies to `discourse_read_post` and per-post content in `discourse_read_topic` and `discourse_read_private_message`. The tools prefer `raw` content by requesting `include_raw=true`.
@@ -152,7 +152,7 @@ Flags still override values from the profile.
152152

153153
### Built-in toolsets
154154

155-
Toolsets let an operator expose only the built-in domains needed by an MCP client. They are optional: when `--toolsets` and the profile field are both omitted, the server registers the default catalog (all non-opt-in domains). The administrative `groups`, `workflows`, `ai_agents`, `ai_custom_tools`, and `ai_features` domains are opt-in. Use `--toolsets all` to explicitly load every domain.
155+
Toolsets let an operator expose only the built-in domains needed by an MCP client. They are optional: when `--toolsets` and the profile field are both omitted, the server registers the default catalog (including `search`, `discourse_search`, and `discourse_filter_topics`). The `groups`, `moderation`, `workflows`, `ai_agents`, `ai_custom_tools`, and `ai_features` domains are opt-in. Use `--toolsets all` to explicitly load every domain.
156156

157157
Pass one name or a comma-separated union:
158158

@@ -203,6 +203,7 @@ Available toolsets are:
203203
| `data_explorer` | Query retrieval, execution, creation, update, and deletion |
204204
| `private_messages` | Authenticated personal/group PM listing and reading, plus write-gated creation, replies, and participant invitations |
205205
| `groups` *(opt-in)* | Complete group CRUD; member/owner listing; explicit username, user-ID, or account-email membership and ownership mutations; separate email invitations; membership-request decisions; self-service requests, joins, and leaves |
206+
| `moderation` *(opt-in)* | Authenticated review queue count/list/topic/detail triage, plus one write-gated, freshly preflighted dynamic reviewable action tool |
206207
| `workflows` *(opt-in)* | Admin-only workflow discovery, graph authoring, expression evaluation, pin-data, draft runs, step runs, executions, and version management |
207208
| `ai_agents` *(opt-in)* | Admin-only AI agent discovery, typed lifecycle, bot-user creation, and portable import/export |
208209
| `ai_custom_tools` *(opt-in)* | Admin-only database-backed scripted custom-tool guide, lifecycle, actual execution testing, and import/export |
@@ -226,6 +227,14 @@ The opt-in `groups` toolset covers the complete custom-group lifecycle: director
226227

227228
All mutations require both `--allow_writes` and `--read_only=false`. Creation and deletion additionally require staff/admin-style API credentials at the MCP access gate. Discourse remains authoritative for Guardian checks, group visibility, staff versus owner capabilities, automatic-group restrictions, membership settings, invitation limits, and the fields a caller may update. Core automatic groups cannot be created, deleted, or have membership/ownership changed; their permitted presentation and interaction settings can still be updated by authorized staff. Selecting the toolset does not grant any of these permissions.
228229

230+
#### Moderation queue
231+
232+
The opt-in `moderation` toolset exposes `discourse_get_review_queue_count`, `discourse_list_reviewables`, `discourse_list_reviewable_topics`, and `discourse_get_reviewable` in read-only mode. These tools require configured authentication, but intentionally do not impose an MCP admin-only gate: Discourse Guardian remains authoritative for staff and category-moderator visibility. Selecting the toolset grants no moderation permission.
233+
234+
For queue totals, use `discourse_get_review_queue_count`; its `count` is the number of pending **reviewable records** visible to the caller, not the number of individual flags. Use `discourse_list_reviewables` with only `status: "pending"` and `offset: 0` for ordinary triage—do not invent topic, category, type, or user filters—and follow `next_offset` until `has_more` is false. Numeric topic/category placeholders of `0` and optional text placeholders of blank/`all`/`any` are treated as omitted, so strict-schema clients cannot accidentally filter to ID 1 or send invalid universal sentinels. List results already contain bounded evidence and dynamic actions; avoid fanning out `discourse_read_topic` or detail calls across the queue. `discourse_list_reviewable_topics` is only a convenience aggregation: upstream includes pending topics at or above its minimum review-priority threshold, omits queue items without topics, and reports `score_count` as the number of review score/flag records—not reviewable items. It must not be used to infer the complete queue size.
235+
236+
When both `--allow_writes` and `--read_only=false` are set, `discourse_perform_reviewable_action` is also registered. Call list/detail first and submit one exact `available_actions[].id` with `confirm: true`; choose from the full action description, not a repeated label such as “Delete post.” Discourse UI action IDs can be prefixed (`post-…` or `user-…`), while the route requires the associated `server_action`; the MCP validates and maps this automatically. Moderation mutations are serialized and paced across the complete fresh-GET/PUT operation, so a concurrent model batch cannot bypass the write throttle. The tool checks an optional expected version, rejects unadvertised fields, and returns normalized success/count fields. A failure after the PUT is marked as an unknown outcome with identifiers and must be verified rather than blindly retried. Discourse still enforces claims, optimistic conflicts, action validity, and Guardian permissions. The tools expose evidence and explicit operations; they do not recommend moderation decisions.
237+
229238
#### Private messages
230239

231240
The default `private_messages` toolset provides a PM-aware interface rather than reusing generic public-topic mutations. Listing and reading require configured authentication. Creation, replies, and invitations additionally require both `--allow_writes` and `--read_only=false`. Discourse remains authoritative for mailbox visibility, PM membership, recipient limits, group messageability, and all Guardian/API-key checks.
@@ -354,9 +363,16 @@ Built‑in tools (always present unless noted). All tools return **strict JSON**
354363
- Input: `{ username: string; page?: number (0-based); limit?: number (1–50, default 30) }`
355364
- Output: `{ posts: [{id, topic_id, post_number, slug, title, created_at, excerpt, category_id}], meta: {page, limit, has_more} }`
356365
- `discourse_filter_topics`
357-
- Input: `{ filter: string; page?: number; per_page?: number (1–50) }`
358-
- Output: `{ results: [{id, slug, title}], meta: {page, limit, has_more} }`
359-
- Query language (succinct): key:value tokens separated by spaces; category/categories (comma = OR, `=category` = without subcats, `-` prefix = exclude); tag/tags (comma = OR, `+` = AND) and tag_group; status:(open|closed|archived|listed|unlisted|public); personal `in:` (bookmarked|watching|tracking|muted|pinned); dates: created/activity/latest-post-(before|after) with `YYYY-MM-DD` or relative days `N`; numeric: likes[-op]-(min|max), posts-(min|max), posters-(min|max), views-(min|max); order: activity|created|latest-post|likes|likes-op|posters|title|views|category with optional `-asc`; free text terms are matched.
366+
- Input: `{ filter?: string; view?: "filtered"|"top"|"hot" (default "filtered"); top_period?: "daily"|"weekly"|"monthly"|"quarterly"|"yearly"|"all"; page?: number (0-based); per_page?: number (1–50) }`
367+
- Filtered requires a nonblank `filter` and uses `/filter.json`. Top rejects `filter`, uses `/top.json`, and defaults to weekly. Hot rejects `filter`/`top_period` and is defined exactly as Discourse's **daily top score**—not semantic controversy, toxicity, or real-time velocity.
368+
- Output: `{ results: [{id, slug, title, category_id, tags, created_at, last_posted_at, bumped_at, posts_count, reply_count, views, like_count, posters_count, closed, archived, pinned, visible, last_poster_username, posters}], meta: {view, top_period, page, per_page, returned, has_more, total?} }`. Missing optional values remain `null`; `total` and continuation are never fabricated.
369+
- Filter query language (succinct): key:value tokens separated by spaces; category/categories (comma = OR, `=category` = without subcats, `-` prefix = exclude); tag/tags (comma = OR, `+` = AND) and tag_group; status:(open|closed|archived|listed|unlisted|public); personal `in:` (bookmarked|watching|tracking|muted|pinned); dates: created/activity/latest-post-(before|after) with `YYYY-MM-DD` or relative days `N`; numeric: likes[-op]-(min|max), posts-(min|max), posters-(min|max), views-(min|max); order: activity|created|latest-post|likes|likes-op|posters|title|views|category with optional `-asc`; free text terms are matched.
370+
- Moderation tools *(only with `--toolsets moderation`; all require authentication)*
371+
- `discourse_get_review_queue_count`: `{}``{ count, unit: "pending_reviewable_queue_items", status: "pending", scope }`, where `count` is the authoritative number of pending reviewable records visible to the caller, not individual flags.
372+
- `discourse_list_reviewables`: stable review filters and offset pagination → normalized reviewables with named status plus numeric `status_id`, current versions, bounded evidence, scores, targets, and dynamic actions. For normal triage send only `status` and `offset`; use `meta.total` and follow `next_offset`. Upstream page size is fixed at 10.
373+
- `discourse_list_reviewable_topics`: `{}` → a non-exhaustive aggregation of pending topics at or above Discourse's minimum review priority. `score_count` counts review score/flag records, not reviewable queue items; queue items without topics are absent.
374+
- `discourse_get_reviewable`: `{ reviewable_id; include_explanation? }` → refreshed bounded context, side-loaded references, editable fields, score evidence, and exact available actions; no recommendation is generated. Avoid bulk fan-out because list results already contain triage evidence.
375+
- `discourse_perform_reviewable_action` *(only when writes enabled)*: `{ reviewable_id; action_id; expected_version?; additional_fields?; confirm: true }` → serialized, freshly preflighted action with normalized success and remaining-count fields. Submit the displayed dynamic action ID; MCP maps its `server_action` to the Discourse route.
360376
- `discourse_get_chat_messages`
361377
- Input: `{ channel_id: number; page_size?: number (1–50, default 50); target_message_id?: number; direction?: "past" | "future"; target_date?: string (ISO 8601) }`
362378
- Output: `{ channel_id, messages: [{id, username, created_at, message, edited, thread_id, in_reply_to_id}], meta }`

src/test/filter_topics.test.ts

Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,113 @@
1+
import test from "node:test";
2+
import assert from "node:assert/strict";
3+
import { Logger } from "../util/logger.js";
4+
import { SiteState } from "../site/state.js";
5+
import { filterTopicsTool, projectTopic } from "../tools/builtin/filter_topics.js";
6+
import type { ToolContext, ToolRegistrationOptions } from "../tools/types.js";
7+
8+
function setup() {
9+
const logger = new Logger("silent");
10+
const siteState = new SiteState({ logger, timeoutMs: 5000, defaultAuth: { type: "none" } });
11+
siteState.selectSite("https://example.com");
12+
const ctx: ToolContext = { server: {} as any, siteState, logger, maxReadLength: 50000 };
13+
const opts: ToolRegistrationOptions = { allowWrites: false, toolsMode: "discourse_api_only" };
14+
return { ctx, opts };
15+
}
16+
17+
async function invoke(input: Record<string, unknown>) {
18+
const { ctx, opts } = setup();
19+
return filterTopicsTool.handler(input as any, {} as any, ctx, opts);
20+
}
21+
22+
function body(result: Awaited<ReturnType<typeof invoke>>): any {
23+
return JSON.parse(String(result.content[0]?.text ?? "{}"));
24+
}
25+
26+
function mockFetch(payload: unknown) {
27+
const original = globalThis.fetch;
28+
const requests: Array<{ url: string; method: string }> = [];
29+
globalThis.fetch = async (input, init) => {
30+
requests.push({ url: String(input), method: init?.method ?? "GET" });
31+
return Response.json(payload);
32+
};
33+
return { requests, restore: () => { globalThis.fetch = original; } };
34+
}
35+
36+
test("topic discovery preserves filtered calls and routes top and hot exactly", async () => {
37+
const mock = mockFetch({ topic_list: { topics: [] } });
38+
try {
39+
await invoke({ filter: "category:support status:open", page: 2, per_page: 15 });
40+
await invoke({ view: "top", top_period: "monthly", page: 1, per_page: 25 });
41+
await invoke({ view: "top" });
42+
const hot = body(await invoke({ view: "hot" }));
43+
44+
assert.deepEqual(mock.requests.map((r) => r.url), [
45+
"https://example.com/filter.json?q=category%3Asupport+status%3Aopen&page=2&per_page=15",
46+
"https://example.com/top.json?period=monthly&page=1&per_page=25",
47+
"https://example.com/top.json?period=weekly&page=0&per_page=20",
48+
"https://example.com/top.json?period=daily&page=0&per_page=20",
49+
]);
50+
assert.equal(hot.meta.view, "hot");
51+
assert.equal(hot.meta.top_period, "daily");
52+
} finally {
53+
mock.restore();
54+
}
55+
});
56+
57+
test("invalid topic discovery combinations fail before HTTP", async () => {
58+
const mock = mockFetch({});
59+
try {
60+
for (const input of [
61+
{},
62+
{ filter: " " },
63+
{ view: "top", filter: "tag:x" },
64+
{ view: "hot", top_period: "daily" },
65+
{ filter: "tag:x", top_period: "weekly" },
66+
]) {
67+
const result = await invoke(input);
68+
assert.equal(result.isError, true);
69+
}
70+
assert.equal(mock.requests.length, 0);
71+
} finally {
72+
mock.restore();
73+
}
74+
});
75+
76+
test("topic discovery returns a uniform rich sparse-safe projection and truthful pagination", async () => {
77+
const rich = {
78+
id: 123, slug: "topic-slug", title: "Topic title", category_id: 4, tags: ["support"],
79+
created_at: "2026-08-01T10:00:00Z", last_posted_at: "2026-08-17T12:00:00Z", bumped_at: "2026-08-17T12:00:00Z",
80+
posts_count: 38, reply_count: 37, views: 2100, like_count: 45, posters_count: 14,
81+
closed: false, archived: false, pinned: false, visible: true,
82+
posters: [{ user_id: 8, username: "alice", description: "Most Recent Poster", plugin_value: "ignored" }],
83+
solved: true,
84+
};
85+
const mock = mockFetch({ topic_list: { topics: [rich, { id: 2 }], more_topics_url: "/latest?before=2", total_count: 9 } });
86+
try {
87+
const output = body(await invoke({ filter: "status:open", per_page: 1 }));
88+
assert.deepEqual(output.results, [projectTopic(rich)]);
89+
assert.deepEqual(output.results[0].posters[0], { user_id: 8, username: "alice", description: "Most Recent Poster" });
90+
assert.equal(output.results[0].last_poster_username, "alice");
91+
assert.equal("solved" in output.results[0], false);
92+
assert.deepEqual(output.meta, {
93+
view: "filtered", top_period: null, page: 0, per_page: 1, returned: 1, has_more: true, total: 9,
94+
});
95+
const sparse = projectTopic({ id: 2 });
96+
assert.equal(sparse.slug, null);
97+
assert.equal(sparse.posts_count, null);
98+
assert.equal(sparse.posters, null);
99+
} finally {
100+
mock.restore();
101+
}
102+
});
103+
104+
test("topic discovery never treats a full page as proof of continuation", async () => {
105+
const mock = mockFetch({ topic_list: { topics: [{ id: 1 }] } });
106+
try {
107+
const output = body(await invoke({ filter: "status:open", per_page: 1 }));
108+
assert.equal(output.meta.has_more, false);
109+
assert.equal("total" in output.meta, false);
110+
} finally {
111+
mock.restore();
112+
}
113+
});

0 commit comments

Comments
 (0)