From a1d47d4819564907635629f73518a61be7faac74 Mon Sep 17 00:00:00 2001 From: Space-Banane Date: Wed, 17 Jun 2026 22:07:21 +0200 Subject: [PATCH] fix: apply all A-J improvements across MCP server MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - A: fix get_user_profile URL (/api/user/profile → /api/users/:username) - B: fix unban_user to use DELETE /api/mod/users/:id/ban (was POST .../unban) - C: add check_duplicates tool (GET /api/news/:id/check-duplicates) - D: add shadowban_user tool (PATCH /api/mod/users/:id/shadowban) - E: add create_mod_user tool (POST /api/mod/users) - F: extract handleTool helper in utils.ts; all 37 tools now use it for consistent isError:true error responses visible to the AI model - G: only send Content-Type header when request has a body - H: support BETTERNEWS_BASE_URL env var for local dev overrides - I: fix get_news_item_stats description (accessible by own author, not Mod/Admin only) - J: add .describe() to page/limit/status params in list_all_tickets and list_my_tickets Co-Authored-By: Claude Sonnet 4.6 --- src/client.ts | 22 ++++--- src/index.ts | 3 +- src/tools/comments.ts | 19 +++--- src/tools/legal.ts | 29 +++++----- src/tools/moderation.ts | 84 ++++++++++++++++++--------- src/tools/news.ts | 125 ++++++++++++++++++++++------------------ src/tools/sources.ts | 61 ++++++++++---------- src/tools/tickets.ts | 37 ++++++------ src/tools/user.ts | 43 +++++++------- src/utils.ts | 16 +++++ 10 files changed, 254 insertions(+), 185 deletions(-) create mode 100644 src/utils.ts diff --git a/src/client.ts b/src/client.ts index 4c6c464..7ccfbf7 100644 --- a/src/client.ts +++ b/src/client.ts @@ -1,10 +1,10 @@ -const BASE_URL = "https://api.betternews.app"; - export class BetterNewsClient { private apiKey: string; + private baseUrl: string; - constructor(apiKey: string) { + constructor(apiKey: string, baseUrl = "https://api.betternews.app") { this.apiKey = apiKey; + this.baseUrl = baseUrl; } async request( @@ -13,7 +13,7 @@ export class BetterNewsClient { body?: unknown, query?: Record, ): Promise { - let url = `${BASE_URL}${path}`; + let url = `${this.baseUrl}${path}`; if (query) { const params = new URLSearchParams(); @@ -26,13 +26,17 @@ export class BetterNewsClient { if (qs) url += `?${qs}`; } + const headers: Record = { + Authorization: `Bearer ${this.apiKey}`, + Accept: "application/json", + }; + if (body !== undefined) { + headers["Content-Type"] = "application/json"; + } + const res = await fetch(url, { method, - headers: { - Authorization: `Bearer ${this.apiKey}`, - "Content-Type": "application/json", - Accept: "application/json", - }, + headers, body: body !== undefined ? JSON.stringify(body) : undefined, }); diff --git a/src/index.ts b/src/index.ts index e2fba06..9c70af7 100644 --- a/src/index.ts +++ b/src/index.ts @@ -19,7 +19,8 @@ if (!apiKey) { process.exit(1); } -const client = new BetterNewsClient(apiKey); +const baseUrl = process.env.BETTERNEWS_BASE_URL ?? "https://api.betternews.app"; +const client = new BetterNewsClient(apiKey, baseUrl); const server = new McpServer({ name: "betternews", diff --git a/src/tools/comments.ts b/src/tools/comments.ts index ea68eba..ecbd9d0 100644 --- a/src/tools/comments.ts +++ b/src/tools/comments.ts @@ -1,6 +1,7 @@ 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 registerCommentTools(server: McpServer, client: BetterNewsClient) { server.tool( @@ -11,10 +12,10 @@ export function registerCommentTools(server: McpServer, client: BetterNewsClient 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 }) => { + handleTool(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) }] }; - }, + return JSON.stringify(data, null, 2); + }), ); server.tool( @@ -24,10 +25,10 @@ export function registerCommentTools(server: McpServer, client: BetterNewsClient newsItemId: z.string().uuid().describe("News item UUID"), content: z.string().min(1).max(2000).describe("Comment text"), }, - async ({ newsItemId, content }) => { + handleTool(async ({ newsItemId, content }) => { const data = await client.post(`/api/news/${newsItemId}/comments`, { content }); - return { content: [{ type: "text", text: JSON.stringify(data, null, 2) }] }; - }, + return JSON.stringify(data, null, 2); + }), ); server.tool( @@ -37,9 +38,9 @@ export function registerCommentTools(server: McpServer, client: BetterNewsClient newsItemId: z.string().uuid().describe("News item UUID"), commentId: z.string().uuid().describe("Comment UUID"), }, - async ({ newsItemId, commentId }) => { + handleTool(async ({ newsItemId, commentId }) => { const data = await client.delete(`/api/news/${newsItemId}/comments/${commentId}`); - return { content: [{ type: "text", text: JSON.stringify(data, null, 2) }] }; - }, + return JSON.stringify(data, null, 2); + }), ); } diff --git a/src/tools/legal.ts b/src/tools/legal.ts index b50702f..09d5864 100644 --- a/src/tools/legal.ts +++ b/src/tools/legal.ts @@ -1,26 +1,27 @@ 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 registerLegalTools(server: McpServer, client: BetterNewsClient) { server.tool( "get_terms_of_service", "Get the current published Terms of Service document.", {}, - async () => { + handleTool(async () => { const data = await client.get("/api/legal/current/terms_of_service"); - return { content: [{ type: "text", text: JSON.stringify(data, null, 2) }] }; - }, + return JSON.stringify(data, null, 2); + }), ); server.tool( "get_privacy_policy", "Get the current published Privacy Policy document.", {}, - async () => { + handleTool(async () => { const data = await client.get("/api/legal/current/privacy_policy"); - return { content: [{ type: "text", text: JSON.stringify(data, null, 2) }] }; - }, + return JSON.stringify(data, null, 2); + }), ); server.tool( @@ -29,22 +30,22 @@ export function registerLegalTools(server: McpServer, client: BetterNewsClient) { 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(), + 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) => { + handleTool(async (args) => { const data = await client.get("/api/legal", args); - return { content: [{ type: "text", text: JSON.stringify(data, null, 2) }] }; - }, + return 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 () => { + handleTool(async () => { const data = await client.post("/api/legal/accept"); - return { content: [{ type: "text", text: JSON.stringify(data, null, 2) }] }; - }, + return JSON.stringify(data, null, 2); + }), ); } diff --git a/src/tools/moderation.ts b/src/tools/moderation.ts index f129997..043301d 100644 --- a/src/tools/moderation.ts +++ b/src/tools/moderation.ts @@ -1,6 +1,7 @@ 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 registerModerationTools(server: McpServer, client: BetterNewsClient) { server.tool( @@ -10,20 +11,20 @@ export function registerModerationTools(server: McpServer, client: BetterNewsCli 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) => { + handleTool(async (args) => { const data = await client.get("/api/mod/queue", args); - return { content: [{ type: "text", text: JSON.stringify(data, null, 2) }] }; - }, + return JSON.stringify(data, null, 2); + }), ); server.tool( "get_mod_stats", "Get aggregate moderation statistics. Requires Mod/Admin role and mod:stats scope.", {}, - async () => { + handleTool(async () => { const data = await client.get("/api/mod/stats"); - return { content: [{ type: "text", text: JSON.stringify(data, null, 2) }] }; - }, + return JSON.stringify(data, null, 2); + }), ); server.tool( @@ -36,10 +37,10 @@ export function registerModerationTools(server: McpServer, client: BetterNewsCli role: z.enum(["User", "Moderator", "Admin"]).optional().describe("Filter by role"), banned: z.boolean().optional().describe("Filter by ban status"), }, - async (args) => { + handleTool(async (args) => { const data = await client.get("/api/mod/users", args); - return { content: [{ type: "text", text: JSON.stringify(data, null, 2) }] }; - }, + return JSON.stringify(data, null, 2); + }), ); server.tool( @@ -48,10 +49,10 @@ export function registerModerationTools(server: McpServer, client: BetterNewsCli { userId: z.string().uuid().describe("User UUID"), }, - async ({ userId }) => { + handleTool(async ({ userId }) => { const data = await client.get(`/api/mod/users/${userId}`); - return { content: [{ type: "text", text: JSON.stringify(data, null, 2) }] }; - }, + return JSON.stringify(data, null, 2); + }), ); server.tool( @@ -61,10 +62,10 @@ export function registerModerationTools(server: McpServer, client: BetterNewsCli userId: z.string().uuid().describe("User UUID"), role: z.enum(["User", "Moderator", "Admin"]).describe("New role"), }, - async ({ userId, role }) => { + handleTool(async ({ userId, role }) => { const data = await client.patch(`/api/mod/users/${userId}/role`, { role }); - return { content: [{ type: "text", text: JSON.stringify(data, null, 2) }] }; - }, + return JSON.stringify(data, null, 2); + }), ); server.tool( @@ -76,10 +77,10 @@ export function registerModerationTools(server: McpServer, client: BetterNewsCli 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 }) => { + handleTool(async ({ userId, ...body }) => { const data = await client.post(`/api/mod/users/${userId}/ban`, body); - return { content: [{ type: "text", text: JSON.stringify(data, null, 2) }] }; - }, + return JSON.stringify(data, null, 2); + }), ); server.tool( @@ -88,23 +89,52 @@ export function registerModerationTools(server: McpServer, client: BetterNewsCli { 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) }] }; + handleTool(async ({ userId }) => { + const data = await client.delete(`/api/mod/users/${userId}/ban`); + return JSON.stringify(data, null, 2); + }), + ); + + server.tool( + "shadowban_user", + "Toggle shadowban on a user. Shadowbanned users can post but their content is invisible to others. Requires Admin role and mod:users scope.", + { + userId: z.string().uuid().describe("User UUID"), + shadowbanned: z.boolean().describe("True to shadowban, false to remove shadowban"), }, + handleTool(async ({ userId, shadowbanned }) => { + const data = await client.patch(`/api/mod/users/${userId}/shadowban`, { shadowbanned }); + return JSON.stringify(data, null, 2); + }), + ); + + server.tool( + "create_mod_user", + "Create a new user account directly (bypasses email verification). Requires Admin role and mod:users scope.", + { + email: z.string().email().describe("User email address"), + username: z.string().min(3).max(30).describe("Username"), + displayName: z.string().min(1).max(25).describe("Display name"), + password: z.string().min(8).describe("Initial password"), + role: z.enum(["User", "Moderator", "Admin"]).optional().describe("Role (default: User)"), + }, + handleTool(async (body) => { + const data = await client.post("/api/mod/users", body); + return 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(), + 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(["open", "in_progress", "resolved", "closed"]).optional().describe("Filter by status"), }, - async (args) => { + handleTool(async (args) => { const data = await client.get("/api/mod/tickets", args); - return { content: [{ type: "text", text: JSON.stringify(data, null, 2) }] }; - }, + return JSON.stringify(data, null, 2); + }), ); } diff --git a/src/tools/news.ts b/src/tools/news.ts index cdf056c..772ea5a 100644 --- a/src/tools/news.ts +++ b/src/tools/news.ts @@ -1,16 +1,17 @@ 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 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 () => { + handleTool(async () => { const data = await client.get("/api/news/top"); - return { content: [{ type: "text", text: JSON.stringify(data, null, 2) }] }; - }, + return JSON.stringify(data, null, 2); + }), ); server.tool( @@ -28,10 +29,10 @@ export function registerNewsTools(server: McpServer, client: BetterNewsClient) { .describe("Filter by status (Mod/Admin only for non-published)"), search: z.string().optional().describe("Text search over title and summary"), }, - async (args) => { + handleTool(async (args) => { const data = await client.get("/api/news", args); - return { content: [{ type: "text", text: JSON.stringify(data, null, 2) }] }; - }, + return JSON.stringify(data, null, 2); + }), ); server.tool( @@ -40,10 +41,10 @@ export function registerNewsTools(server: McpServer, client: BetterNewsClient) { { id: z.string().uuid().describe("News item UUID"), }, - async ({ id }) => { + handleTool(async ({ id }) => { const data = await client.get(`/api/news/${id}`); - return { content: [{ type: "text", text: JSON.stringify(data, null, 2) }] }; - }, + return JSON.stringify(data, null, 2); + }), ); server.tool( @@ -53,10 +54,10 @@ export function registerNewsTools(server: McpServer, client: BetterNewsClient) { q: z.string().describe("Search query"), limit: z.number().int().min(1).max(20).optional().describe("Max results (default 10)"), }, - async ({ q, limit }) => { + handleTool(async ({ q, limit }) => { const data = await client.get("/api/news/search", { q, limit }); - return { content: [{ type: "text", text: JSON.stringify(data, null, 2) }] }; - }, + return JSON.stringify(data, null, 2); + }), ); server.tool( @@ -66,10 +67,22 @@ export function registerNewsTools(server: McpServer, client: BetterNewsClient) { 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 }) => { + handleTool(async ({ id, limit }) => { const data = await client.get(`/api/news/${id}/similar`, { limit }); - return { content: [{ type: "text", text: JSON.stringify(data, null, 2) }] }; + return JSON.stringify(data, null, 2); + }), + ); + + server.tool( + "check_duplicates", + "Check a draft news item for near-duplicate published or pending articles using vector similarity. Call this before submitting a draft for review. Requires news:moderate scope.", + { + id: z.string().uuid().describe("News item UUID to check"), }, + handleTool(async ({ id }) => { + const data = await client.get(`/api/news/${id}/check-duplicates`); + return JSON.stringify(data, null, 2); + }), ); server.tool( @@ -91,10 +104,10 @@ export function registerNewsTools(server: McpServer, client: BetterNewsClient) { imageCredits: z.string().optional().describe("Image attribution"), datePublished: z.string().datetime().optional().describe("Original publication date (ISO 8601)"), }, - async (args) => { + handleTool(async (args) => { const data = await client.post("/api/news", args); - return { content: [{ type: "text", text: JSON.stringify(data, null, 2) }] }; - }, + return JSON.stringify(data, null, 2); + }), ); server.tool( @@ -103,10 +116,10 @@ export function registerNewsTools(server: McpServer, client: BetterNewsClient) { { id: z.string().uuid().describe("News item UUID"), }, - async ({ id }) => { + handleTool(async ({ id }) => { const data = await client.post(`/api/news/${id}/submit`); - return { content: [{ type: "text", text: JSON.stringify(data, null, 2) }] }; - }, + return JSON.stringify(data, null, 2); + }), ); server.tool( @@ -114,25 +127,25 @@ export function registerNewsTools(server: McpServer, client: BetterNewsClient) { "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(), + title: z.string().min(1).max(200).optional().describe("Article title"), + readMoreUrl: z.string().url().optional().describe("URL to the original article"), + sourceId: z.string().uuid().optional().describe("Source UUID"), + summary: z.string().max(1000).optional().describe("Short summary"), + longDescription: z.string().optional().describe("Detailed description"), + authorsThought: z.string().max(500).optional().describe("Author commentary"), + category: z.string().optional().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().nullable().optional().describe("Hero image URL"), + imageAltText: z.string().nullable().optional().describe("Image alt text"), + imageCredits: z.string().nullable().optional().describe("Image attribution"), + datePublished: z.string().datetime().nullable().optional().describe("Original publication date (ISO 8601)"), }, - async ({ id, ...body }) => { + handleTool(async ({ id, ...body }) => { const data = await client.patch(`/api/news/${id}`, body); - return { content: [{ type: "text", text: JSON.stringify(data, null, 2) }] }; - }, + return JSON.stringify(data, null, 2); + }), ); server.tool( @@ -143,10 +156,10 @@ export function registerNewsTools(server: McpServer, client: BetterNewsClient) { action: z.enum(["approve", "reject", "needs_revision"]).describe("Review decision"), comment: z.string().optional().describe("Optional feedback comment"), }, - async ({ id, ...body }) => { + handleTool(async ({ id, ...body }) => { const data = await client.post(`/api/news/${id}/review`, body); - return { content: [{ type: "text", text: JSON.stringify(data, null, 2) }] }; - }, + return JSON.stringify(data, null, 2); + }), ); server.tool( @@ -155,10 +168,10 @@ export function registerNewsTools(server: McpServer, client: BetterNewsClient) { { id: z.string().uuid().describe("News item UUID"), }, - async ({ id }) => { + handleTool(async ({ id }) => { const data = await client.post(`/api/news/${id}/like`); - return { content: [{ type: "text", text: JSON.stringify(data, null, 2) }] }; - }, + return JSON.stringify(data, null, 2); + }), ); server.tool( @@ -167,10 +180,10 @@ export function registerNewsTools(server: McpServer, client: BetterNewsClient) { { id: z.string().uuid().describe("News item UUID"), }, - async ({ id }) => { + handleTool(async ({ id }) => { const data = await client.post(`/api/news/${id}/dislike`); - return { content: [{ type: "text", text: JSON.stringify(data, null, 2) }] }; - }, + return JSON.stringify(data, null, 2); + }), ); server.tool( @@ -179,10 +192,10 @@ export function registerNewsTools(server: McpServer, client: BetterNewsClient) { { id: z.string().uuid().describe("News item UUID"), }, - async ({ id }) => { + handleTool(async ({ id }) => { const data = await client.post(`/api/news/${id}/bookmark`); - return { content: [{ type: "text", text: JSON.stringify(data, null, 2) }] }; - }, + return JSON.stringify(data, null, 2); + }), ); server.tool( @@ -191,21 +204,21 @@ export function registerNewsTools(server: McpServer, client: BetterNewsClient) { { id: z.string().uuid().describe("News item UUID"), }, - async ({ id }) => { + handleTool(async ({ id }) => { const data = await client.delete(`/api/news/${id}`); - return { content: [{ type: "text", text: JSON.stringify(data, null, 2) }] }; - }, + return JSON.stringify(data, null, 2); + }), ); server.tool( "get_news_item_stats", - "Get engagement statistics for a news item. Requires Mod/Admin role.", + "Get engagement statistics for a news item. Accessible by the item's own author or Mod/Admin. Requires news:read scope.", { id: z.string().uuid().describe("News item UUID"), }, - async ({ id }) => { + handleTool(async ({ id }) => { const data = await client.get(`/api/news/${id}/stats`); - return { content: [{ type: "text", text: JSON.stringify(data, null, 2) }] }; - }, + return JSON.stringify(data, null, 2); + }), ); } diff --git a/src/tools/sources.ts b/src/tools/sources.ts index 8457214..7c2aed7 100644 --- a/src/tools/sources.ts +++ b/src/tools/sources.ts @@ -1,6 +1,7 @@ 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 registerSourceTools(server: McpServer, client: BetterNewsClient) { server.tool( @@ -12,10 +13,10 @@ export function registerSourceTools(server: McpServer, client: BetterNewsClient) search: z.string().optional().describe("Text search over source name"), type: z.string().optional().describe("Filter by source type"), }, - async (args) => { + handleTool(async (args) => { const data = await client.get("/api/sources", args); - return { content: [{ type: "text", text: JSON.stringify(data, null, 2) }] }; - }, + return JSON.stringify(data, null, 2); + }), ); server.tool( @@ -24,10 +25,10 @@ export function registerSourceTools(server: McpServer, client: BetterNewsClient) { id: z.string().uuid().describe("Source UUID"), }, - async ({ id }) => { + handleTool(async ({ id }) => { const data = await client.get(`/api/sources/${id}`); - return { content: [{ type: "text", text: JSON.stringify(data, null, 2) }] }; - }, + return JSON.stringify(data, null, 2); + }), ); server.tool( @@ -44,10 +45,10 @@ export function registerSourceTools(server: McpServer, client: BetterNewsClient) contactEmail: z.string().email().optional().describe("Contact email"), logoUrl: z.string().url().optional().describe("Logo image URL"), }, - async (args) => { + handleTool(async (args) => { const data = await client.post("/api/sources", args); - return { content: [{ type: "text", text: JSON.stringify(data, null, 2) }] }; - }, + return JSON.stringify(data, null, 2); + }), ); server.tool( @@ -55,21 +56,21 @@ export function registerSourceTools(server: McpServer, client: BetterNewsClient) "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(), + name: z.string().min(1).max(100).optional().describe("Source name"), + type: z.string().optional().describe("Source type (e.g. newspaper, blog, wire)"), + url: z.string().url().nullable().optional().describe("Source homepage URL"), + description: z.string().nullable().optional().describe("Short description"), + credibilityScore: z.number().min(0).max(100).nullable().optional().describe("Credibility score 0–100"), + country: z.string().nullable().optional().describe("ISO 3166-1 alpha-2 country code"), + language: z.string().nullable().optional().describe("ISO 639-1 language code"), + contactEmail: z.string().email().nullable().optional().describe("Contact email"), + logoUrl: z.string().url().nullable().optional().describe("Logo image URL"), hidden: z.boolean().optional().describe("Hide/unhide this source"), }, - async ({ id, ...body }) => { + handleTool(async ({ id, ...body }) => { const data = await client.patch(`/api/sources/${id}`, body); - return { content: [{ type: "text", text: JSON.stringify(data, null, 2) }] }; - }, + return JSON.stringify(data, null, 2); + }), ); server.tool( @@ -78,10 +79,10 @@ export function registerSourceTools(server: McpServer, client: BetterNewsClient) { newsItemId: z.string().uuid().describe("News item UUID"), }, - async ({ newsItemId }) => { + handleTool(async ({ newsItemId }) => { const data = await client.get(`/api/news/${newsItemId}/sources`); - return { content: [{ type: "text", text: JSON.stringify(data, null, 2) }] }; - }, + return JSON.stringify(data, null, 2); + }), ); server.tool( @@ -93,10 +94,10 @@ export function registerSourceTools(server: McpServer, client: BetterNewsClient) title: z.string().optional().describe("Optional title for the source link"), description: z.string().optional().describe("Optional description"), }, - async ({ newsItemId, ...body }) => { + handleTool(async ({ newsItemId, ...body }) => { const data = await client.post(`/api/news/${newsItemId}/sources`, body); - return { content: [{ type: "text", text: JSON.stringify(data, null, 2) }] }; - }, + return JSON.stringify(data, null, 2); + }), ); server.tool( @@ -108,12 +109,12 @@ export function registerSourceTools(server: McpServer, client: BetterNewsClient) action: z.enum(["approve", "reject"]).describe("Review decision"), comment: z.string().optional().describe("Optional feedback"), }, - async ({ newsItemId, submissionId, ...body }) => { + handleTool(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) }] }; - }, + return JSON.stringify(data, null, 2); + }), ); } diff --git a/src/tools/tickets.ts b/src/tools/tickets.ts index 4e72475..8c62bb0 100644 --- a/src/tools/tickets.ts +++ b/src/tools/tickets.ts @@ -1,20 +1,21 @@ 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 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(), + 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)"), + status: z.enum(["open", "in_progress", "resolved", "closed"]).optional().describe("Filter by status"), }, - async (args) => { + handleTool(async (args) => { const data = await client.get("/api/tickets", args); - return { content: [{ type: "text", text: JSON.stringify(data, null, 2) }] }; - }, + return JSON.stringify(data, null, 2); + }), ); server.tool( @@ -23,10 +24,10 @@ export function registerTicketTools(server: McpServer, client: BetterNewsClient) { id: z.string().uuid().describe("Ticket UUID"), }, - async ({ id }) => { + handleTool(async ({ id }) => { const data = await client.get(`/api/tickets/${id}`); - return { content: [{ type: "text", text: JSON.stringify(data, null, 2) }] }; - }, + return JSON.stringify(data, null, 2); + }), ); server.tool( @@ -37,10 +38,10 @@ export function registerTicketTools(server: McpServer, client: BetterNewsClient) 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) => { + handleTool(async (body) => { const data = await client.post("/api/tickets", body); - return { content: [{ type: "text", text: JSON.stringify(data, null, 2) }] }; - }, + return JSON.stringify(data, null, 2); + }), ); server.tool( @@ -50,10 +51,10 @@ export function registerTicketTools(server: McpServer, client: BetterNewsClient) id: z.string().uuid().describe("Ticket UUID"), content: z.string().min(1).max(5000).describe("Reply text"), }, - async ({ id, content }) => { + handleTool(async ({ id, content }) => { const data = await client.post(`/api/tickets/${id}/reply`, { content }); - return { content: [{ type: "text", text: JSON.stringify(data, null, 2) }] }; - }, + return JSON.stringify(data, null, 2); + }), ); server.tool( @@ -63,9 +64,9 @@ export function registerTicketTools(server: McpServer, client: BetterNewsClient) id: z.string().uuid().describe("Ticket UUID"), status: z.enum(["open", "in_progress", "resolved", "closed"]).describe("New status"), }, - async ({ id, status }) => { + handleTool(async ({ id, status }) => { const data = await client.patch(`/api/tickets/${id}/status`, { status }); - return { content: [{ type: "text", text: JSON.stringify(data, null, 2) }] }; - }, + return JSON.stringify(data, null, 2); + }), ); } diff --git a/src/tools/user.ts b/src/tools/user.ts index 2f97dda..54a26c1 100644 --- a/src/tools/user.ts +++ b/src/tools/user.ts @@ -1,16 +1,17 @@ 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 registerUserTools(server: McpServer, client: BetterNewsClient) { server.tool( "get_current_user", "Get the authenticated user's profile and account details. Requires user:profile scope.", {}, - async () => { + handleTool(async () => { const data = await client.get("/api/user/whoami"); - return { content: [{ type: "text", text: JSON.stringify(data, null, 2) }] }; - }, + return JSON.stringify(data, null, 2); + }), ); server.tool( @@ -19,10 +20,10 @@ export function registerUserTools(server: McpServer, client: BetterNewsClient) { { 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) }] }; - }, + handleTool(async ({ username }) => { + const data = await client.get(`/api/users/${username}`); + return JSON.stringify(data, null, 2); + }), ); server.tool( @@ -36,20 +37,20 @@ export function registerUserTools(server: McpServer, client: BetterNewsClient) { .optional() .describe("Filter by status"), }, - async (args) => { + handleTool(async (args) => { const data = await client.get("/api/user/news", args); - return { content: [{ type: "text", text: JSON.stringify(data, null, 2) }] }; - }, + return JSON.stringify(data, null, 2); + }), ); server.tool( "list_my_source_submissions", "List the authenticated user's own source submissions. Requires user:profile scope.", {}, - async () => { + handleTool(async () => { const data = await client.get("/api/user/source-submissions"); - return { content: [{ type: "text", text: JSON.stringify(data, null, 2) }] }; - }, + return JSON.stringify(data, null, 2); + }), ); server.tool( @@ -59,10 +60,10 @@ export function registerUserTools(server: McpServer, client: BetterNewsClient) { 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) => { + handleTool(async (args) => { const data = await client.get("/api/user/bookmarks", args); - return { content: [{ type: "text", text: JSON.stringify(data, null, 2) }] }; - }, + return JSON.stringify(data, null, 2); + }), ); server.tool( @@ -75,12 +76,12 @@ export function registerUserTools(server: McpServer, client: BetterNewsClient) { 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(), + emailNewsUpdates: z.boolean().optional().describe("Opt in/out of news update emails"), + emailCommentEvents: z.boolean().optional().describe("Opt in/out of comment notification emails"), }, - async (body) => { + handleTool(async (body) => { const data = await client.patch("/api/user/profile", body); - return { content: [{ type: "text", text: JSON.stringify(data, null, 2) }] }; - }, + return JSON.stringify(data, null, 2); + }), ); } diff --git a/src/utils.ts b/src/utils.ts new file mode 100644 index 0000000..a29a628 --- /dev/null +++ b/src/utils.ts @@ -0,0 +1,16 @@ +type TextContent = { type: "text"; text: string }; +type ToolResult = { content: TextContent[]; isError?: boolean }; + +export function handleTool( + fn: (args: T) => Promise, +): (args: T) => Promise { + return async (args: T) => { + try { + const text = await fn(args); + return { content: [{ type: "text", text }] }; + } catch (err) { + const text = err instanceof Error ? err.message : String(err); + return { isError: true, content: [{ type: "text", text }] }; + } + }; +}