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
This commit is contained in:
@@ -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<unknown>("/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<unknown>("/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<unknown>(`/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<unknown>("/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<unknown>(`/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<unknown>("/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<unknown>(`/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<unknown>(`/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<unknown>(`/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<unknown>(`/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<unknown>(`/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<unknown>(`/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<unknown>(`/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<unknown>(`/api/news/${id}/stats`);
|
||||
return { content: [{ type: "text", text: JSON.stringify(data, null, 2) }] };
|
||||
},
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user