UNPKG

featurebase-mcp

Version:

MCP server for Featurebase API - manage posts and comments

642 lines 29.1 kB
#!/usr/bin/env node import { Server } from "@modelcontextprotocol/sdk/server/index.js"; import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; import { CallToolRequestSchema, ErrorCode, ListToolsRequestSchema, McpError, } from "@modelcontextprotocol/sdk/types.js"; import axios from "axios"; import mask from "json-mask"; const commentModel = "upvoted, downvoted, inReview, isSpam, pinned, emailSent, sendNotification, organization, submission, author, authorId, authorPicture, isPrivate, isDeleted, confidenceScore, content, upvotes, downvotes, score, parentComment, path, createdAt, updatedAt, id"; class FeaturebaseAPI { client; orgUrl; constructor(config) { this.client = axios.create({ baseURL: config.baseUrl || "https://do.featurebase.app/v2", headers: { "X-API-Key": config.apiKey, "Content-Type": "application/json", }, }); this.orgUrl = config.orgUrl || ""; } // Posts endpoints async listPosts(params) { const response = await this.client.get("/posts", { params }); return response.data; } async createPost(data) { const response = await this.client.post("/posts", data); return response.data; } async updatePost(data) { const response = await this.client.patch("/posts", data); return response.data; } async deletePost(id) { const response = await this.client.delete("/posts", { data: { id } }); return response.data; } async getPostUpvoters(submissionId, page = 1, limit = 10) { const response = await this.client.get("/posts/upvoters", { params: { submissionId, page, limit }, }); return response.data; } async addUpvoter(id, email, name) { const response = await this.client.post("/posts/upvoters", { id, email, name, }); return response.data; } async resolvePostSlug(slug) { if (!this.orgUrl) { throw new Error("FEATUREBASE_ORG_URL environment variable is required for slug resolution"); } const response = await axios.get(`${this.orgUrl}/api/v1/submission`, { params: { slug }, }); return response.data; } async getSimilarSubmissions(query, locale = "en") { if (!this.orgUrl) { throw new Error("FEATUREBASE_ORG_URL environment variable is required for similar submissions"); } const response = await axios.get(`${this.orgUrl}/api/v1/submission/getSimilarSubmissions`, { params: { query, locale }, }); return response.data; } // Comments endpoints async getComments(params) { const response = await this.client.get("/comment", { params }); return response.data; } async createComment(data) { const response = await this.client.post("/comment", data); return response.data; } async updateComment(data) { const response = await this.client.patch("/comment", data); return response.data; } async deleteComment(id) { const response = await this.client.delete("/comment", { data: { id } }); return response.data; } } class FeaturebaseMCPServer { server; api; constructor() { const apiKey = process.env.FEATUREBASE_API_KEY; if (!apiKey) { throw new Error("FEATUREBASE_API_KEY environment variable is required"); } const baseUrl = process.env.FEATUREBASE_BASE_URL; const orgUrl = process.env.FEATUREBASE_ORG_URL; this.api = new FeaturebaseAPI({ apiKey, baseUrl, orgUrl }); this.server = new Server({ name: "featurebase-mcp", version: "1.0.0", }, { capabilities: { tools: {}, }, }); this.setupHandlers(); } setupHandlers() { this.server.setRequestHandler(ListToolsRequestSchema, async () => ({ tools: [ // Posts tools { name: "list_posts", description: "List posts with optional filtering. Available fields: id, title, content, author, authorId, authorPicture, commentsAllowed, organization, upvotes, upvoted, postCategory(category,private,prefill,roles,hiddenFromRoles,id), postTags(name,color,private,id), postStatus(name,color,type,isDefault,id), date, lastModified, comments, isSubscribed, inReview, lastDraggedTimestamps", inputSchema: { type: "object", properties: { id: { type: "string", description: "Find submission by its id" }, q: { type: "string", description: "Search for posts by title or content", }, category: { type: "array", items: { type: "string" }, description: "Filter posts by category (board) names", }, status: { type: "array", items: { type: "string" }, description: "Filter posts by status ids", }, sortBy: { type: "string", description: 'Sort posts (e.g., "date:desc" or "upvotes:desc")', }, startDate: { type: "string", description: "Get posts created after this date", }, endDate: { type: "string", description: "Get posts created before this date", }, limit: { type: "number", description: "Number of results per page", }, page: { type: "number", description: "Page number" }, select: { type: "string", description: 'Fields to return. Examples: "id,title,upvotes" | "title,author(name)" | "postCategory(category),postStatus(name)". Leave empty for all fields.', }, }, required: [], }, }, { name: "create_post", description: "Create a new post", inputSchema: { type: "object", properties: { title: { type: "string", description: "Post title (min 2 characters)", }, category: { type: "string", description: "The board (category) for the post", }, content: { type: "string", description: "Post content (can be empty)", }, email: { type: "string", description: "Email of the user submitting", }, authorName: { type: "string", description: "Name for new user if email not found", }, tags: { type: "array", items: { type: "string" }, description: "Array of tag names", }, commentsAllowed: { type: "boolean", description: "Allow comments on post", }, status: { type: "string", description: "Post status" }, date: { type: "string", description: "Post creation date" }, customInputValues: { type: "object", description: "Custom field values", }, }, required: ["title", "category"], }, }, { name: "update_post", description: "Update an existing post", inputSchema: { type: "object", properties: { id: { type: "string", description: "Post ID to update" }, title: { type: "string", description: "New title" }, content: { type: "string", description: "New content" }, status: { type: "string", description: "New status" }, commentsAllowed: { type: "boolean", description: "Allow comments", }, category: { type: "string", description: "New category" }, sendStatusUpdateEmail: { type: "boolean", description: "Send status update email to upvoters", }, tags: { type: "array", items: { type: "string" }, description: "New tags", }, inReview: { type: "boolean", description: "Put post in review" }, date: { type: "string", description: "Post creation date" }, customInputValues: { type: "object", description: "Custom field values", }, }, required: ["id"], }, }, { name: "delete_post", description: "Delete a post permanently", inputSchema: { type: "object", properties: { id: { type: "string", description: "Post ID to delete" }, }, required: ["id"], }, }, { name: "get_post_upvoters", description: "Get list of users who upvoted a post", inputSchema: { type: "object", properties: { submissionId: { type: "string", description: "Post ID" }, page: { type: "number", description: "Page number (default: 1)" }, limit: { type: "number", description: "Results per page (default: 10, max: 100)", }, }, required: ["submissionId"], }, }, { name: "add_upvoter", description: "Add an upvoter to a post", inputSchema: { type: "object", properties: { id: { type: "string", description: "Post ID" }, email: { type: "string", description: "Upvoter email" }, name: { type: "string", description: "Upvoter name" }, }, required: ["id", "email", "name"], }, }, // Comments tools { name: "get_comments", description: `Get comments for a post or changelog. Available fields: ${commentModel}, replies(${commentModel})`, inputSchema: { type: "object", properties: { submissionId: { type: "string", description: "Post ID or slug (required if no changelogId)", }, changelogId: { type: "string", description: "Changelog ID or slug (required if no submissionId)", }, privacy: { type: "string", enum: ["public", "private", "all"], description: "Filter by privacy setting", }, inReview: { type: "boolean", description: "Filter for comments in review", }, commentThreadId: { type: "string", description: "Get all comments in a thread", }, limit: { type: "number", description: "Results per page (default: 10)", }, page: { type: "number", description: "Page number (default: 1)" }, sortBy: { type: "string", enum: ["best", "top", "new", "old"], description: "Sort order (default: best)", }, select: { type: "string", description: 'Fields to return. Examples: "id,content,author(name)" | "content,upvotes,createdAt" | "author(name,email),replies(content)". Leave empty for all fields.', }, }, required: [], }, }, { name: "create_comment", description: "Create a new comment or reply", inputSchema: { type: "object", properties: { submissionId: { type: "string", description: "Post ID or slug (required if no changelogId)", }, changelogId: { type: "string", description: "Changelog ID or slug (required if no submissionId)", }, content: { type: "string", description: "Comment content" }, parentCommentId: { type: "string", description: "Parent comment ID for replies", }, isPrivate: { type: "boolean", description: "Make comment private (admins only)", }, sendNotification: { type: "boolean", description: "Notify voters (default: true)", }, createdAt: { type: "string", description: "Set creation date" }, author: { type: "object", properties: { name: { type: "string" }, email: { type: "string" }, profilePicture: { type: "string" }, }, description: "Post as specific user", }, }, required: ["content"], }, }, { name: "update_comment", description: "Update an existing comment", inputSchema: { type: "object", properties: { id: { type: "string", description: "Comment ID" }, content: { type: "string", description: "New content" }, isPrivate: { type: "boolean", description: "Make private (admins only)", }, pinned: { type: "boolean", description: "Pin comment to top" }, inReview: { type: "boolean", description: "Put comment in review", }, createdAt: { type: "string", description: "Update creation date", }, }, required: ["id"], }, }, { name: "delete_comment", description: "Delete a comment (soft delete if has replies)", inputSchema: { type: "object", properties: { id: { type: "string", description: "Comment ID to delete" }, }, required: ["id"], }, }, { name: "resolve_post_slug", description: "Convert a post slug to post ID and get post details", inputSchema: { type: "object", properties: { slug: { type: "string", description: "Post slug from URL (e.g., 'spacectl-stack-local-preview-target')" }, }, required: ["slug"], }, }, { name: "get_similar_submissions", description: "Find posts similar to the given query text", inputSchema: { type: "object", properties: { query: { type: "string", description: "Search query text to find similar submissions" }, locale: { type: "string", description: "Locale for search (default: 'en')" }, }, required: ["query"], }, }, ], })); this.server.setRequestHandler(CallToolRequestSchema, async (request) => { const { name, arguments: args } = request.params; try { switch (name) { // Posts handlers case "list_posts": { const { select, ...apiArgs } = args; const result = await this.api.listPosts(apiArgs); const filtered = select ? { ...result, results: result.results?.map((post) => mask(post, select)) || [], } : result; return { content: [ { type: "text", text: JSON.stringify(filtered), }, ], }; } case "create_post": { const result = await this.api.createPost(args); const filtered = { success: result.success, submission: { id: result.submission?.id, }, }; return { content: [ { type: "text", text: JSON.stringify(filtered), }, ], }; } case "update_post": { const result = await this.api.updatePost(args); return { content: [ { type: "text", text: JSON.stringify(result), }, ], }; } case "delete_post": { const { id } = args; const result = await this.api.deletePost(id); return { content: [ { type: "text", text: JSON.stringify(result), }, ], }; } case "get_post_upvoters": { const { submissionId, page = 1, limit = 10 } = args; const result = await this.api.getPostUpvoters(submissionId, page, limit); const filtered = { results: result.results?.map((upvoter) => ({ userId: upvoter.userId, organizationId: upvoter.organizationId, companies: upvoter.companies?.map((company) => ({ id: company.id, name: company.name, })), email: upvoter.email, name: upvoter.name, })) || [], page: result.page, limit: result.limit, totalPages: result.totalPages, totalResults: result.totalResults, }; return { content: [ { type: "text", text: JSON.stringify(filtered), }, ], }; } case "add_upvoter": { const { id, email, name } = args; const result = await this.api.addUpvoter(id, email, name); return { content: [ { type: "text", text: JSON.stringify(result), }, ], }; } // Comments handlers case "get_comments": { const { select, ...apiArgs } = args; const result = await this.api.getComments(apiArgs); const filtered = select ? { ...result, results: result.results?.map((comment) => mask(comment, select)) || [], } : result; return { content: [ { type: "text", text: JSON.stringify(filtered), }, ], }; } case "create_comment": { const result = await this.api.createComment(args); const filtered = { success: result.success, comment: { id: result.comment?.id, }, }; return { content: [ { type: "text", text: JSON.stringify(filtered), }, ], }; } case "update_comment": { const result = await this.api.updateComment(args); return { content: [ { type: "text", text: JSON.stringify(result), }, ], }; } case "delete_comment": { const { id } = args; const result = await this.api.deleteComment(id); return { content: [ { type: "text", text: JSON.stringify(result), }, ], }; } case "resolve_post_slug": { const { slug } = args; const result = await this.api.resolvePostSlug(slug); return { content: [ { type: "text", text: JSON.stringify(result), }, ], }; } case "get_similar_submissions": { const { query, locale = "en" } = args; const result = await this.api.getSimilarSubmissions(query, locale); return { content: [ { type: "text", text: JSON.stringify(result), }, ], }; } default: throw new McpError(ErrorCode.MethodNotFound, `Unknown tool: ${name}`); } } catch (error) { if (error.response) { throw new McpError(ErrorCode.InternalError, `Featurebase API error: ${error.response.status} - ${JSON.stringify(error.response.data)}`); } throw new McpError(ErrorCode.InternalError, error.message); } }); } async run() { const transport = new StdioServerTransport(); await this.server.connect(transport); console.error("Featurebase MCP server running on stdio"); } } const server = new FeaturebaseMCPServer(); server.run().catch(console.error); //# sourceMappingURL=index.js.map