feat: add discussion tools for managing discussion channels and messages
CI / Build & Test (push) Successful in 15s

This commit is contained in:
Space-Banane
2026-06-20 19:12:27 +02:00
parent fa43346e51
commit a5b90a2c05
4 changed files with 404 additions and 3 deletions
+214
View File
@@ -0,0 +1,214 @@
# CLAUDE.md — BetterNews MCP Server
Stdio MCP server (`@modelcontextprotocol/sdk`) that exposes the BetterNews API as tools.
Auth: `BETTERNEWS_API_KEY` env var, sent as `Authorization: Bearer` on every request.
---
## Source Files
| File | Purpose |
| ---- | ------- |
| `src/index.ts` | Entry point. Imports all register functions and calls them against `server` and `client`. |
| `src/client.ts` | `BetterNewsClient``get/post/patch/delete` wrappers with auth header and error handling. |
| `src/utils.ts` | `handleTool(fn)` — wraps a handler, catches errors, returns `{ content: [{ type:"text", text }] }`. |
| `src/tools/news.ts` | 22 news tools |
| `src/tools/comments.ts` | 6 comment tools |
| `src/tools/sources.ts` | 7 source tools |
| `src/tools/user.ts` | 7 user/profile tools |
| `src/tools/moderation.ts` | 11 moderation tools |
| `src/tools/tickets.ts` | 5 support ticket tools |
| `src/tools/legal.ts` | 4 legal document tools |
| `src/tools/blog.ts` | 7 blog tools |
| `src/tools/api-keys.ts` | 4 API key tools |
| `src/tools/sessions.ts` | 3 session tools |
| `src/tools/discussions.ts` | 13 discussion channel tools |
---
## Complete Tool Index
Format: `tool_name` → file → `METHOD /api/route` → minimum auth
### News — `tools/news.ts`
| Tool | Route | Auth |
| ---- | ----- | ---- |
| `get_top_news` | `GET /api/news/top` | public |
| `list_news` | `GET /api/news` | optional |
| `get_news_item` | `GET /api/news/{id}` | optional |
| `search_news` | `GET /api/news/search` | optional |
| `get_similar_news` | `GET /api/news/{id}/similar` | optional |
| `list_topics` | `GET /api/news/topics` | public |
| `get_news_item_stats` | `GET /api/news/{id}/stats` | owner or Mod/Admin |
| `check_duplicates` | `GET /api/news/{id}/check-duplicates` | `news:moderate` |
| `create_news_item` | `POST /api/news` | `news:write` |
| `update_news_item` | `PATCH /api/news/{id}` | `news:write` |
| `delete_news_item` | `DELETE /api/news/{id}` | owner or Admin |
| `submit_news_item` | `POST /api/news/{id}/submit` | `news:write` (owner) |
| `review_news_item` | `POST /api/news/{id}/review` | `news:moderate` |
| `like_news_item` | `POST /api/news/{id}/like` | `news:write` |
| `dislike_news_item` | `POST /api/news/{id}/dislike` | `news:write` |
| `bookmark_news_item` | `POST /api/news/{id}/bookmark` | `bookmarks:write` |
| `publish_news_item` | `POST /api/news/{id}/publish` | `news:moderate` |
| `unpublish_news_item` | `POST /api/news/{id}/unpublish` | `news:moderate` |
| `republish_news_item` | `POST /api/news/{id}/republish` | `news:moderate` |
| `archive_news_item` | `POST /api/news/{id}/archive` | `news:moderate` (owner) |
| `direct_publish_news_item` | `POST /api/news/{id}/direct-publish` | `news:moderate` |
| `direct_create_publish_news_item` | `POST /api/news/direct` | `news:moderate` |
### Comments — `tools/comments.ts`
| Tool | Route | Auth |
| ---- | ----- | ---- |
| `list_comments` | `GET /api/news/{id}/comments` | optional; sort: top/most_disliked/new/oldest |
| `create_comment` | `POST /api/news/{id}/comments` | `news:comment` + email verified |
| `delete_comment` | `DELETE /api/news/{id}/comments/{commentId}` | owner or `news:comment` Mod/Admin |
| `like_comment` | `POST /api/news/{id}/comments/{commentId}/like` | `news:comment` |
| `dislike_comment` | `POST /api/news/{id}/comments/{commentId}/dislike` | `news:comment` |
| `hide_comment` | `PATCH /api/news/{id}/comments/{commentId}` | `news:moderate` Mod/Admin |
### Sources — `tools/sources.ts`
| Tool | Route | Auth |
| ---- | ----- | ---- |
| `list_sources` | `GET /api/sources` | `sources:read` |
| `get_source` | `GET /api/sources/{id}` | `sources:read` |
| `create_source` | `POST /api/sources` | `sources:write` Mod/Admin |
| `update_source` | `PATCH /api/sources/{id}` | `sources:write` Mod/Admin |
| `list_source_submissions` | `GET /api/news/{id}/sources` | `news:read` |
| `submit_source` | `POST /api/news/{id}/sources` | `news:write` |
| `review_source_submission` | `POST /api/news/{id}/sources/{submissionId}/review` | `news:moderate` |
### User — `tools/user.ts`
| Tool | Route | Auth |
| ---- | ----- | ---- |
| `get_current_user` | `GET /api/user/whoami` | `user:profile` |
| `get_user_profile` | `GET /api/users/{username}` | public |
| `list_my_news` | `GET /api/user/news` | `user:profile` |
| `list_my_source_submissions` | `GET /api/user/source-submissions` | `user:profile` |
| `list_bookmarks` | `GET /api/user/bookmarks` | `bookmarks:write` |
| `update_profile` | `PATCH /api/user/profile` | `user:profile` |
| `update_privacy_settings` | `PATCH /api/user/privacy` | `user:profile` |
### Moderation — `tools/moderation.ts`
| Tool | Route | Auth |
| ---- | ----- | ---- |
| `get_mod_queue` | `GET /api/mod/queue` | `mod:queue` Mod/Admin |
| `get_mod_stats` | `GET /api/mod/stats` | `mod:stats` Mod/Admin |
| `list_mod_users` | `GET /api/mod/users` | `mod:users` Admin |
| `get_mod_user` | `GET /api/mod/users/{userId}` | `mod:users` Admin |
| `change_user_role` | `PATCH /api/mod/users/{userId}/role` | `mod:users` Admin |
| `ban_user` | `POST /api/mod/users/{userId}/ban` | `mod:users` Admin |
| `unban_user` | `DELETE /api/mod/users/{userId}/ban` | `mod:users` Admin |
| `shadowban_user` | `PATCH /api/mod/users/{userId}/shadowban` | `mod:users` Admin |
| `create_mod_user` | `POST /api/mod/users` | `mod:users` Admin |
| `list_all_tickets` | `GET /api/mod/tickets` | `mod:queue` Mod/Admin |
| `list_mod_comments` | `GET /api/mod/comments` | `mod:queue` Mod/Admin |
### Tickets — `tools/tickets.ts`
| Tool | Route | Auth |
| ---- | ----- | ---- |
| `list_my_tickets` | `GET /api/tickets` | `tickets:read` |
| `get_ticket` | `GET /api/tickets/{id}` | `tickets:read` owner or Mod/Admin |
| `create_ticket` | `POST /api/tickets` | `tickets:write` |
| `reply_to_ticket` | `POST /api/tickets/{id}/reply` | `tickets:write` |
| `update_ticket_status` | `PATCH /api/tickets/{id}/status` | `tickets:moderate` Mod/Admin |
### Legal — `tools/legal.ts`
| Tool | Route | Auth |
| ---- | ----- | ---- |
| `get_terms_of_service` | `GET /api/legal/current/terms_of_service` | public |
| `get_privacy_policy` | `GET /api/legal/current/privacy_policy` | public |
| `list_legal_documents` | `GET /api/legal` | optional; Mod/Admin see drafts |
| `accept_legal_documents` | `POST /api/legal/accept` | User+ |
### Blog — `tools/blog.ts`
| Tool | Route | Auth |
| ---- | ----- | ---- |
| `list_blog_posts` | `GET /api/blog` | optional; Mod/Admin see drafts |
| `get_blog_post` | `GET /api/blog/{slug}` | optional; Mod/Admin see drafts |
| `create_blog_post` | `POST /api/blog` | `blog:write` Mod/Admin |
| `update_blog_post` | `PATCH /api/blog/{id}` | `blog:write` Mod/Admin |
| `delete_blog_post` | `DELETE /api/blog/{id}` | `blog:write` Mod/Admin |
| `publish_blog_post` | `POST /api/blog/{id}/publish` | `blog:write` Mod/Admin |
| `unpublish_blog_post` | `POST /api/blog/{id}/unpublish` | `blog:write` Mod/Admin |
### API Keys — `tools/api-keys.ts`
| Tool | Route | Auth |
| ---- | ----- | ---- |
| `list_api_keys` | `GET /api/user/api-keys` | `user:apikeys` |
| `create_api_key` | `POST /api/user/api-keys` | cookie session only |
| `update_api_key` | `PATCH /api/user/api-keys/{keyId}` | `user:apikeys` |
| `revoke_api_key` | `DELETE /api/user/api-keys/{keyId}` | `user:apikeys` |
### Sessions — `tools/sessions.ts`
| Tool | Route | Auth |
| ---- | ----- | ---- |
| `list_sessions` | `GET /api/user/sessions` | cookie session only |
| `revoke_all_sessions` | `DELETE /api/user/sessions` | cookie session only |
| `revoke_session` | `DELETE /api/user/sessions/{sessionId}` | cookie session only |
### Discussions — `tools/discussions.ts`
| Tool | Route | Auth |
| ---- | ----- | ---- |
| `list_discussions` | `GET /api/discussions` | optional; Mod/Admin see all statuses |
| `propose_discussion` | `POST /api/discussions` | User+ (3/hour rate limit) |
| `get_discussion` | `GET /api/discussions/{id}` | optional; Mod/Admin see pending/rejected |
| `update_discussion` | `PATCH /api/discussions/{id}` | Mod/Admin |
| `delete_discussion` | `DELETE /api/discussions/{id}` | Admin |
| `list_discussion_messages` | `GET /api/discussions/{id}/messages` | optional; cursor via `before` ISO timestamp |
| `delete_discussion_message` | `DELETE /api/discussions/{id}/messages/{messageId}` | owner or Mod/Admin |
| `pin_discussion_message` | `POST /api/discussions/{id}/messages/{messageId}/pin` | Mod/Admin |
| `list_discussion_bans` | `GET /api/discussions/{id}/bans` | Mod/Admin |
| `ban_discussion_user` | `POST /api/discussions/{id}/bans` | Mod/Admin |
| `unban_discussion_user` | `DELETE /api/discussions/{id}/bans/{userId}` | Mod/Admin |
| `list_mod_discussions` | `GET /api/mod/discussions` | Mod/Admin |
| `review_discussion` | `POST /api/mod/discussions/{id}/review` | Mod/Admin |
---
## Adding a New Tool
1. Find or create the right `src/tools/<category>.ts` file.
2. Add a `server.tool(name, description, zodSchema, handleTool(async (args) => { ... }))` call inside the register function.
3. If creating a new file, export `function registerXxxTools(server, client)` and call it in `src/index.ts`.
4. Run `pnpm build` — a clean exit means no type errors.
Pattern (copy from any existing tool):
```ts
server.tool(
"tool_name",
"One-line description. Mention required scope.",
{
id: z.string().uuid().describe("..."),
},
handleTool(async ({ id }) => {
const data = await client.get<unknown>(`/api/route/${id}`);
return JSON.stringify(data, null, 2);
}),
);
```
Query params: pass as second arg to `client.get(url, params)` — undefined values are stripped.
Body params: pass as second arg to `client.post/patch(url, body)`.
---
## Build & Validate
```sh
pnpm build # tsc — must exit 0 before committing
pnpm test # vitest unit tests for client + utils
```
No pre-push script; just `pnpm build` is the gate.
+2
View File
@@ -12,6 +12,7 @@ import { registerLegalTools } from "./tools/legal.js";
import { registerBlogTools } from "./tools/blog.js";
import { registerApiKeyTools } from "./tools/api-keys.js";
import { registerSessionTools } from "./tools/sessions.js";
import { registerDiscussionTools } from "./tools/discussions.js";
const apiKey = process.env.BETTERNEWS_API_KEY;
if (!apiKey) {
@@ -40,6 +41,7 @@ registerLegalTools(server, client);
registerBlogTools(server, client);
registerApiKeyTools(server, client);
registerSessionTools(server, client);
registerDiscussionTools(server, client);
const transport = new StdioServerTransport();
await server.connect(transport);
+4 -3
View File
@@ -10,10 +10,11 @@ export function registerCommentTools(server: McpServer, client: BetterNewsClient
{
newsItemId: z.string().uuid().describe("News item UUID"),
page: z.number().int().positive().optional().describe("Page number (default 1)"),
limit: z.number().int().min(1).max(100).optional().describe("Items per page (default 20)"),
limit: z.number().int().min(1).max(50).optional().describe("Items per page (default 20, max 50)"),
sort: z.enum(["top", "most_disliked", "new", "oldest"]).optional().describe("Sort order: top = most liked (default), most_disliked, new = newest first, oldest"),
},
handleTool(async ({ newsItemId, page, limit }) => {
const data = await client.get<unknown>(`/api/news/${newsItemId}/comments`, { page, limit });
handleTool(async ({ newsItemId, page, limit, sort }) => {
const data = await client.get<unknown>(`/api/news/${newsItemId}/comments`, { page, limit, sort });
return JSON.stringify(data, null, 2);
}),
);
+184
View File
@@ -0,0 +1,184 @@
import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { z } from "zod";
import type { BetterNewsClient } from "../client.js";
import { handleTool } from "../utils.js";
export function registerDiscussionTools(server: McpServer, client: BetterNewsClient) {
server.tool(
"list_discussions",
"List approved discussion channels. Mod/Admin also see pending/rejected/closed topics. No auth required.",
{
page: z.number().int().positive().optional().describe("Page number (default 1)"),
limit: z.number().int().min(1).max(50).optional().describe("Items per page (default 20, max 50)"),
},
handleTool(async (args) => {
const data = await client.get<unknown>("/api/discussions", args);
return JSON.stringify(data, null, 2);
}),
);
server.tool(
"propose_discussion",
"Submit a new discussion topic for AI review and moderator approval. Requires authentication. Rate limited to 3 proposals per hour.",
{
title: z.string().min(5).max(120).describe("Topic title"),
description: z.string().min(10).max(1000).describe("What this discussion channel is about"),
imageUrl: z.string().url().max(2048).nullable().optional().describe("Optional cover image URL"),
},
handleTool(async (body) => {
const data = await client.post<unknown>("/api/discussions", body);
return JSON.stringify(data, null, 2);
}),
);
server.tool(
"get_discussion",
"Get a single discussion topic by UUID. Non-mod users can only see approved/closed topics.",
{
id: z.string().uuid().describe("Discussion topic UUID"),
},
handleTool(async ({ id }) => {
const data = await client.get<unknown>(`/api/discussions/${id}`);
return JSON.stringify(data, null, 2);
}),
);
server.tool(
"update_discussion",
"Update a discussion topic's metadata or status. Requires Mod/Admin role.",
{
id: z.string().uuid().describe("Discussion topic UUID"),
title: z.string().min(5).max(120).optional().describe("New title"),
description: z.string().min(10).max(1000).optional().describe("New description"),
imageUrl: z.string().url().max(2048).nullable().optional().describe("Cover image URL (null to remove)"),
status: z.enum(["approved", "rejected", "closed"]).optional().describe("New status"),
slowdownMs: z.number().int().min(0).max(300000).optional().describe("Slowdown in ms between messages (0 = disabled)"),
aiReviewNote: z.string().max(500).optional().describe("Override the AI review note"),
},
handleTool(async ({ id, ...body }) => {
const data = await client.patch<unknown>(`/api/discussions/${id}`, body);
return JSON.stringify(data, null, 2);
}),
);
server.tool(
"delete_discussion",
"Permanently delete a discussion topic and all its messages. Requires Admin role.",
{
id: z.string().uuid().describe("Discussion topic UUID"),
},
handleTool(async ({ id }) => {
const data = await client.delete<unknown>(`/api/discussions/${id}`);
return JSON.stringify(data, null, 2);
}),
);
server.tool(
"list_discussion_messages",
"Get message history in a discussion channel. Supports cursor-based pagination via 'before'. Mod/Admin also see soft-deleted messages.",
{
id: z.string().uuid().describe("Discussion topic UUID"),
before: z.string().datetime().optional().describe("Cursor: load messages before this ISO timestamp"),
limit: z.number().int().min(1).max(100).optional().describe("Max messages to return (default 50, max 100)"),
},
handleTool(async ({ id, ...params }) => {
const data = await client.get<unknown>(`/api/discussions/${id}/messages`, params);
return JSON.stringify(data, null, 2);
}),
);
server.tool(
"delete_discussion_message",
"Soft-delete a message in a discussion channel. Owners can delete their own; Mod/Admin can delete any.",
{
topicId: z.string().uuid().describe("Discussion topic UUID"),
messageId: z.string().uuid().describe("Message UUID"),
},
handleTool(async ({ topicId, messageId }) => {
const data = await client.delete<unknown>(`/api/discussions/${topicId}/messages/${messageId}`);
return JSON.stringify(data, null, 2);
}),
);
server.tool(
"pin_discussion_message",
"Pin or unpin a message in a discussion channel. Requires Mod/Admin role.",
{
topicId: z.string().uuid().describe("Discussion topic UUID"),
messageId: z.string().uuid().describe("Message UUID"),
pinned: z.boolean().describe("True to pin, false to unpin"),
},
handleTool(async ({ topicId, messageId, pinned }) => {
const data = await client.post<unknown>(`/api/discussions/${topicId}/messages/${messageId}/pin`, { pinned });
return JSON.stringify(data, null, 2);
}),
);
server.tool(
"list_discussion_bans",
"List all user bans in a discussion channel. Requires Mod/Admin role.",
{
id: z.string().uuid().describe("Discussion topic UUID"),
},
handleTool(async ({ id }) => {
const data = await client.get<unknown>(`/api/discussions/${id}/bans`);
return JSON.stringify(data, null, 2);
}),
);
server.tool(
"ban_discussion_user",
"Ban a user from a discussion channel. Requires Mod/Admin role.",
{
topicId: z.string().uuid().describe("Discussion topic UUID"),
userId: z.string().uuid().describe("UUID of the user to ban"),
reason: z.string().min(1).max(500).describe("Reason for the ban"),
expiresAt: z.string().datetime().nullish().describe("Optional ban expiry (ISO 8601). Omit for permanent."),
},
handleTool(async ({ topicId, ...body }) => {
const data = await client.post<unknown>(`/api/discussions/${topicId}/bans`, body);
return JSON.stringify(data, null, 2);
}),
);
server.tool(
"unban_discussion_user",
"Remove a user's ban from a discussion channel. Requires Mod/Admin role.",
{
topicId: z.string().uuid().describe("Discussion topic UUID"),
userId: z.string().uuid().describe("UUID of the user to unban"),
},
handleTool(async ({ topicId, userId }) => {
const data = await client.delete<unknown>(`/api/discussions/${topicId}/bans/${userId}`);
return JSON.stringify(data, null, 2);
}),
);
server.tool(
"list_mod_discussions",
"List discussion topic submissions with moderation detail (AI review result, message/ban counts). Requires Mod/Admin role.",
{
status: z.enum(["pending", "approved", "rejected", "closed"]).optional().describe("Filter by status"),
page: z.number().int().positive().optional().describe("Page number (default 1)"),
limit: z.number().int().min(1).max(50).optional().describe("Items per page (default 20, max 50)"),
},
handleTool(async (args) => {
const data = await client.get<unknown>("/api/mod/discussions", args);
return JSON.stringify(data, null, 2);
}),
);
server.tool(
"review_discussion",
"Approve or reject a pending discussion topic submission. Requires Mod/Admin role.",
{
id: z.string().uuid().describe("Discussion topic UUID"),
action: z.enum(["approve", "reject", "reject_with_ai"]).describe("'approve', 'reject' with an optional note, or 'reject_with_ai' to use the stored AI review note as the rejection reason"),
note: z.string().max(500).optional().describe("Optional moderator note (used as rejection reason when action is 'reject')"),
},
handleTool(async ({ id, ...body }) => {
const data = await client.post<unknown>(`/api/mod/discussions/${id}/review`, body);
return JSON.stringify(data, null, 2);
}),
);
}