feat: update version to 1.1.0 and add new tools for blog, API keys, and sessions
CI / Build & Test (push) Successful in 36s

This commit is contained in:
Space-Banane
2026-06-18 16:49:16 +02:00
parent 4b1ab53543
commit fa43346e51
11 changed files with 448 additions and 35 deletions
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "betternews-mcp",
"version": "1.0.0",
"version": "1.1.0",
"description": "MCP server for the BetterNews API",
"type": "module",
"main": "dist/index.js",
+7 -1
View File
@@ -9,6 +9,9 @@ import { registerUserTools } from "./tools/user.js";
import { registerModerationTools } from "./tools/moderation.js";
import { registerTicketTools } from "./tools/tickets.js";
import { registerLegalTools } from "./tools/legal.js";
import { registerBlogTools } from "./tools/blog.js";
import { registerApiKeyTools } from "./tools/api-keys.js";
import { registerSessionTools } from "./tools/sessions.js";
const apiKey = process.env.BETTERNEWS_API_KEY;
if (!apiKey) {
@@ -24,7 +27,7 @@ const client = new BetterNewsClient(apiKey, baseUrl);
const server = new McpServer({
name: "betternews",
version: "1.0.0",
version: "1.1.0",
});
registerNewsTools(server, client);
@@ -34,6 +37,9 @@ registerUserTools(server, client);
registerModerationTools(server, client);
registerTicketTools(server, client);
registerLegalTools(server, client);
registerBlogTools(server, client);
registerApiKeyTools(server, client);
registerSessionTools(server, client);
const transport = new StdioServerTransport();
await server.connect(transport);
+98
View File
@@ -0,0 +1,98 @@
import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { z } from "zod";
import type { BetterNewsClient } from "../client.js";
import { handleTool } from "../utils.js";
const SCOPES = [
"news:read",
"news:write",
"news:moderate",
"news:comment",
"sources:read",
"sources:write",
"bookmarks:write",
"tickets:read",
"tickets:write",
"tickets:moderate",
"mod:queue",
"mod:stats",
"mod:users",
"blog:write",
"legal:write",
"user:profile",
"user:apikeys",
"privacy:read",
"files:write",
] as const;
export function registerApiKeyTools(server: McpServer, client: BetterNewsClient) {
server.tool(
"list_api_keys",
"List API keys for the authenticated user. Requires user:apikeys scope.",
{},
handleTool(async () => {
const data = await client.get<unknown>("/api/user/api-keys");
return JSON.stringify(data, null, 2);
}),
);
server.tool(
"create_api_key",
"Create a new API key. The raw key value is returned only once. Requires cookie-based session (not another API key) and user:apikeys scope.",
{
name: z.string().min(1).max(50).describe("Human-readable name for this key"),
scopes: z
.array(z.enum(SCOPES))
.optional()
.describe("Permission scopes. Empty array = full access (inherits your role)."),
expiresAt: z
.string()
.datetime()
.nullable()
.optional()
.describe("Expiry date (ISO 8601). Null = never expires."),
},
handleTool(async (body) => {
const data = await client.post<unknown>("/api/user/api-keys", body);
return JSON.stringify(data, null, 2);
}),
);
server.tool(
"update_api_key",
"Update an API key's name, scopes, or expiry. Requires user:apikeys scope.",
{
keyId: z.string().uuid().describe("API key UUID"),
name: z.string().min(1).max(50).optional().describe("New name"),
scopes: z.array(z.enum(SCOPES)).optional().describe("New scopes list"),
expiresAt: z
.string()
.datetime()
.nullable()
.optional()
.describe("New expiry date (ISO 8601). Null = never expires."),
},
handleTool(async ({ keyId, ...body }) => {
const data = await client.patch<unknown>(`/api/user/api-keys/${keyId}`, body);
return JSON.stringify(data, null, 2);
}),
);
server.tool(
"revoke_api_key",
"Revoke or permanently delete an API key. Pass hardDelete=true to permanently remove it; otherwise it is soft-revoked and stays visible in the list. Requires user:apikeys scope.",
{
keyId: z.string().uuid().describe("API key UUID"),
hardDelete: z
.boolean()
.optional()
.describe("True to permanently delete; false (default) to soft-revoke"),
},
handleTool(async ({ keyId, hardDelete }) => {
const data = await client.delete<unknown>(
`/api/user/api-keys/${keyId}${hardDelete ? "?hardDelete=true" : ""}`,
);
return JSON.stringify(data, null, 2);
}),
);
}
+99
View File
@@ -0,0 +1,99 @@
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 registerBlogTools(server: McpServer, client: BetterNewsClient) {
server.tool(
"list_blog_posts",
"List blog posts. Public sees published only; Mod/Admin see all statuses. No auth required for published posts.",
{
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(["draft", "published"]).optional().describe("Filter by status (Mod/Admin only for draft)"),
},
handleTool(async (args) => {
const data = await client.get<unknown>("/api/blog", args);
return JSON.stringify(data, null, 2);
}),
);
server.tool(
"get_blog_post",
"Get a single blog post by slug. Published posts are publicly accessible; drafts require Mod/Admin role.",
{
slug: z.string().describe("Blog post slug"),
},
handleTool(async ({ slug }) => {
const data = await client.get<unknown>(`/api/blog/${slug}`);
return JSON.stringify(data, null, 2);
}),
);
server.tool(
"create_blog_post",
"Create a new blog post draft. Requires Mod/Admin role and blog:write scope.",
{
title: z.string().min(1).max(150).describe("Post title"),
content: z.string().min(1).describe("Post body (markdown supported)"),
excerpt: z.string().max(300).optional().describe("Short excerpt shown in listings"),
imageUrl: z.string().url().optional().describe("Hero image URL"),
},
handleTool(async (body) => {
const data = await client.post<unknown>("/api/blog", body);
return JSON.stringify(data, null, 2);
}),
);
server.tool(
"update_blog_post",
"Update a blog post. Requires Mod/Admin role and blog:write scope.",
{
id: z.string().uuid().describe("Blog post UUID"),
title: z.string().min(1).max(150).optional().describe("Post title"),
content: z.string().min(1).optional().describe("Post body"),
excerpt: z.string().max(300).nullable().optional().describe("Short excerpt"),
imageUrl: z.string().url().nullable().optional().describe("Hero image URL"),
},
handleTool(async ({ id, ...body }) => {
const data = await client.patch<unknown>(`/api/blog/${id}`, body);
return JSON.stringify(data, null, 2);
}),
);
server.tool(
"delete_blog_post",
"Delete a blog post. Requires Mod/Admin role and blog:write scope.",
{
id: z.string().uuid().describe("Blog post UUID"),
},
handleTool(async ({ id }) => {
const data = await client.delete<unknown>(`/api/blog/${id}`);
return JSON.stringify(data, null, 2);
}),
);
server.tool(
"publish_blog_post",
"Publish a draft blog post, making it publicly visible. Requires Mod/Admin role and blog:write scope.",
{
id: z.string().uuid().describe("Blog post UUID"),
},
handleTool(async ({ id }) => {
const data = await client.post<unknown>(`/api/blog/${id}/publish`);
return JSON.stringify(data, null, 2);
}),
);
server.tool(
"unpublish_blog_post",
"Take down a published blog post, reverting it to draft. Requires Mod/Admin role and blog:write scope.",
{
id: z.string().uuid().describe("Blog post UUID"),
},
handleTool(async ({ id }) => {
const data = await client.post<unknown>(`/api/blog/${id}/unpublish`);
return JSON.stringify(data, null, 2);
}),
);
}
+40
View File
@@ -43,4 +43,44 @@ export function registerCommentTools(server: McpServer, client: BetterNewsClient
return JSON.stringify(data, null, 2);
}),
);
server.tool(
"like_comment",
"Toggle a like on a comment. Requires news:comment scope.",
{
newsItemId: z.string().uuid().describe("News item UUID"),
commentId: z.string().uuid().describe("Comment UUID"),
},
handleTool(async ({ newsItemId, commentId }) => {
const data = await client.post<unknown>(`/api/news/${newsItemId}/comments/${commentId}/like`);
return JSON.stringify(data, null, 2);
}),
);
server.tool(
"dislike_comment",
"Toggle a dislike on a comment. Requires news:comment scope.",
{
newsItemId: z.string().uuid().describe("News item UUID"),
commentId: z.string().uuid().describe("Comment UUID"),
},
handleTool(async ({ newsItemId, commentId }) => {
const data = await client.post<unknown>(`/api/news/${newsItemId}/comments/${commentId}/dislike`);
return JSON.stringify(data, null, 2);
}),
);
server.tool(
"hide_comment",
"Hide or unhide a comment. Hidden comments are invisible to regular users but still visible to Mod/Admin. Requires Mod/Admin role and news:moderate scope.",
{
newsItemId: z.string().uuid().describe("News item UUID"),
commentId: z.string().uuid().describe("Comment UUID"),
hidden: z.boolean().describe("True to hide, false to unhide"),
},
handleTool(async ({ newsItemId, commentId, hidden }) => {
const data = await client.patch<unknown>(`/api/news/${newsItemId}/comments/${commentId}`, { hidden });
return JSON.stringify(data, null, 2);
}),
);
}
+18
View File
@@ -137,4 +137,22 @@ export function registerModerationTools(server: McpServer, client: BetterNewsCli
return JSON.stringify(data, null, 2);
}),
);
server.tool(
"list_mod_comments",
"List all comments across the platform with moderation details (like/dislike counts, hidden status). 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, max 50)"),
hidden: z.boolean().optional().describe("Filter by hidden status. Omit to see all."),
sort: z
.enum(["top", "most_disliked", "new", "oldest"])
.optional()
.describe("Sort order: top = most liked, most_disliked, new (default), oldest"),
},
handleTool(async (args) => {
const data = await client.get<unknown>("/api/mod/comments", args);
return JSON.stringify(data, null, 2);
}),
);
}
+115 -18
View File
@@ -19,7 +19,7 @@ export function registerNewsTools(server: McpServer, client: BetterNewsClient) {
"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)"),
limit: z.number().int().min(1).max(50).optional().describe("Items per page (default 20, max 50)"),
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"),
@@ -89,19 +89,19 @@ export function registerNewsTools(server: McpServer, client: BetterNewsClient) {
"create_news_item",
"Create a new draft news item. Requires news:write scope.",
{
title: z.string().min(1).max(200).describe("Article title"),
title: z.string().min(3).max(150).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"),
summary: z.string().max(200).optional().describe("Short summary of the article"),
longDescription: z.string().max(1000).optional().describe("Detailed description"),
authorsThought: z.string().max(250).optional().describe("Author commentary"),
category: z.string().min(1).max(100).describe("Category slug"),
tags: z.array(z.string().max(50)).max(3).optional().describe("Up to 3 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"),
imageAltText: z.string().max(200).optional().describe("Image alt text"),
imageCredits: z.string().max(200).optional().describe("Image attribution"),
datePublished: z.string().datetime().optional().describe("Original publication date (ISO 8601)"),
},
handleTool(async (args) => {
@@ -127,19 +127,19 @@ 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().describe("Article title"),
title: z.string().min(3).max(150).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"),
summary: z.string().max(200).optional().describe("Short summary"),
longDescription: z.string().max(1000).optional().describe("Detailed description"),
authorsThought: z.string().max(250).optional().describe("Author commentary"),
category: z.string().min(1).max(100).optional().describe("Category slug"),
tags: z.array(z.string().max(50)).max(3).optional().describe("Up to 3 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"),
imageUrl: z.string().nullable().optional().describe("Hero image URL"),
imageAltText: z.string().max(200).nullable().optional().describe("Image alt text"),
imageCredits: z.string().max(200).nullable().optional().describe("Image attribution"),
datePublished: z.string().datetime().nullable().optional().describe("Original publication date (ISO 8601)"),
},
handleTool(async ({ id, ...body }) => {
@@ -221,4 +221,101 @@ export function registerNewsTools(server: McpServer, client: BetterNewsClient) {
return JSON.stringify(data, null, 2);
}),
);
server.tool(
"list_topics",
"List all unique topic tags from published news items. Results are cached for 1 hour. No auth required.",
{},
handleTool(async () => {
const data = await client.get<unknown>("/api/news/topics");
return JSON.stringify(data, null, 2);
}),
);
server.tool(
"publish_news_item",
"Publish an approved news item, making it live on the feed. The item must be in the 'approved' state. Owner or Mod/Admin may call this. Requires news:moderate scope.",
{
id: z.string().uuid().describe("News item UUID"),
},
handleTool(async ({ id }) => {
const data = await client.post<unknown>(`/api/news/${id}/publish`);
return JSON.stringify(data, null, 2);
}),
);
server.tool(
"unpublish_news_item",
"Take down a published news item, archiving it. Owner or Mod/Admin may call this. Requires news:moderate scope.",
{
id: z.string().uuid().describe("News item UUID"),
},
handleTool(async ({ id }) => {
const data = await client.post<unknown>(`/api/news/${id}/unpublish`);
return JSON.stringify(data, null, 2);
}),
);
server.tool(
"republish_news_item",
"Re-publish an archived news item, putting it back on the live feed. Requires Mod/Admin role and news:moderate scope.",
{
id: z.string().uuid().describe("News item UUID"),
},
handleTool(async ({ id }) => {
const data = await client.post<unknown>(`/api/news/${id}/republish`);
return JSON.stringify(data, null, 2);
}),
);
server.tool(
"archive_news_item",
"Move a rejected news item to archived status so the owner can edit and resubmit it. Owner only. Requires news:moderate scope.",
{
id: z.string().uuid().describe("News item UUID"),
},
handleTool(async ({ id }) => {
const data = await client.post<unknown>(`/api/news/${id}/archive`);
return JSON.stringify(data, null, 2);
}),
);
server.tool(
"direct_publish_news_item",
"Publish a news item directly to the feed, bypassing the moderation queue. Any existing status is overridden. Requires Mod/Admin role and news:moderate scope.",
{
id: z.string().uuid().describe("News item UUID"),
},
handleTool(async ({ id }) => {
const data = await client.post<unknown>(`/api/news/${id}/direct-publish`);
return JSON.stringify(data, null, 2);
}),
);
server.tool(
"direct_create_publish_news_item",
"Create a news item and publish it immediately in one step, bypassing the moderation queue. Admin only. Requires news:moderate scope.",
{
title: z.string().min(3).max(150).describe("Article title"),
readMoreUrl: z.string().url().describe("URL to the original article"),
category: z.string().min(1).max(100).describe("Category slug"),
sourceId: z.string().uuid().optional().describe("UUID of the source"),
summary: z.string().max(200).optional().describe("Short summary"),
longDescription: z.string().max(1000).optional().describe("Detailed description"),
tags: z.array(z.string().max(50)).max(3).optional().describe("Up to 3 tag strings"),
imageUrl: z.string().url().optional().describe("Hero image URL"),
imageAltText: z.string().max(200).optional().describe("Image alt text"),
imageCredits: z.string().max(200).optional().describe("Image attribution"),
readTime: z.number().int().positive().optional().describe("Estimated read time in minutes"),
authorsThought: z.string().max(250).optional().describe("Author commentary"),
language: z.string().max(10).optional().describe("ISO 639-1 language code"),
country: z.string().max(10).optional().describe("ISO 3166-1 alpha-2 country code"),
datePublished: z.string().datetime().optional().describe("Original publication date (ISO 8601)"),
createdAt: z.string().datetime().optional().describe("Override creation timestamp (ISO 8601)"),
},
handleTool(async (body) => {
const data = await client.post<unknown>("/api/news/direct", body);
return JSON.stringify(data, null, 2);
}),
);
}
+38
View File
@@ -0,0 +1,38 @@
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 registerSessionTools(server: McpServer, client: BetterNewsClient) {
server.tool(
"list_sessions",
"List all active sessions for the authenticated user. The current session is flagged with `current: true`.",
{},
handleTool(async () => {
const data = await client.get<unknown>("/api/user/sessions");
return JSON.stringify(data, null, 2);
}),
);
server.tool(
"revoke_all_sessions",
"Revoke all sessions for the authenticated user, forcing re-login everywhere. The current session is also invalidated.",
{},
handleTool(async () => {
const data = await client.delete<unknown>("/api/user/sessions");
return JSON.stringify(data, null, 2);
}),
);
server.tool(
"revoke_session",
"Revoke a single session by ID. Use list_sessions to find session IDs.",
{
sessionId: z.string().uuid().describe("Session UUID to revoke"),
},
handleTool(async ({ sessionId }) => {
const data = await client.delete<unknown>(`/api/user/sessions/${sessionId}`);
return JSON.stringify(data, null, 2);
}),
);
}
+13 -13
View File
@@ -9,7 +9,7 @@ export function registerSourceTools(server: McpServer, client: BetterNewsClient)
"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)"),
limit: z.number().int().min(1).max(50).optional().describe("Items per page (default 20, max 50)"),
search: z.string().optional().describe("Text search over source name"),
type: z.string().optional().describe("Filter by source type"),
},
@@ -35,13 +35,13 @@ export function registerSourceTools(server: McpServer, client: BetterNewsClient)
"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)"),
name: z.string().min(1).max(200).describe("Source name"),
type: z.string().min(1).max(50).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 0100"),
country: z.string().optional().describe("ISO 3166-1 alpha-2 country code"),
language: z.string().optional().describe("ISO 639-1 language code"),
description: z.string().max(1000).optional().describe("Short description"),
credibilityScore: z.number().min(0).max(10).optional().describe("Credibility score 010"),
country: z.string().max(10).optional().describe("ISO 3166-1 alpha-2 country code"),
language: z.string().max(10).optional().describe("ISO 639-1 language code"),
contactEmail: z.string().email().optional().describe("Contact email"),
logoUrl: z.string().url().optional().describe("Logo image URL"),
},
@@ -56,13 +56,13 @@ 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().describe("Source name"),
type: z.string().optional().describe("Source type (e.g. newspaper, blog, wire)"),
name: z.string().min(1).max(200).optional().describe("Source name"),
type: z.string().min(1).max(50).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 0100"),
country: z.string().nullable().optional().describe("ISO 3166-1 alpha-2 country code"),
language: z.string().nullable().optional().describe("ISO 639-1 language code"),
description: z.string().max(1000).nullable().optional().describe("Short description"),
credibilityScore: z.number().min(0).max(10).nullable().optional().describe("Credibility score 010"),
country: z.string().max(10).nullable().optional().describe("ISO 3166-1 alpha-2 country code"),
language: z.string().max(10).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"),
+2 -2
View File
@@ -34,8 +34,8 @@ export function registerTicketTools(server: McpServer, client: BetterNewsClient)
"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"),
title: z.string().min(3).max(100).describe("Ticket title"),
description: z.string().min(10).max(5000).describe("Detailed description of the issue"),
priority: z.enum(["low", "medium", "high", "critical"]).describe("Ticket priority"),
},
handleTool(async (body) => {
+17
View File
@@ -84,4 +84,21 @@ export function registerUserTools(server: McpServer, client: BetterNewsClient) {
return JSON.stringify(data, null, 2);
}),
);
server.tool(
"update_privacy_settings",
"Update the authenticated user's privacy settings. Requires user:profile scope.",
{
profilePublic: z.boolean().optional().describe("Whether the profile is publicly visible"),
showEmail: z.boolean().optional().describe("Whether to show email address on public profile"),
autoPublishAfterReview: z
.boolean()
.optional()
.describe("Automatically publish news items as soon as they are approved by a moderator"),
},
handleTool(async (body) => {
const data = await client.patch<unknown>("/api/user/privacy", body);
return JSON.stringify(data, null, 2);
}),
);
}