feat: add polls, deep-dive, ticket messages, and mod API key tools
CI / Build & Test (push) Successful in 17s

- New polls.ts: 13 tools covering full poll lifecycle (create/list/get/
  update/delete/vote/like/dislike/review) and poll comments
- news.ts: get_deep_dive and regenerate_deep_dive for AI analysis
- tickets.ts: create_ticket_message with internal-note and attachment support
- moderation.ts: list/update/revoke mod-managed user API keys
- user.ts: list_my_polls and list_my_poll_votes
- Bump server version to 1.2.0

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Space-Banane
2026-07-01 22:31:48 +02:00
parent 8083b0b8a8
commit bc42508849
7 changed files with 348 additions and 5 deletions
+31 -4
View File
@@ -12,16 +12,17 @@ Auth: `BETTERNEWS_API_KEY` env var, sent as `Authorization: Bearer` on every req
| `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/news.ts` | 24 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/user.ts` | 9 user/profile tools |
| `src/tools/moderation.ts` | 14 moderation tools |
| `src/tools/tickets.ts` | 6 support ticket 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 |
| `src/tools/polls.ts` | 13 poll tools |
---
@@ -55,6 +56,8 @@ Format: `tool_name` → file → `METHOD /api/route` → minimum auth
| `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` |
| `get_deep_dive` | `GET /api/news/{id}/deep-dive` | optional |
| `regenerate_deep_dive` | `POST /api/news/{id}/deep-dive/regenerate` | `news:moderate` Mod/Admin |
### Comments — `tools/comments.ts`
@@ -90,6 +93,8 @@ Format: `tool_name` → file → `METHOD /api/route` → minimum auth
| `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` |
| `list_my_polls` | `GET /api/user/polls` | `user:profile` |
| `list_my_poll_votes` | `GET /api/user/poll-votes` | `user:profile` |
### Moderation — `tools/moderation.ts`
@@ -106,6 +111,9 @@ Format: `tool_name` → file → `METHOD /api/route` → minimum auth
| `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 |
| `list_mod_user_api_keys` | `GET /api/mod/users/{userId}/api-keys` | `mod:users` Admin |
| `update_mod_user_api_key` | `PATCH /api/mod/users/{userId}/api-keys/{keyId}` | `mod:users` Admin |
| `revoke_mod_user_api_key` | `DELETE /api/mod/users/{userId}/api-keys/{keyId}` | `mod:users` Admin |
### Tickets — `tools/tickets.ts`
@@ -115,6 +123,7 @@ Format: `tool_name` → file → `METHOD /api/route` → minimum auth
| `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` |
| `create_ticket_message` | `POST /api/tickets/{id}/messages` | `tickets:write` / `tickets:moderate` |
| `update_ticket_status` | `PATCH /api/tickets/{id}/status` | `tickets:moderate` Mod/Admin |
### Blog — `tools/blog.ts`
@@ -164,6 +173,24 @@ Format: `tool_name` → file → `METHOD /api/route` → minimum auth
| `list_mod_discussions` | `GET /api/mod/discussions` | Mod/Admin |
| `review_discussion` | `POST /api/mod/discussions/{id}/review` | Mod/Admin |
### Polls — `tools/polls.ts`
| Tool | Route | Auth |
| ---- | ----- | ---- |
| `list_polls` | `GET /api/polls` | optional; non-published statuses require Mod/Admin |
| `create_poll` | `POST /api/polls` | `polls:write` + email verified |
| `get_poll` | `GET /api/polls/{id}` | optional; non-published only for author or Mod/Admin |
| `update_poll` | `PATCH /api/polls/{id}` | `polls:write` (owner) or `polls:moderate` (Mod/Admin) |
| `delete_poll` | `DELETE /api/polls/{id}` | `polls:write` (owner pending_review only) or Mod/Admin |
| `vote_poll` | `POST /api/polls/{id}/vote` | `polls:write` |
| `like_poll` | `POST /api/polls/{id}/like` | `polls:write` |
| `dislike_poll` | `POST /api/polls/{id}/dislike` | `polls:write` |
| `review_poll` | `POST /api/polls/{id}/review` | `polls:moderate` Mod/Admin |
| `list_poll_comments` | `GET /api/polls/{id}/comments` | optional |
| `create_poll_comment` | `POST /api/polls/{id}/comments` | `polls:comment` + email verified |
| `hide_poll_comment` | `PATCH /api/polls/{id}/comments/{commentId}` | `polls:moderate` Mod/Admin |
| `delete_poll_comment` | `DELETE /api/polls/{id}/comments/{commentId}` | owner or `polls:comment` Mod/Admin |
---
## Adding a New Tool
+3 -1
View File
@@ -12,6 +12,7 @@ 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";
import { registerPollTools } from "./tools/polls.js";
const apiKey = process.env.BETTERNEWS_API_KEY;
if (!apiKey) {
@@ -27,7 +28,7 @@ const client = new BetterNewsClient(apiKey, baseUrl);
const server = new McpServer({
name: "betternews",
version: "1.1.0",
version: "1.2.0",
});
registerNewsTools(server, client);
@@ -40,6 +41,7 @@ registerBlogTools(server, client);
registerApiKeyTools(server, client);
registerSessionTools(server, client);
registerDiscussionTools(server, client);
registerPollTools(server, client);
const transport = new StdioServerTransport();
await server.connect(transport);
+39
View File
@@ -155,4 +155,43 @@ export function registerModerationTools(server: McpServer, client: BetterNewsCli
return JSON.stringify(data, null, 2);
}),
);
server.tool(
"list_mod_user_api_keys",
"List all API keys belonging to a specific user. Requires Admin role and mod:users scope.",
{
userId: z.string().uuid().describe("Target user UUID"),
},
handleTool(async ({ userId }) => {
const data = await client.get<unknown>(`/api/mod/users/${userId}/api-keys`);
return JSON.stringify(data, null, 2);
}),
);
server.tool(
"update_mod_user_api_key",
"Toggle the rate-limit exemption on a user's API key. Requires Admin role and mod:users scope.",
{
userId: z.string().uuid().describe("Target user UUID"),
keyId: z.string().uuid().describe("API key UUID"),
rateLimitExempt: z.boolean().describe("True to grant unlimited rate-limit; false to restore limits"),
},
handleTool(async ({ userId, keyId, rateLimitExempt }) => {
const data = await client.patch<unknown>(`/api/mod/users/${userId}/api-keys/${keyId}`, { rateLimitExempt });
return JSON.stringify(data, null, 2);
}),
);
server.tool(
"revoke_mod_user_api_key",
"Revoke a specific API key belonging to a user. Requires Admin role and mod:users scope.",
{
userId: z.string().uuid().describe("Target user UUID"),
keyId: z.string().uuid().describe("API key UUID to revoke"),
},
handleTool(async ({ userId, keyId }) => {
const data = await client.delete<unknown>(`/api/mod/users/${userId}/api-keys/${keyId}`);
return JSON.stringify(data, null, 2);
}),
);
}
+24
View File
@@ -318,4 +318,28 @@ export function registerNewsTools(server: McpServer, client: BetterNewsClient) {
return JSON.stringify(data, null, 2);
}),
);
server.tool(
"get_deep_dive",
"Get or generate an AI deep-dive analysis for a published news item. Returns sentiment, trust score, have-to-know score, comment sentiment analysis, post stats, author stats, and related articles. Generated on first request and cached.",
{
id: z.string().uuid().describe("News item UUID"),
},
handleTool(async ({ id }) => {
const data = await client.get<unknown>(`/api/news/${id}/deep-dive`);
return JSON.stringify(data, null, 2);
}),
);
server.tool(
"regenerate_deep_dive",
"Force-regenerate the AI deep-dive for a published news item, discarding the cached version. Requires Mod/Admin role and news:moderate scope.",
{
id: z.string().uuid().describe("News item UUID"),
},
handleTool(async ({ id }) => {
const data = await client.post<unknown>(`/api/news/${id}/deep-dive/regenerate`);
return JSON.stringify(data, null, 2);
}),
);
}
+203
View File
@@ -0,0 +1,203 @@
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 registerPollTools(server: McpServer, client: BetterNewsClient) {
server.tool(
"list_polls",
"List polls. Published polls are visible to all; other statuses require Mod/Admin role. Requires polls:read scope.",
{
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)"),
status: z
.enum(["published", "pending_review", "rejected"])
.optional()
.describe("Filter by status. Non-published statuses require Mod/Admin role."),
},
handleTool(async (args) => {
const data = await client.get<unknown>("/api/polls", args);
return JSON.stringify(data, null, 2);
}),
);
server.tool(
"create_poll",
"Create a new poll. The poll starts in pending_review and must be approved before it's visible. Requires polls:write scope and a verified email.",
{
title: z.string().min(1).max(150).describe("Poll title"),
description: z.string().min(1).max(2000).describe("Poll description"),
options: z
.array(z.string().min(1).max(100))
.min(2)
.max(6)
.describe("Between 2 and 6 answer options"),
expiresAt: z
.string()
.datetime({ offset: true })
.describe("Expiry timestamp (ISO 8601). Must be between now and 7 days from now."),
},
handleTool(async (body) => {
const data = await client.post<unknown>("/api/polls", body);
return JSON.stringify(data, null, 2);
}),
);
server.tool(
"get_poll",
"Get a single poll by ID. Published polls are publicly visible. Pending/rejected polls are only visible to the author and Mod/Admin. Requires polls:read scope.",
{
id: z.string().uuid().describe("Poll UUID"),
},
handleTool(async ({ id }) => {
const data = await client.get<unknown>(`/api/polls/${id}`);
return JSON.stringify(data, null, 2);
}),
);
server.tool(
"update_poll",
"Update a poll. Owners can only edit polls in pending_review status. Mod/Admin can edit any. Options cannot be changed after a poll is published. Requires polls:write (owner) or polls:moderate (Mod/Admin) scope.",
{
id: z.string().uuid().describe("Poll UUID"),
title: z.string().min(1).max(150).optional().describe("Poll title"),
description: z.string().min(1).max(2000).optional().describe("Poll description"),
options: z
.array(z.string().min(1).max(100))
.min(2)
.max(6)
.optional()
.describe("Answer options (cannot change after publication)"),
expiresAt: z
.string()
.datetime({ offset: true })
.optional()
.describe("New expiry timestamp (ISO 8601, must be within 7 days from now)"),
},
handleTool(async ({ id, ...body }) => {
const data = await client.patch<unknown>(`/api/polls/${id}`, body);
return JSON.stringify(data, null, 2);
}),
);
server.tool(
"delete_poll",
"Delete a poll. Owners can only delete polls in pending_review status. Mod/Admin can delete any. Requires polls:write scope.",
{
id: z.string().uuid().describe("Poll UUID"),
},
handleTool(async ({ id }) => {
const data = await client.delete<unknown>(`/api/polls/${id}`);
return JSON.stringify(data, null, 2);
}),
);
server.tool(
"vote_poll",
"Cast a vote on a published poll. Votes are final and cannot be changed. Requires polls:write scope.",
{
id: z.string().uuid().describe("Poll UUID"),
optionId: z.string().uuid().describe("The UUID of the option to vote for"),
},
handleTool(async ({ id, optionId }) => {
const data = await client.post<unknown>(`/api/polls/${id}/vote`, { optionId });
return JSON.stringify(data, null, 2);
}),
);
server.tool(
"like_poll",
"Toggle a like on a published poll. Requires polls:write scope.",
{
id: z.string().uuid().describe("Poll UUID"),
},
handleTool(async ({ id }) => {
const data = await client.post<unknown>(`/api/polls/${id}/like`);
return JSON.stringify(data, null, 2);
}),
);
server.tool(
"dislike_poll",
"Toggle a dislike on a published poll. Requires polls:write scope.",
{
id: z.string().uuid().describe("Poll UUID"),
},
handleTool(async ({ id }) => {
const data = await client.post<unknown>(`/api/polls/${id}/dislike`);
return JSON.stringify(data, null, 2);
}),
);
server.tool(
"review_poll",
"Approve or reject a pending poll. Requires Mod/Admin role and polls:moderate scope.",
{
id: z.string().uuid().describe("Poll UUID"),
action: z.enum(["approve", "reject"]).describe("Review decision"),
comment: z.string().max(1000).optional().describe("Optional feedback comment sent to the author"),
},
handleTool(async ({ id, ...body }) => {
const data = await client.post<unknown>(`/api/polls/${id}/review`, body);
return JSON.stringify(data, null, 2);
}),
);
server.tool(
"list_poll_comments",
"List comments on a poll. Moderators also see hidden comments. Sort options: top, most_disliked, new, oldest.",
{
id: z.string().uuid().describe("Poll UUID"),
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)"),
sort: z
.enum(["top", "most_disliked", "new", "oldest"])
.optional()
.describe("Sort order: top = most liked (default), most_disliked, new, oldest"),
},
handleTool(async ({ id, ...args }) => {
const data = await client.get<unknown>(`/api/polls/${id}/comments`, args);
return JSON.stringify(data, null, 2);
}),
);
server.tool(
"create_poll_comment",
"Post a comment on a published poll. Requires polls:comment scope and a verified email.",
{
id: z.string().uuid().describe("Poll UUID"),
content: z.string().min(1).max(2000).describe("Comment text"),
},
handleTool(async ({ id, content }) => {
const data = await client.post<unknown>(`/api/polls/${id}/comments`, { content });
return JSON.stringify(data, null, 2);
}),
);
server.tool(
"hide_poll_comment",
"Hide or unhide a poll comment. Requires Mod/Admin role and polls:moderate scope.",
{
id: z.string().uuid().describe("Poll UUID"),
commentId: z.string().uuid().describe("Comment UUID"),
hidden: z.boolean().describe("True to hide the comment, false to unhide it"),
},
handleTool(async ({ id, commentId, hidden }) => {
const data = await client.patch<unknown>(`/api/polls/${id}/comments/${commentId}`, { hidden });
return JSON.stringify(data, null, 2);
}),
);
server.tool(
"delete_poll_comment",
"Delete a poll comment. Owners can delete their own; Mod/Admin can delete any. Requires polls:comment scope.",
{
id: z.string().uuid().describe("Poll UUID"),
commentId: z.string().uuid().describe("Comment UUID"),
},
handleTool(async ({ id, commentId }) => {
const data = await client.delete<unknown>(`/api/polls/${id}/comments/${commentId}`);
return JSON.stringify(data, null, 2);
}),
);
}
+22
View File
@@ -57,6 +57,28 @@ export function registerTicketTools(server: McpServer, client: BetterNewsClient)
}),
);
server.tool(
"create_ticket_message",
"Post a message to a support ticket. Owners can message their own tickets; Mod/Admin can message any. Mod/Admin can mark messages as internal notes (not visible to the ticket author). Closed/resolved tickets cannot receive new messages. Requires tickets:write (owner) or tickets:moderate (Mod/Admin) scope.",
{
id: z.string().uuid().describe("Ticket UUID"),
content: z.string().min(1).max(5000).describe("Message text"),
isInternal: z
.boolean()
.optional()
.describe("Mark as internal note visible only to Mod/Admin (Mod/Admin only)"),
attachments: z
.array(z.string().url())
.max(5)
.optional()
.describe("Optional file attachment URLs (upload via POST /api/files/upload/attachment first)"),
},
handleTool(async ({ id, ...body }) => {
const data = await client.post<unknown>(`/api/tickets/${id}/messages`, body);
return JSON.stringify(data, null, 2);
}),
);
server.tool(
"update_ticket_status",
"Update the status of a support ticket. Requires Mod/Admin role and tickets:moderate scope.",
+26
View File
@@ -101,4 +101,30 @@ export function registerUserTools(server: McpServer, client: BetterNewsClient) {
return JSON.stringify(data, null, 2);
}),
);
server.tool(
"list_my_polls",
"List the authenticated user's own polls (authored or co-authored as a moderator who modified the poll). Requires user:profile scope.",
{
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)"),
},
handleTool(async (args) => {
const data = await client.get<unknown>("/api/user/polls", args);
return JSON.stringify(data, null, 2);
}),
);
server.tool(
"list_my_poll_votes",
"List polls the authenticated user has voted on, most recently voted first. Requires user:profile scope.",
{
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)"),
},
handleTool(async (args) => {
const data = await client.get<unknown>("/api/user/poll-votes", args);
return JSON.stringify(data, null, 2);
}),
);
}