diff --git a/CLAUDE.md b/CLAUDE.md index a7c4af7..84ad9c2 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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 diff --git a/src/index.ts b/src/index.ts index 2a62179..c0c0341 100644 --- a/src/index.ts +++ b/src/index.ts @@ -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); diff --git a/src/tools/moderation.ts b/src/tools/moderation.ts index ac3888c..863595d 100644 --- a/src/tools/moderation.ts +++ b/src/tools/moderation.ts @@ -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(`/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(`/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(`/api/mod/users/${userId}/api-keys/${keyId}`); + return JSON.stringify(data, null, 2); + }), + ); } diff --git a/src/tools/news.ts b/src/tools/news.ts index 1b61e11..ca9bd6a 100644 --- a/src/tools/news.ts +++ b/src/tools/news.ts @@ -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(`/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(`/api/news/${id}/deep-dive/regenerate`); + return JSON.stringify(data, null, 2); + }), + ); } diff --git a/src/tools/polls.ts b/src/tools/polls.ts new file mode 100644 index 0000000..45db957 --- /dev/null +++ b/src/tools/polls.ts @@ -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("/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("/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(`/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(`/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(`/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(`/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(`/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(`/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(`/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(`/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(`/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(`/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(`/api/polls/${id}/comments/${commentId}`); + return JSON.stringify(data, null, 2); + }), + ); +} diff --git a/src/tools/tickets.ts b/src/tools/tickets.ts index 05ab5d7..10accdb 100644 --- a/src/tools/tickets.ts +++ b/src/tools/tickets.ts @@ -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(`/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.", diff --git a/src/tools/user.ts b/src/tools/user.ts index 0cbef15..5667565 100644 --- a/src/tools/user.ts +++ b/src/tools/user.ts @@ -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("/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("/api/user/poll-votes", args); + return JSON.stringify(data, null, 2); + }), + ); }