esa MCP Server

by kajirita2002
Verified
#!/usr/bin/env node import { Server } from "@modelcontextprotocol/sdk/server/index.js"; import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; import { CallToolRequest, CallToolRequestSchema, ListToolsRequestSchema, Tool, } from "@modelcontextprotocol/sdk/types.js"; // Argument type definitions for esa tools interface ListPostsArgs { q?: string; include?: string; sort?: string; order?: string; per_page?: number; page?: number; } interface GetPostArgs { post_number: number; include?: string; } interface CreatePostArgs { name: string; body_md?: string; tags?: string[]; category?: string; wip?: boolean; message?: string; user?: string; template_post_id?: number; } interface UpdatePostArgs { post_number: number; name?: string; body_md?: string; tags?: string[]; category?: string; wip?: boolean; message?: string; created_by?: string; original_revision?: string; } interface ListCommentsArgs { post_number: number; page?: number; per_page?: number; } interface GetCommentArgs { comment_id: number; include?: string; } interface CreateCommentArgs { post_number: number; body_md: string; user?: string; } interface GetMembersArgs { page?: number; per_page?: number; } interface GetMemberArgs { screen_name_or_email: string; } // ツール定義 const listPostsTool: Tool = { name: "esa_list_posts", description: "Get a list of posts in the team (with pagination support)", inputSchema: { type: "object", properties: { q: { type: "string", description: "Search query (see esa API documentation for details)", }, include: { type: "string", description: "Related data to include in the response (e.g. 'comments,stargazers')", }, sort: { type: "string", description: "Sort method (updated, created, number, stars, watches, comments, best_match)", default: "updated", }, order: { type: "string", description: "Sort order (desc, asc)", default: "desc", }, per_page: { type: "number", description: "Number of results per page (default: 20, max: 100)", default: 20, }, page: { type: "number", description: "Page number to retrieve", default: 1, }, }, }, }; const getPostTool: Tool = { name: "esa_get_post", description: "Get detailed information about a specific post", inputSchema: { type: "object", properties: { post_number: { type: "number", description: "Post number to retrieve", }, include: { type: "string", description: "Related data to include in the response (e.g. 'comments,stargazers')", }, }, required: ["post_number"], }, }; const createPostTool: Tool = { name: "esa_create_post", description: "Create a new post", inputSchema: { type: "object", properties: { name: { type: "string", description: "Post title", }, body_md: { type: "string", description: "Post body (Markdown format)", }, tags: { type: "array", items: { type: "string" }, description: "List of tags for the post", }, category: { type: "string", description: "Post category", }, wip: { type: "boolean", description: "Whether to mark as WIP (Work In Progress)", default: true, }, message: { type: "string", description: "Change message", }, user: { type: "string", description: "Poster's screen_name (only team owners can specify)", }, template_post_id: { type: "number", description: "ID of the post to use as a template", }, }, required: ["name"], }, }; const updatePostTool: Tool = { name: "esa_update_post", description: "Update an existing post", inputSchema: { type: "object", properties: { post_number: { type: "number", description: "Post number to update", }, name: { type: "string", description: "New title for the post", }, body_md: { type: "string", description: "New body for the post (Markdown format)", }, tags: { type: "array", items: { type: "string" }, description: "New list of tags for the post", }, category: { type: "string", description: "New category for the post", }, wip: { type: "boolean", description: "Whether to mark as WIP (Work In Progress)", }, message: { type: "string", description: "Change message", }, created_by: { type: "string", description: "Poster's screen_name (only team owners can specify)", }, original_revision: { type: "string", description: "Revision to base the update on", }, }, required: ["post_number"], }, }; const listCommentsTool: Tool = { name: "esa_list_comments", description: "Get a list of comments for a post", inputSchema: { type: "object", properties: { post_number: { type: "number", description: "Post number to get comments for", }, page: { type: "number", description: "Page number to retrieve", default: 1, }, per_page: { type: "number", description: "Number of results per page (default: 20, max: 100)", default: 20, }, }, required: ["post_number"], }, }; const getCommentTool: Tool = { name: "esa_get_comment", description: "Get a specific comment", inputSchema: { type: "object", properties: { comment_id: { type: "number", description: "ID of the comment to retrieve", }, include: { type: "string", description: "レスポンスに含める関連データ (例: 'stargazers')", }, }, required: ["comment_id"], }, }; const createCommentTool: Tool = { name: "esa_create_comment", description: "Post a comment to an article", inputSchema: { type: "object", properties: { post_number: { type: "number", description: "Post number to comment on", }, body_md: { type: "string", description: "Comment body (Markdown format)", }, user: { type: "string", description: "Poster's screen_name (only team owners can specify)", }, }, required: ["post_number", "body_md"], }, }; const getMembersTool: Tool = { name: "esa_get_members", description: "Get a list of team members", inputSchema: { type: "object", properties: { page: { type: "number", description: "Page number to retrieve", default: 1, }, per_page: { type: "number", description: "Number of results per page (default: 20, max: 100)", default: 20, }, }, }, }; const getMemberTool: Tool = { name: "esa_get_member", description: "Get information about a specific team member", inputSchema: { type: "object", properties: { screen_name_or_email: { type: "string", description: "Screen name or email of the member to retrieve", }, }, required: ["screen_name_or_email"], }, }; class EsaClient { private baseUrl: string; private headers: { Authorization: string; "Content-Type": string }; constructor(accessToken: string, teamName: string) { this.baseUrl = `https://api.esa.io/v1/teams/${teamName}`; this.headers = { Authorization: `Bearer ${accessToken}`, "Content-Type": "application/json", }; } async listPosts(args: ListPostsArgs = {}): Promise<any> { const params = new URLSearchParams(); if (args.q) params.append("q", args.q); if (args.include) params.append("include", args.include); if (args.sort) params.append("sort", args.sort); if (args.order) params.append("order", args.order); if (args.per_page) params.append("per_page", args.per_page.toString()); if (args.page) params.append("page", args.page.toString()); const url = `${this.baseUrl}/posts${params.toString() ? `?${params}` : ""}`; const response = await fetch(url, { headers: this.headers }); return response.json(); } async getPost(post_number: number, include?: string): Promise<any> { const params = new URLSearchParams(); if (include) params.append("include", include); const url = `${this.baseUrl}/posts/${post_number}${params.toString() ? `?${params}` : ""}`; const response = await fetch(url, { headers: this.headers }); return response.json(); } async createPost(postData: Omit<CreatePostArgs, 'template_post_id'> & { template_post_id?: number }): Promise<any> { const url = `${this.baseUrl}/posts`; const response = await fetch(url, { method: "POST", headers: this.headers, body: JSON.stringify({ post: postData }), }); return response.json(); } async updatePost(post_number: number, postData: Omit<UpdatePostArgs, 'post_number'>): Promise<any> { const url = `${this.baseUrl}/posts/${post_number}`; const response = await fetch(url, { method: "PATCH", headers: this.headers, body: JSON.stringify({ post: postData }), }); return response.json(); } async listComments(post_number: number, page?: number, per_page?: number): Promise<any> { const params = new URLSearchParams(); if (page) params.append("page", page.toString()); if (per_page) params.append("per_page", per_page.toString()); const url = `${this.baseUrl}/posts/${post_number}/comments${params.toString() ? `?${params}` : ""}`; const response = await fetch(url, { headers: this.headers }); return response.json(); } async getComment(comment_id: number, include?: string): Promise<any> { const params = new URLSearchParams(); if (include) params.append("include", include); const url = `${this.baseUrl}/comments/${comment_id}${params.toString() ? `?${params}` : ""}`; const response = await fetch(url, { headers: this.headers }); return response.json(); } async createComment(post_number: number, body_md: string, user?: string): Promise<any> { const url = `${this.baseUrl}/posts/${post_number}/comments`; const commentData: { body_md: string; user?: string } = { body_md }; if (user) commentData.user = user; const response = await fetch(url, { method: "POST", headers: this.headers, body: JSON.stringify({ comment: commentData }), }); return response.json(); } async getMembers(page?: number, per_page?: number): Promise<any> { const params = new URLSearchParams(); if (page) params.append("page", page.toString()); if (per_page) params.append("per_page", per_page.toString()); const url = `${this.baseUrl}/members${params.toString() ? `?${params}` : ""}`; const response = await fetch(url, { headers: this.headers }); return response.json(); } async getMember(screen_name_or_email: string): Promise<any> { const url = `${this.baseUrl}/members/${screen_name_or_email}`; const response = await fetch(url, { headers: this.headers }); return response.json(); } } async function main() { const accessToken = process.env.ESA_ACCESS_TOKEN; const teamName = process.env.ESA_TEAM; if (!accessToken || !teamName) { console.error( "Please set the ESA_ACCESS_TOKEN and ESA_TEAM environment variables", ); process.exit(1); } console.error("Starting esa MCP Server..."); const server = new Server( { name: "esa MCP Server", version: "1.0.0", }, { capabilities: { tools: {}, }, }, ); const esaClient = new EsaClient(accessToken, teamName); server.setRequestHandler( CallToolRequestSchema, async (request: CallToolRequest) => { console.error("CallToolRequest received:", request); try { if (!request.params.arguments) { throw new Error("No arguments provided"); } switch (request.params.name) { case "esa_list_posts": { const args = request.params.arguments as unknown as ListPostsArgs; const response = await esaClient.listPosts(args); return { content: [{ type: "text", text: JSON.stringify(response) }], }; } case "esa_get_post": { const args = request.params.arguments as unknown as GetPostArgs; if (!args.post_number) { throw new Error("post_number is required"); } const response = await esaClient.getPost(args.post_number, args.include); return { content: [{ type: "text", text: JSON.stringify(response) }], }; } case "esa_create_post": { const args = request.params.arguments as unknown as CreatePostArgs; if (!args.name) { throw new Error("name is required"); } const response = await esaClient.createPost(args); return { content: [{ type: "text", text: JSON.stringify(response) }], }; } case "esa_update_post": { const args = request.params.arguments as unknown as UpdatePostArgs; if (!args.post_number) { throw new Error("post_number is required"); } const { post_number, ...postData } = args; const response = await esaClient.updatePost(post_number, postData); return { content: [{ type: "text", text: JSON.stringify(response) }], }; } case "esa_list_comments": { const args = request.params.arguments as unknown as ListCommentsArgs; if (!args.post_number) { throw new Error("post_number is required"); } const response = await esaClient.listComments( args.post_number, args.page, args.per_page ); return { content: [{ type: "text", text: JSON.stringify(response) }], }; } case "esa_get_comment": { const args = request.params.arguments as unknown as GetCommentArgs; if (!args.comment_id) { throw new Error("comment_id is required"); } const response = await esaClient.getComment(args.comment_id, args.include); return { content: [{ type: "text", text: JSON.stringify(response) }], }; } case "esa_create_comment": { const args = request.params.arguments as unknown as CreateCommentArgs; if (!args.post_number || !args.body_md) { throw new Error("post_number and body_md are required"); } const response = await esaClient.createComment( args.post_number, args.body_md, args.user ); return { content: [{ type: "text", text: JSON.stringify(response) }], }; } case "esa_get_members": { const args = request.params.arguments as unknown as GetMembersArgs; const response = await esaClient.getMembers(args.page, args.per_page); return { content: [{ type: "text", text: JSON.stringify(response) }], }; } case "esa_get_member": { const args = request.params.arguments as unknown as GetMemberArgs; if (!args.screen_name_or_email) { throw new Error("screen_name_or_email is required"); } const response = await esaClient.getMember(args.screen_name_or_email); return { content: [{ type: "text", text: JSON.stringify(response) }], }; } default: throw new Error(`Unknown tool: ${request.params.name}`); } } catch (error) { console.error("Tool execution error:", error); return { content: [ { type: "text", text: JSON.stringify({ error: error instanceof Error ? error.message : String(error), }), }, ], }; } }, ); server.setRequestHandler(ListToolsRequestSchema, async () => { console.error("ListToolsRequest received"); return { tools: [ listPostsTool, getPostTool, createPostTool, updatePostTool, listCommentsTool, getCommentTool, createCommentTool, getMembersTool, getMemberTool, ], }; }); const transport = new StdioServerTransport(); console.error("Connecting server to transport..."); await server.connect(transport); console.error("esa MCP Server running on stdio"); } main().catch((error) => { console.error("Fatal error in main():", error); process.exit(1); });