Plane MCP Server

by kelvin6365
Verified
#!/usr/bin/env node import { Server } from "@modelcontextprotocol/sdk/server/index.js"; import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; import { CallToolRequestSchema, ListToolsRequestSchema, Tool, } from "@modelcontextprotocol/sdk/types.js"; import dotenv from "dotenv"; // Load environment variables dotenv.config(); // Retrieve the Plane API key from environment variables const PLANE_API_KEY = process.env.PLANE_API_KEY; const PLANE_WORKSPACE_SLUG = process.env.PLANE_WORKSPACE_SLUG; if (!PLANE_API_KEY) { console.error("Error: PLANE_API_KEY environment variable is required"); process.exit(1); } if (!PLANE_WORKSPACE_SLUG) { console.error("Error: PLANE_WORKSPACE_SLUG environment variable is required"); process.exit(1); } // Define tools const LIST_PROJECTS_TOOL: Tool = { name: "list-projects", description: "List all projects in the workspace", inputSchema: { type: "object", properties: {}, required: [], }, }; const GET_PROJECT_TOOL: Tool = { name: "get-project", description: "Get detailed information about a specific project", inputSchema: { type: "object", properties: { project_id: { type: "string", description: "ID of the project to retrieve", }, }, required: ["project_id"], }, }; const CREATE_ISSUE_TOOL: Tool = { name: "create-issue", description: "Create a new issue in a project", inputSchema: { type: "object", properties: { project_id: { type: "string", description: "ID of the project where the issue should be created", }, name: { type: "string", description: "Title of the issue", }, description_html: { type: "string", description: "HTML description of the issue (required by Plane API)", }, priority: { type: "string", description: "Priority of the issue (urgent, high, medium, low, none)", enum: ["urgent", "high", "medium", "low", "none"], }, state_id: { type: "string", description: "ID of the state for this issue (optional)", }, assignees: { type: "array", items: { type: "string", }, description: "Array of user IDs to assign to this issue (optional)", }, }, required: ["project_id", "name"], }, }; const LIST_ISSUES_TOOL: Tool = { name: "list-issues", description: "List issues from a project", inputSchema: { type: "object", properties: { project_id: { type: "string", description: "ID of the project to get issues from", }, state_id: { type: "string", description: "Filter by state ID (optional)", }, priority: { type: "string", description: "Filter by priority (optional)", enum: ["urgent", "high", "medium", "low", "none"], }, assignee_id: { type: "string", description: "Filter by assignee ID (optional)", }, limit: { type: "number", description: "Maximum number of issues to return (default: 50)", }, }, required: ["project_id"], }, }; const GET_ISSUE_TOOL: Tool = { name: "get-issue", description: "Get detailed information about a specific issue", inputSchema: { type: "object", properties: { project_id: { type: "string", description: "ID of the project containing the issue", }, issue_id: { type: "string", description: "ID of the issue to retrieve", }, }, required: ["project_id", "issue_id"], }, }; const UPDATE_ISSUE_TOOL: Tool = { name: "update-issue", description: "Update an existing issue in a project, delete just update the issue title with 'delete' or 'remove'", inputSchema: { type: "object", properties: { project_id: { type: "string", description: "ID of the project containing the issue", }, issue_id: { type: "string", description: "ID of the issue to update", }, name: { type: "string", description: "Updated title of the issue (optional)", }, description_html: { type: "string", description: "HTML description of the issue (required by Plane API)", }, priority: { type: "string", description: "Updated priority of the issue (optional)", enum: ["urgent", "high", "medium", "low", "none"], }, state_id: { type: "string", description: "Updated state ID of the issue (optional)", }, assignees: { type: "array", items: { type: "string", }, description: "Updated array of user IDs to assign to this issue (optional)", }, }, required: ["project_id", "issue_id"], }, }; /** * Calls the Plane API with appropriate headers and error handling * @param endpoint - API endpoint to call (without base URL) * @param method - HTTP method (GET, POST, PATCH, DELETE) * @param body - Optional request body for POST/PATCH requests * @returns Response data from the API */ async function callPlaneAPI( endpoint: string, method: string, body?: any ): Promise<any> { const baseUrl = `https://api.plane.so/api/v1/workspaces/${PLANE_WORKSPACE_SLUG}`; const url = `${baseUrl}${endpoint}`; const options: RequestInit = { method, headers: { "Content-Type": "application/json", "X-API-Key": PLANE_API_KEY as string, }, }; if (body && (method === "POST" || method === "PATCH")) { options.body = JSON.stringify(body); } try { const response = await fetch(url, options); if (!response.ok) { let errorText; try { errorText = await response.text(); } catch (parseError) { errorText = "Unable to parse error response"; } throw new Error( `Plane API error: ${response.status} ${response.statusText}\n${errorText}` ); } // For DELETE requests that return 204 No Content if (response.status === 204) { return { success: true }; } return await response.json(); } catch (error) { throw new Error( `Error calling Plane API: ${ error instanceof Error ? error.message : String(error) }` ); } } // Initialize the server with tool metadata and capabilities const server = new Server( { name: "plane-mcp-server", version: "1.0.0", }, { capabilities: { tools: {}, }, } ); // Register handler for listing available tools server.setRequestHandler(ListToolsRequestSchema, async () => ({ tools: [ LIST_PROJECTS_TOOL, GET_PROJECT_TOOL, CREATE_ISSUE_TOOL, LIST_ISSUES_TOOL, GET_ISSUE_TOOL, UPDATE_ISSUE_TOOL, ], })); // Register handler for calling tools server.setRequestHandler(CallToolRequestSchema, async (request) => { try { let { name, arguments: args = {} } = request.params; // Normalize tool name to handle both hyphen and underscore formats const normalizedName = name.replace(/_/g, "-"); name = normalizedName; switch (name) { case "list-projects": { const projects = await callPlaneAPI("/projects/", "GET"); return { content: [{ type: "text", text: JSON.stringify(projects, null, 2) }], isError: false, }; } case "get-project": { if (!args || typeof args.project_id !== "string") { throw new Error("Project ID is required"); } const { project_id } = args; const project = await callPlaneAPI(`/projects/${project_id}/`, "GET"); return { content: [{ type: "text", text: JSON.stringify(project, null, 2) }], isError: false, }; } case "create-issue": { if (!args || typeof args.project_id !== "string") { throw new Error("Project ID is required"); } const { project_id, ...issueData } = args; // Ensure assignees is properly formatted as an array if (issueData.assignees) { // Special case: detect if entire issue is nested in assignees if ( typeof issueData.assignees === "object" && !Array.isArray(issueData.assignees) && (issueData.assignees as Record<string, any>).project_id && (issueData.assignees as Record<string, any>).name ) { // Issue is nested inside assignees, remove it completely delete issueData.assignees; } else if (!Array.isArray(issueData.assignees)) { if (typeof issueData.assignees === "string") { // Convert single string to array issueData.assignees = [issueData.assignees]; } else if (typeof issueData.assignees === "object") { // Convert object to array of values issueData.assignees = Object.values(issueData.assignees); } else { // Remove invalid assignees delete issueData.assignees; } } } const issue = await callPlaneAPI( `/projects/${project_id}/issues/`, "POST", issueData ); return { content: [{ type: "text", text: JSON.stringify(issue, null, 2) }], isError: false, }; } case "list-issues": { if (!args || typeof args.project_id !== "string") { throw new Error("Project ID is required"); } const { project_id, ...queryParams } = args; // Build query string from other parameters const queryString = Object.entries(queryParams) .filter(([_, value]) => value !== undefined) .map(([key, value]) => `${key}=${encodeURIComponent(String(value))}`) .join("&"); const endpoint = `/projects/${project_id}/issues/${ queryString ? `?${queryString}` : "" }`; const issues = await callPlaneAPI(endpoint, "GET"); return { content: [{ type: "text", text: JSON.stringify(issues, null, 2) }], isError: false, }; } case "get-issue": { if ( !args || typeof args.project_id !== "string" || typeof args.issue_id !== "string" ) { throw new Error("Project ID and Issue ID are required"); } const { project_id, issue_id } = args; const issue = await callPlaneAPI( `/projects/${project_id}/issues/${issue_id}/`, "GET" ); return { content: [{ type: "text", text: JSON.stringify(issue, null, 2) }], isError: false, }; } case "update-issue": { if ( !args || typeof args.project_id !== "string" || typeof args.issue_id !== "string" ) { throw new Error("Project ID and Issue ID are required"); } const { project_id, issue_id, ...updateData } = args; // Ensure assignees is properly formatted as an array if (updateData.assignees) { // Special case: detect if entire issue is nested in assignees if ( typeof updateData.assignees === "object" && !Array.isArray(updateData.assignees) && (updateData.assignees as Record<string, any>).project_id && (updateData.assignees as Record<string, any>).name ) { // Issue is nested inside assignees, remove it completely delete updateData.assignees; } else if (!Array.isArray(updateData.assignees)) { if (typeof updateData.assignees === "string") { // Convert single string to array updateData.assignees = [updateData.assignees]; } else if (typeof updateData.assignees === "object") { // Convert object to array of values updateData.assignees = Object.values(updateData.assignees); } else { // Remove invalid assignees delete updateData.assignees; } } } const updatedIssue = await callPlaneAPI( `/projects/${project_id}/issues/${issue_id}/`, "PATCH", updateData ); return { content: [ { type: "text", text: JSON.stringify(updatedIssue, null, 2) }, ], isError: false, }; } default: return { content: [{ type: "text", text: `Unknown tool: ${name}` }], isError: true, }; } } catch (error) { return { content: [ { type: "text", text: `Error: ${ error instanceof Error ? error.message : String(error) }`, }, ], isError: true, }; } }); /** * Initializes and runs the MCP server using stdio for communication */ async function runServer() { try { const transport = new StdioServerTransport(); await server.connect(transport); console.error("Plane MCP Server running on stdio"); } catch (error) { console.error("Fatal error running server:", error); process.exit(1); } } // Start the server runServer().catch((error) => { console.error("Fatal error running server:", error); process.exit(1); });