From a5b90a2c05a511843ea45975a2fc955e1a802d7f Mon Sep 17 00:00:00 2001 From: Space-Banane Date: Sat, 20 Jun 2026 19:12:27 +0200 Subject: [PATCH] feat: add discussion tools for managing discussion channels and messages --- CLAUDE.md | 214 +++++++++++++++++++++++++++++++++++++++ src/index.ts | 2 + src/tools/comments.ts | 7 +- src/tools/discussions.ts | 184 +++++++++++++++++++++++++++++++++ 4 files changed, 404 insertions(+), 3 deletions(-) create mode 100644 CLAUDE.md create mode 100644 src/tools/discussions.ts diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..8d505db --- /dev/null +++ b/CLAUDE.md @@ -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/.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(`/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. diff --git a/src/index.ts b/src/index.ts index 83141a6..110abc8 100644 --- a/src/index.ts +++ b/src/index.ts @@ -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); diff --git a/src/tools/comments.ts b/src/tools/comments.ts index 8752f9b..9210ba1 100644 --- a/src/tools/comments.ts +++ b/src/tools/comments.ts @@ -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(`/api/news/${newsItemId}/comments`, { page, limit }); + handleTool(async ({ newsItemId, page, limit, sort }) => { + const data = await client.get(`/api/news/${newsItemId}/comments`, { page, limit, sort }); return JSON.stringify(data, null, 2); }), ); diff --git a/src/tools/discussions.ts b/src/tools/discussions.ts new file mode 100644 index 0000000..f90fd87 --- /dev/null +++ b/src/tools/discussions.ts @@ -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("/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("/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(`/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(`/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(`/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(`/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(`/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(`/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(`/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(`/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(`/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("/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(`/api/mod/discussions/${id}/review`, body); + return JSON.stringify(data, null, 2); + }), + ); +}