From 6c0a07980862be37667febed702a25439e06a581 Mon Sep 17 00:00:00 2001 From: Space-Banane Date: Wed, 17 Jun 2026 21:30:52 +0200 Subject: [PATCH] feat: initialize BetterNews MCP server with core functionality - Add .gitignore to exclude node_modules, dist, and .env files - Create README.md with setup instructions and API key information - Add package.json with dependencies, scripts, and project metadata - Implement BetterNewsClient for API interactions - Set up index.ts to initialize MCP server and register tools - Create tools for managing news, comments, sources, users, moderation, and tickets - Add TypeScript configuration for project compilation --- .gitignore | 3 + README.md | 157 ++++++++++++++++++++++++++++++ package.json | 27 +++++ src/client.ts | 73 ++++++++++++++ src/index.ts | 38 ++++++++ src/tools/comments.ts | 45 +++++++++ src/tools/legal.ts | 50 ++++++++++ src/tools/moderation.ts | 110 +++++++++++++++++++++ src/tools/news.ts | 211 ++++++++++++++++++++++++++++++++++++++++ src/tools/sources.ts | 119 ++++++++++++++++++++++ src/tools/tickets.ts | 71 ++++++++++++++ src/tools/user.ts | 86 ++++++++++++++++ tsconfig.json | 15 +++ 13 files changed, 1005 insertions(+) create mode 100644 .gitignore create mode 100644 README.md create mode 100644 package.json create mode 100644 src/client.ts create mode 100644 src/index.ts create mode 100644 src/tools/comments.ts create mode 100644 src/tools/legal.ts create mode 100644 src/tools/moderation.ts create mode 100644 src/tools/news.ts create mode 100644 src/tools/sources.ts create mode 100644 src/tools/tickets.ts create mode 100644 src/tools/user.ts create mode 100644 tsconfig.json diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..deed335 --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +node_modules/ +dist/ +.env diff --git a/README.md b/README.md new file mode 100644 index 0000000..4f610c6 --- /dev/null +++ b/README.md @@ -0,0 +1,157 @@ +# betternews-mcp + +MCP server for the [BetterNews](https://betternews.app) API. Works with Claude, Codex, OpenClaw, and any other MCP-compatible client. + +## Setup + +### 1. Get an API key + +Create one at **Settings → API Keys** on [betternews.app](https://betternews.app). + +Choose only the scopes you need. Available scopes: + +| Scope | Access | +|---|---| +| `news:read` | List and search news | +| `news:write` | Create, edit, react, bookmark | +| `news:comment` | Post and delete comments | +| `news:moderate` | Review items and source submissions | +| `sources:read` | List sources | +| `sources:write` | Create/edit sources (Mod/Admin) | +| `bookmarks:write` | Manage bookmarks | +| `tickets:read` | View own tickets | +| `tickets:write` | Open tickets and reply | +| `tickets:moderate` | Manage all tickets (Mod/Admin) | +| `mod:queue` | Access mod queue and tickets | +| `mod:stats` | View moderation statistics | +| `mod:users` | Manage users (Admin) | +| `user:profile` | Read own profile and news | +| `user:apikeys` | List own API keys | +| `privacy:read` | Export personal data | + +### 2. Install and build + +```sh +pnpm install +pnpm build +``` + +### 3. Configure your MCP client + +#### Claude Desktop / Claude Code + +Add to your `claude_desktop_config.json` (or project `.mcp.json`): + +```json +{ + "mcpServers": { + "betternews": { + "command": "node", + "args": ["/absolute/path/to/mcp-server/dist/index.js"], + "env": { + "BETTERNEWS_API_KEY": "bn_sk_your_key_here" + } + } + } +} +``` + +#### Codex CLI + +```sh +BETTERNEWS_API_KEY=bn_sk_your_key_here codex --mcp "node /path/to/mcp-server/dist/index.js" +``` + +#### OpenClaw + +Add as a stdio MCP tool with `command: node dist/index.js` and the `BETTERNEWS_API_KEY` env var. + +## Tools + +### News + +| Tool | Description | Required scope | +|---|---|---| +| `get_top_news` | Top 5 news by weighted score | — | +| `list_news` | Paginated news list with filters | `news:read` | +| `get_news_item` | Single news item by ID | `news:read` | +| `search_news` | Semantic vector search | `news:read` | +| `get_similar_news` | Vector-similar articles | `news:read` | +| `create_news_item` | Create a draft | `news:write` | +| `update_news_item` | Edit an item | `news:write` | +| `submit_news_item` | Submit draft for review | `news:write` | +| `review_news_item` | Approve / reject / revise | `news:moderate` | +| `like_news_item` | Toggle like | `news:write` | +| `dislike_news_item` | Toggle dislike | `news:write` | +| `bookmark_news_item` | Toggle bookmark | `bookmarks:write` | +| `delete_news_item` | Delete an item | `news:write` | +| `get_news_item_stats` | Engagement stats | Mod/Admin | + +### Comments + +| Tool | Description | Required scope | +|---|---|---| +| `list_comments` | Comments on a news item | `news:read` | +| `create_comment` | Post a comment | `news:comment` | +| `delete_comment` | Delete a comment | `news:comment` | + +### Sources + +| Tool | Description | Required scope | +|---|---|---| +| `list_sources` | Paginated source list | `sources:read` | +| `get_source` | Single source by ID | `sources:read` | +| `create_source` | Create a source | `sources:write` | +| `update_source` | Edit a source | `sources:write` | +| `list_source_submissions` | Source suggestions on an item | `news:read` | +| `submit_source` | Suggest a source | `news:write` | +| `review_source_submission` | Approve / reject suggestion | `news:moderate` | + +### User + +| Tool | Description | Required scope | +|---|---|---| +| `get_current_user` | Own profile and account details | `user:profile` | +| `get_user_profile` | Public profile by username | — | +| `list_my_news` | Own news items | `user:profile` | +| `list_my_source_submissions` | Own source submissions | `user:profile` | +| `list_bookmarks` | Bookmarked news items | `bookmarks:write` | +| `update_profile` | Update display name / email / bio | `user:profile` | + +### Moderation + +| Tool | Description | Required scope | +|---|---|---| +| `get_mod_queue` | Pending review queue | `mod:queue` | +| `get_mod_stats` | Aggregate statistics | `mod:stats` | +| `list_mod_users` | All users (Admin) | `mod:users` | +| `get_mod_user` | User detail (Admin) | `mod:users` | +| `change_user_role` | Change role (Admin) | `mod:users` | +| `ban_user` | Ban a user (Admin) | `mod:users` | +| `unban_user` | Lift a ban (Admin) | `mod:users` | +| `list_all_tickets` | All tickets (Mod view) | `mod:queue` | + +### Tickets + +| Tool | Description | Required scope | +|---|---|---| +| `list_my_tickets` | Own support tickets | `tickets:read` | +| `get_ticket` | Ticket with reply history | `tickets:read` | +| `create_ticket` | Open a ticket | `tickets:write` | +| `reply_to_ticket` | Reply to a ticket | `tickets:write` | +| `update_ticket_status` | Change ticket status (Mod) | `tickets:moderate` | + +### Legal + +| Tool | Description | +|---|---| +| `get_terms_of_service` | Current published ToS | +| `get_privacy_policy` | Current published Privacy Policy | +| `list_legal_documents` | List all docs (Mod/Admin see drafts) | +| `accept_legal_documents` | Accept current legal docs | + +## Environment variables + +| Variable | Required | Description | +|---|---|---| +| `BETTERNEWS_API_KEY` | Yes | API key with prefix `bn_sk_` | diff --git a/package.json b/package.json new file mode 100644 index 0000000..a0a3b06 --- /dev/null +++ b/package.json @@ -0,0 +1,27 @@ +{ + "name": "betternews-mcp", + "version": "1.0.0", + "description": "MCP server for the BetterNews API", + "type": "module", + "main": "dist/index.js", + "bin": { + "betternews-mcp": "dist/index.js" + }, + "scripts": { + "build": "tsc", + "dev": "tsc --watch", + "start": "node dist/index.js", + "prepublishOnly": "pnpm build" + }, + "dependencies": { + "@modelcontextprotocol/sdk": "^1.29.0", + "zod": "^4.4.3" + }, + "devDependencies": { + "@types/node": "^22.19.21", + "typescript": "^5.9.3" + }, + "engines": { + "node": ">=22" + } +} diff --git a/src/client.ts b/src/client.ts new file mode 100644 index 0000000..4c6c464 --- /dev/null +++ b/src/client.ts @@ -0,0 +1,73 @@ +const BASE_URL = "https://api.betternews.app"; + +export class BetterNewsClient { + private apiKey: string; + + constructor(apiKey: string) { + this.apiKey = apiKey; + } + + async request( + method: string, + path: string, + body?: unknown, + query?: Record, + ): Promise { + let url = `${BASE_URL}${path}`; + + if (query) { + const params = new URLSearchParams(); + for (const [key, value] of Object.entries(query)) { + if (value !== undefined) { + params.set(key, String(value)); + } + } + const qs = params.toString(); + if (qs) url += `?${qs}`; + } + + const res = await fetch(url, { + method, + headers: { + Authorization: `Bearer ${this.apiKey}`, + "Content-Type": "application/json", + Accept: "application/json", + }, + body: body !== undefined ? JSON.stringify(body) : undefined, + }); + + if (!res.ok) { + let detail = ""; + try { + const err = (await res.json()) as { message?: string }; + detail = err.message ?? ""; + } catch { + detail = await res.text().catch(() => ""); + } + throw new Error( + `BetterNews API error ${res.status}: ${detail || res.statusText}`, + ); + } + + return res.json() as Promise; + } + + get( + path: string, + query?: Record, + ) { + return this.request("GET", path, undefined, query); + } + + post(path: string, body?: unknown) { + return this.request("POST", path, body); + } + + patch(path: string, body?: unknown) { + return this.request("PATCH", path, body); + } + + delete(path: string) { + return this.request("DELETE", path); + } +} diff --git a/src/index.ts b/src/index.ts new file mode 100644 index 0000000..e2fba06 --- /dev/null +++ b/src/index.ts @@ -0,0 +1,38 @@ +#!/usr/bin/env node +import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; +import { BetterNewsClient } from "./client.js"; +import { registerNewsTools } from "./tools/news.js"; +import { registerCommentTools } from "./tools/comments.js"; +import { registerSourceTools } from "./tools/sources.js"; +import { registerUserTools } from "./tools/user.js"; +import { registerModerationTools } from "./tools/moderation.js"; +import { registerTicketTools } from "./tools/tickets.js"; +import { registerLegalTools } from "./tools/legal.js"; + +const apiKey = process.env.BETTERNEWS_API_KEY; +if (!apiKey) { + process.stderr.write( + "Error: BETTERNEWS_API_KEY environment variable is required.\n" + + "Create an API key at https://betternews.app/settings/api-keys and set it.\n", + ); + process.exit(1); +} + +const client = new BetterNewsClient(apiKey); + +const server = new McpServer({ + name: "betternews", + version: "1.0.0", +}); + +registerNewsTools(server, client); +registerCommentTools(server, client); +registerSourceTools(server, client); +registerUserTools(server, client); +registerModerationTools(server, client); +registerTicketTools(server, client); +registerLegalTools(server, client); + +const transport = new StdioServerTransport(); +await server.connect(transport); diff --git a/src/tools/comments.ts b/src/tools/comments.ts new file mode 100644 index 0000000..ea68eba --- /dev/null +++ b/src/tools/comments.ts @@ -0,0 +1,45 @@ +import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { z } from "zod"; +import type { BetterNewsClient } from "../client.js"; + +export function registerCommentTools(server: McpServer, client: BetterNewsClient) { + server.tool( + "list_comments", + "List comments on a news item. Moderators also see hidden comments. Requires news:read scope.", + { + 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)"), + }, + async ({ newsItemId, page, limit }) => { + const data = await client.get(`/api/news/${newsItemId}/comments`, { page, limit }); + return { content: [{ type: "text", text: JSON.stringify(data, null, 2) }] }; + }, + ); + + server.tool( + "create_comment", + "Post a comment on a published news item. Requires news:comment scope.", + { + newsItemId: z.string().uuid().describe("News item UUID"), + content: z.string().min(1).max(2000).describe("Comment text"), + }, + async ({ newsItemId, content }) => { + const data = await client.post(`/api/news/${newsItemId}/comments`, { content }); + return { content: [{ type: "text", text: JSON.stringify(data, null, 2) }] }; + }, + ); + + server.tool( + "delete_comment", + "Delete a comment. Owners can delete their own; Mod/Admin can delete any. Requires news:comment scope.", + { + newsItemId: z.string().uuid().describe("News item UUID"), + commentId: z.string().uuid().describe("Comment UUID"), + }, + async ({ newsItemId, commentId }) => { + const data = await client.delete(`/api/news/${newsItemId}/comments/${commentId}`); + return { content: [{ type: "text", text: JSON.stringify(data, null, 2) }] }; + }, + ); +} diff --git a/src/tools/legal.ts b/src/tools/legal.ts new file mode 100644 index 0000000..b50702f --- /dev/null +++ b/src/tools/legal.ts @@ -0,0 +1,50 @@ +import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { z } from "zod"; +import type { BetterNewsClient } from "../client.js"; + +export function registerLegalTools(server: McpServer, client: BetterNewsClient) { + server.tool( + "get_terms_of_service", + "Get the current published Terms of Service document.", + {}, + async () => { + const data = await client.get("/api/legal/current/terms_of_service"); + return { content: [{ type: "text", text: JSON.stringify(data, null, 2) }] }; + }, + ); + + server.tool( + "get_privacy_policy", + "Get the current published Privacy Policy document.", + {}, + async () => { + const data = await client.get("/api/legal/current/privacy_policy"); + return { content: [{ type: "text", text: JSON.stringify(data, null, 2) }] }; + }, + ); + + server.tool( + "list_legal_documents", + "List legal documents. Public sees published only; Mod/Admin see all.", + { + type: z.enum(["terms_of_service", "privacy_policy"]).optional().describe("Filter by document type"), + status: z.enum(["draft", "published", "archived"]).optional().describe("Filter by status (Mod/Admin only)"), + page: z.number().int().positive().optional(), + limit: z.number().int().min(1).max(50).optional(), + }, + async (args) => { + const data = await client.get("/api/legal", args); + return { content: [{ type: "text", text: JSON.stringify(data, null, 2) }] }; + }, + ); + + server.tool( + "accept_legal_documents", + "Accept the current legal documents (Terms of Service and Privacy Policy). Clears the legalAcceptanceRequired flag.", + {}, + async () => { + const data = await client.post("/api/legal/accept"); + return { content: [{ type: "text", text: JSON.stringify(data, null, 2) }] }; + }, + ); +} diff --git a/src/tools/moderation.ts b/src/tools/moderation.ts new file mode 100644 index 0000000..f129997 --- /dev/null +++ b/src/tools/moderation.ts @@ -0,0 +1,110 @@ +import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { z } from "zod"; +import type { BetterNewsClient } from "../client.js"; + +export function registerModerationTools(server: McpServer, client: BetterNewsClient) { + server.tool( + "get_mod_queue", + "Get the moderation queue of pending news items and source submissions. Requires Mod/Admin role and mod:queue 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)"), + }, + async (args) => { + const data = await client.get("/api/mod/queue", args); + return { content: [{ type: "text", text: JSON.stringify(data, null, 2) }] }; + }, + ); + + server.tool( + "get_mod_stats", + "Get aggregate moderation statistics. Requires Mod/Admin role and mod:stats scope.", + {}, + async () => { + const data = await client.get("/api/mod/stats"); + return { content: [{ type: "text", text: JSON.stringify(data, null, 2) }] }; + }, + ); + + server.tool( + "list_mod_users", + "List all users with moderation details. Requires Admin role and mod:users scope.", + { + 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)"), + search: z.string().optional().describe("Search by username or email"), + role: z.enum(["User", "Moderator", "Admin"]).optional().describe("Filter by role"), + banned: z.boolean().optional().describe("Filter by ban status"), + }, + async (args) => { + const data = await client.get("/api/mod/users", args); + return { content: [{ type: "text", text: JSON.stringify(data, null, 2) }] }; + }, + ); + + server.tool( + "get_mod_user", + "Get detailed moderation view of a specific user. Requires Admin role and mod:users scope.", + { + userId: z.string().uuid().describe("User UUID"), + }, + async ({ userId }) => { + const data = await client.get(`/api/mod/users/${userId}`); + return { content: [{ type: "text", text: JSON.stringify(data, null, 2) }] }; + }, + ); + + server.tool( + "change_user_role", + "Change a user's role. Requires Admin role and mod:users scope.", + { + userId: z.string().uuid().describe("User UUID"), + role: z.enum(["User", "Moderator", "Admin"]).describe("New role"), + }, + async ({ userId, role }) => { + const data = await client.patch(`/api/mod/users/${userId}/role`, { role }); + return { content: [{ type: "text", text: JSON.stringify(data, null, 2) }] }; + }, + ); + + server.tool( + "ban_user", + "Ban a user. Requires Admin role and mod:users scope.", + { + userId: z.string().uuid().describe("User UUID"), + reason: z.string().max(500).optional().describe("Ban reason"), + bannedUntil: z.string().datetime().optional().describe("Temporary ban expiry (ISO 8601). Omit for permanent."), + permanent: z.boolean().optional().describe("Whether the ban is permanent"), + }, + async ({ userId, ...body }) => { + const data = await client.post(`/api/mod/users/${userId}/ban`, body); + return { content: [{ type: "text", text: JSON.stringify(data, null, 2) }] }; + }, + ); + + server.tool( + "unban_user", + "Lift a ban from a user. Requires Admin role and mod:users scope.", + { + userId: z.string().uuid().describe("User UUID"), + }, + async ({ userId }) => { + const data = await client.post(`/api/mod/users/${userId}/unban`); + return { content: [{ type: "text", text: JSON.stringify(data, null, 2) }] }; + }, + ); + + server.tool( + "list_all_tickets", + "List all support tickets (mod view). Requires Mod/Admin role and mod:queue scope.", + { + page: z.number().int().positive().optional(), + limit: z.number().int().min(1).max(100).optional(), + status: z.enum(["open", "in_progress", "resolved", "closed"]).optional(), + }, + async (args) => { + const data = await client.get("/api/mod/tickets", args); + return { content: [{ type: "text", text: JSON.stringify(data, null, 2) }] }; + }, + ); +} diff --git a/src/tools/news.ts b/src/tools/news.ts new file mode 100644 index 0000000..cdf056c --- /dev/null +++ b/src/tools/news.ts @@ -0,0 +1,211 @@ +import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { z } from "zod"; +import type { BetterNewsClient } from "../client.js"; + +export function registerNewsTools(server: McpServer, client: BetterNewsClient) { + server.tool( + "get_top_news", + "Get the top 5 news items by weighted score (likes + recency). No auth required.", + {}, + async () => { + const data = await client.get("/api/news/top"); + return { content: [{ type: "text", text: JSON.stringify(data, null, 2) }] }; + }, + ); + + server.tool( + "list_news", + "List published news items with optional pagination and filters. Moderators/Admins can also filter by status.", + { + 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, max 100)"), + category: z.string().optional().describe("Filter by category slug"), + tag: z.string().optional().describe("Filter by tag"), + source: z.string().uuid().optional().describe("Filter by source UUID"), + status: z + .enum(["published", "draft", "pending_review", "approved", "rejected", "needs_revision", "archived"]) + .optional() + .describe("Filter by status (Mod/Admin only for non-published)"), + search: z.string().optional().describe("Text search over title and summary"), + }, + async (args) => { + const data = await client.get("/api/news", args); + return { content: [{ type: "text", text: JSON.stringify(data, null, 2) }] }; + }, + ); + + server.tool( + "get_news_item", + "Get a single news item by ID.", + { + id: z.string().uuid().describe("News item UUID"), + }, + async ({ id }) => { + const data = await client.get(`/api/news/${id}`); + return { content: [{ type: "text", text: JSON.stringify(data, null, 2) }] }; + }, + ); + + server.tool( + "search_news", + "Semantic vector search over published news items.", + { + q: z.string().describe("Search query"), + limit: z.number().int().min(1).max(20).optional().describe("Max results (default 10)"), + }, + async ({ q, limit }) => { + const data = await client.get("/api/news/search", { q, limit }); + return { content: [{ type: "text", text: JSON.stringify(data, null, 2) }] }; + }, + ); + + server.tool( + "get_similar_news", + "Get news items similar to a given item using vector similarity.", + { + id: z.string().uuid().describe("News item UUID"), + limit: z.number().int().min(1).max(10).optional().describe("Max results (default 5)"), + }, + async ({ id, limit }) => { + const data = await client.get(`/api/news/${id}/similar`, { limit }); + return { content: [{ type: "text", text: JSON.stringify(data, null, 2) }] }; + }, + ); + + server.tool( + "create_news_item", + "Create a new draft news item. Requires news:write scope.", + { + title: z.string().min(1).max(200).describe("Article title"), + readMoreUrl: z.string().url().describe("URL to the original article"), + sourceId: z.string().uuid().describe("UUID of the source this article belongs to"), + summary: z.string().max(1000).optional().describe("Short summary of the article"), + longDescription: z.string().optional().describe("Detailed description"), + authorsThought: z.string().max(500).optional().describe("Author commentary"), + category: z.string().describe("Category slug"), + tags: z.array(z.string()).optional().describe("Array of tag strings"), + language: z.string().optional().describe("ISO 639-1 language code"), + country: z.string().optional().describe("ISO 3166-1 alpha-2 country code"), + imageUrl: z.string().url().optional().describe("Hero image URL"), + imageAltText: z.string().optional().describe("Image alt text"), + imageCredits: z.string().optional().describe("Image attribution"), + datePublished: z.string().datetime().optional().describe("Original publication date (ISO 8601)"), + }, + async (args) => { + const data = await client.post("/api/news", args); + return { content: [{ type: "text", text: JSON.stringify(data, null, 2) }] }; + }, + ); + + server.tool( + "submit_news_item", + "Submit a draft news item for moderation review. Requires news:write scope and ownership.", + { + id: z.string().uuid().describe("News item UUID"), + }, + async ({ id }) => { + const data = await client.post(`/api/news/${id}/submit`); + return { content: [{ type: "text", text: JSON.stringify(data, null, 2) }] }; + }, + ); + + server.tool( + "update_news_item", + "Edit a news item. Owners can only edit draft/needs_revision items. Mod/Admin can edit any. Requires news:write scope.", + { + id: z.string().uuid().describe("News item UUID"), + title: z.string().min(1).max(200).optional(), + readMoreUrl: z.string().url().optional(), + sourceId: z.string().uuid().optional(), + summary: z.string().max(1000).optional(), + longDescription: z.string().optional(), + authorsThought: z.string().max(500).optional(), + category: z.string().optional(), + tags: z.array(z.string()).optional(), + language: z.string().optional(), + country: z.string().optional(), + imageUrl: z.string().url().nullable().optional(), + imageAltText: z.string().nullable().optional(), + imageCredits: z.string().nullable().optional(), + datePublished: z.string().datetime().nullable().optional(), + }, + async ({ id, ...body }) => { + const data = await client.patch(`/api/news/${id}`, body); + return { content: [{ type: "text", text: JSON.stringify(data, null, 2) }] }; + }, + ); + + server.tool( + "review_news_item", + "Approve, reject, or request revision on a news item. Requires Mod/Admin role and news:moderate scope.", + { + id: z.string().uuid().describe("News item UUID"), + action: z.enum(["approve", "reject", "needs_revision"]).describe("Review decision"), + comment: z.string().optional().describe("Optional feedback comment"), + }, + async ({ id, ...body }) => { + const data = await client.post(`/api/news/${id}/review`, body); + return { content: [{ type: "text", text: JSON.stringify(data, null, 2) }] }; + }, + ); + + server.tool( + "like_news_item", + "Toggle a like on a news item. Requires news:write scope.", + { + id: z.string().uuid().describe("News item UUID"), + }, + async ({ id }) => { + const data = await client.post(`/api/news/${id}/like`); + return { content: [{ type: "text", text: JSON.stringify(data, null, 2) }] }; + }, + ); + + server.tool( + "dislike_news_item", + "Toggle a dislike on a news item. Requires news:write scope.", + { + id: z.string().uuid().describe("News item UUID"), + }, + async ({ id }) => { + const data = await client.post(`/api/news/${id}/dislike`); + return { content: [{ type: "text", text: JSON.stringify(data, null, 2) }] }; + }, + ); + + server.tool( + "bookmark_news_item", + "Toggle a bookmark on a news item. Requires bookmarks:write scope.", + { + id: z.string().uuid().describe("News item UUID"), + }, + async ({ id }) => { + const data = await client.post(`/api/news/${id}/bookmark`); + return { content: [{ type: "text", text: JSON.stringify(data, null, 2) }] }; + }, + ); + + server.tool( + "delete_news_item", + "Delete a news item. Owners can delete their own drafts; Admins can delete anything. Requires news:write scope.", + { + id: z.string().uuid().describe("News item UUID"), + }, + async ({ id }) => { + const data = await client.delete(`/api/news/${id}`); + return { content: [{ type: "text", text: JSON.stringify(data, null, 2) }] }; + }, + ); + + server.tool( + "get_news_item_stats", + "Get engagement statistics for a news item. Requires Mod/Admin role.", + { + id: z.string().uuid().describe("News item UUID"), + }, + async ({ id }) => { + const data = await client.get(`/api/news/${id}/stats`); + return { content: [{ type: "text", text: JSON.stringify(data, null, 2) }] }; + }, + ); +} diff --git a/src/tools/sources.ts b/src/tools/sources.ts new file mode 100644 index 0000000..8457214 --- /dev/null +++ b/src/tools/sources.ts @@ -0,0 +1,119 @@ +import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { z } from "zod"; +import type { BetterNewsClient } from "../client.js"; + +export function registerSourceTools(server: McpServer, client: BetterNewsClient) { + server.tool( + "list_sources", + "List curated news sources. Requires sources:read scope.", + { + 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)"), + search: z.string().optional().describe("Text search over source name"), + type: z.string().optional().describe("Filter by source type"), + }, + async (args) => { + const data = await client.get("/api/sources", args); + return { content: [{ type: "text", text: JSON.stringify(data, null, 2) }] }; + }, + ); + + server.tool( + "get_source", + "Get a single news source by ID. Requires sources:read scope.", + { + id: z.string().uuid().describe("Source UUID"), + }, + async ({ id }) => { + const data = await client.get(`/api/sources/${id}`); + return { content: [{ type: "text", text: JSON.stringify(data, null, 2) }] }; + }, + ); + + server.tool( + "create_source", + "Create a new news source. Requires Mod/Admin role and sources:write scope.", + { + name: z.string().min(1).max(100).describe("Source name"), + type: z.string().describe("Source type (e.g. newspaper, blog, wire)"), + url: z.string().url().optional().describe("Source homepage URL"), + description: z.string().optional().describe("Short description"), + credibilityScore: z.number().min(0).max(100).optional().describe("Credibility score 0–100"), + country: z.string().optional().describe("ISO 3166-1 alpha-2 country code"), + language: z.string().optional().describe("ISO 639-1 language code"), + contactEmail: z.string().email().optional().describe("Contact email"), + logoUrl: z.string().url().optional().describe("Logo image URL"), + }, + async (args) => { + const data = await client.post("/api/sources", args); + return { content: [{ type: "text", text: JSON.stringify(data, null, 2) }] }; + }, + ); + + server.tool( + "update_source", + "Update a news source. Requires Mod/Admin role and sources:write scope.", + { + id: z.string().uuid().describe("Source UUID"), + name: z.string().min(1).max(100).optional(), + type: z.string().optional(), + url: z.string().url().nullable().optional(), + description: z.string().nullable().optional(), + credibilityScore: z.number().min(0).max(100).nullable().optional(), + country: z.string().nullable().optional(), + language: z.string().nullable().optional(), + contactEmail: z.string().email().nullable().optional(), + logoUrl: z.string().url().nullable().optional(), + hidden: z.boolean().optional().describe("Hide/unhide this source"), + }, + async ({ id, ...body }) => { + const data = await client.patch(`/api/sources/${id}`, body); + return { content: [{ type: "text", text: JSON.stringify(data, null, 2) }] }; + }, + ); + + server.tool( + "list_source_submissions", + "List community source submissions on a news item. Requires news:read scope.", + { + newsItemId: z.string().uuid().describe("News item UUID"), + }, + async ({ newsItemId }) => { + const data = await client.get(`/api/news/${newsItemId}/sources`); + return { content: [{ type: "text", text: JSON.stringify(data, null, 2) }] }; + }, + ); + + server.tool( + "submit_source", + "Submit a source suggestion for a news item. Requires news:write scope.", + { + newsItemId: z.string().uuid().describe("News item UUID"), + readMoreUrl: z.string().url().describe("URL to the source article"), + title: z.string().optional().describe("Optional title for the source link"), + description: z.string().optional().describe("Optional description"), + }, + async ({ newsItemId, ...body }) => { + const data = await client.post(`/api/news/${newsItemId}/sources`, body); + return { content: [{ type: "text", text: JSON.stringify(data, null, 2) }] }; + }, + ); + + server.tool( + "review_source_submission", + "Approve or reject a community source submission. Requires Mod/Admin role and news:moderate scope.", + { + newsItemId: z.string().uuid().describe("News item UUID"), + submissionId: z.string().uuid().describe("Source submission UUID"), + action: z.enum(["approve", "reject"]).describe("Review decision"), + comment: z.string().optional().describe("Optional feedback"), + }, + async ({ newsItemId, submissionId, ...body }) => { + const data = await client.post( + `/api/news/${newsItemId}/sources/${submissionId}/review`, + body, + ); + return { content: [{ type: "text", text: JSON.stringify(data, null, 2) }] }; + }, + ); +} diff --git a/src/tools/tickets.ts b/src/tools/tickets.ts new file mode 100644 index 0000000..4e72475 --- /dev/null +++ b/src/tools/tickets.ts @@ -0,0 +1,71 @@ +import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { z } from "zod"; +import type { BetterNewsClient } from "../client.js"; + +export function registerTicketTools(server: McpServer, client: BetterNewsClient) { + server.tool( + "list_my_tickets", + "List the authenticated user's support tickets. Requires tickets:read scope.", + { + page: z.number().int().positive().optional(), + limit: z.number().int().min(1).max(50).optional(), + status: z.enum(["open", "in_progress", "resolved", "closed"]).optional(), + }, + async (args) => { + const data = await client.get("/api/tickets", args); + return { content: [{ type: "text", text: JSON.stringify(data, null, 2) }] }; + }, + ); + + server.tool( + "get_ticket", + "Get a support ticket with its reply history. Requires ownership or Mod/Admin role. Requires tickets:read scope.", + { + id: z.string().uuid().describe("Ticket UUID"), + }, + async ({ id }) => { + const data = await client.get(`/api/tickets/${id}`); + return { content: [{ type: "text", text: JSON.stringify(data, null, 2) }] }; + }, + ); + + server.tool( + "create_ticket", + "Open a new support ticket. Requires tickets:write scope.", + { + title: z.string().min(1).max(200).describe("Ticket title"), + description: z.string().min(1).max(5000).describe("Detailed description of the issue"), + priority: z.enum(["low", "medium", "high", "critical"]).describe("Ticket priority"), + }, + async (body) => { + const data = await client.post("/api/tickets", body); + return { content: [{ type: "text", text: JSON.stringify(data, null, 2) }] }; + }, + ); + + server.tool( + "reply_to_ticket", + "Add a reply to a support ticket. Owners can reply to their own tickets; Mod/Admin can reply to any. Requires tickets:write scope.", + { + id: z.string().uuid().describe("Ticket UUID"), + content: z.string().min(1).max(5000).describe("Reply text"), + }, + async ({ id, content }) => { + const data = await client.post(`/api/tickets/${id}/reply`, { content }); + return { content: [{ type: "text", text: 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.", + { + id: z.string().uuid().describe("Ticket UUID"), + status: z.enum(["open", "in_progress", "resolved", "closed"]).describe("New status"), + }, + async ({ id, status }) => { + const data = await client.patch(`/api/tickets/${id}/status`, { status }); + return { content: [{ type: "text", text: JSON.stringify(data, null, 2) }] }; + }, + ); +} diff --git a/src/tools/user.ts b/src/tools/user.ts new file mode 100644 index 0000000..2f97dda --- /dev/null +++ b/src/tools/user.ts @@ -0,0 +1,86 @@ +import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { z } from "zod"; +import type { BetterNewsClient } from "../client.js"; + +export function registerUserTools(server: McpServer, client: BetterNewsClient) { + server.tool( + "get_current_user", + "Get the authenticated user's profile and account details. Requires user:profile scope.", + {}, + async () => { + const data = await client.get("/api/user/whoami"); + return { content: [{ type: "text", text: JSON.stringify(data, null, 2) }] }; + }, + ); + + server.tool( + "get_user_profile", + "Get a public user profile by username.", + { + username: z.string().describe("Username to look up"), + }, + async ({ username }) => { + const data = await client.get(`/api/user/profile/${username}`); + return { content: [{ type: "text", text: JSON.stringify(data, null, 2) }] }; + }, + ); + + server.tool( + "list_my_news", + "List the authenticated user's own news items. Requires user:profile scope.", + { + 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)"), + status: z + .enum(["published", "draft", "pending_review", "approved", "rejected", "needs_revision", "archived"]) + .optional() + .describe("Filter by status"), + }, + async (args) => { + const data = await client.get("/api/user/news", args); + return { content: [{ type: "text", text: JSON.stringify(data, null, 2) }] }; + }, + ); + + server.tool( + "list_my_source_submissions", + "List the authenticated user's own source submissions. Requires user:profile scope.", + {}, + async () => { + const data = await client.get("/api/user/source-submissions"); + return { content: [{ type: "text", text: JSON.stringify(data, null, 2) }] }; + }, + ); + + server.tool( + "list_bookmarks", + "List the authenticated user's bookmarked news items. Requires bookmarks:write scope.", + { + 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)"), + }, + async (args) => { + const data = await client.get("/api/user/bookmarks", args); + return { content: [{ type: "text", text: JSON.stringify(data, null, 2) }] }; + }, + ); + + server.tool( + "update_profile", + "Update the authenticated user's display name or email. Requires user:profile scope.", + { + displayName: z.string().min(1).max(25).optional().describe("New display name"), + email: z.string().email().optional().describe("New email address (triggers verification)"), + bio: z.string().max(500).nullable().optional().describe("Profile bio"), + avatarUrl: z.string().url().nullable().optional().describe("Avatar image URL"), + profilePublic: z.boolean().optional().describe("Whether the profile is public"), + showEmail: z.boolean().optional().describe("Whether to show email on profile"), + emailNewsUpdates: z.boolean().optional(), + emailCommentEvents: z.boolean().optional(), + }, + async (body) => { + const data = await client.patch("/api/user/profile", body); + return { content: [{ type: "text", text: JSON.stringify(data, null, 2) }] }; + }, + ); +} diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..ecf49c3 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,15 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "Node16", + "moduleResolution": "Node16", + "outDir": "dist", + "rootDir": "src", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "declaration": true + }, + "include": ["src"], + "exclude": ["node_modules", "dist"] +}