Skip to main content
Glama

Planka MCP Server

by gcorroto
index.ts28 kB
#!/usr/bin/env node import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; import express from "express"; import { Request, Response } from "express"; import { z } from "zod"; // Import Planka operations import * as boardMemberships from "./operations/boardMemberships.js"; import * as boards from "./operations/boards.js"; import * as cards from "./operations/cards.js"; import * as comments from "./operations/comments.js"; import * as labels from "./operations/labels.js"; import * as lists from "./operations/lists.js"; import * as projects from "./operations/projects.js"; import * as tasks from "./operations/tasks.js"; // Import custom tools import { createCardWithTasks, getBoardSummary, getCardDetails, } from "./tools/index.js"; import { VERSION } from "./common/version.js"; // Create the MCP Server with proper configuration const server = new McpServer({ name: "planka-mcp-server", version: VERSION, }); // Note: MCP Server doesn't have onerror, we'll handle errors in the runServer function // ----- CONSOLIDATED KANBAN TOOLS ----- // 1. Project and Board Manager server.tool( "mcp_kanban_project_board_manager", "Manage projects and boards with various operations", { action: z .enum([ "get_projects", "get_project", "get_boards", "create_board", "get_board", "update_board", "delete_board", "get_board_summary", ]) .describe("The action to perform"), id: z.string().optional().describe("The ID of the project or board"), projectId: z.string().optional().describe("The ID of the project"), name: z.string().optional().describe("The name of the board"), position: z.number().optional().describe("The position of the board"), type: z.string().optional().describe("The type of the board"), page: z .number() .optional() .describe("The page number for pagination (1-indexed)"), perPage: z.number().optional().describe("The number of items per page"), boardId: z .string() .optional() .describe("The ID of the board to get a summary for"), includeTaskDetails: z .boolean() .optional() .default(false) .describe("Whether to include detailed task information for each card"), includeComments: z .boolean() .optional() .default(false) .describe("Whether to include comments for each card"), }, async (args) => { let result; switch (args.action) { case "get_projects": if (!args.page || !args.perPage) throw new Error( "page and perPage are required for get_projects action" ); result = await projects.getProjects(args.page, args.perPage); break; case "get_project": if (!args.id) throw new Error("id is required for get_project action"); result = await projects.getProject(args.id); break; case "get_boards": if (!args.projectId) throw new Error("projectId is required for get_boards action"); result = await boards.getBoards(args.projectId); break; case "create_board": if (!args.projectId || !args.name || args.position === undefined) throw new Error( "projectId, name, and position are required for create_board action" ); result = await boards.createBoard({ projectId: args.projectId, name: args.name, position: args.position, }); break; case "get_board": if (!args.id) throw new Error("id is required for get_board action"); result = await boards.getBoard(args.id); break; case "update_board": if (!args.id || !args.name || args.position === undefined) throw new Error( "id, name, and position are required for update_board action" ); const boardUpdateOptions = { name: args.name, position: args.position, } as any; // Use type assertion to avoid TypeScript errors if (args.type) { boardUpdateOptions.type = args.type; } result = await boards.updateBoard(args.id, boardUpdateOptions); break; case "delete_board": if (!args.id) throw new Error("id is required for delete_board action"); result = await boards.deleteBoard(args.id); break; case "get_board_summary": if (!args.boardId) throw new Error("boardId is required for get_board_summary action"); result = await getBoardSummary({ boardId: args.boardId, includeTaskDetails: args.includeTaskDetails, includeComments: args.includeComments, }); break; default: throw new Error(`Unknown action: ${args.action}`); } return { content: [{ type: "text", text: JSON.stringify(result) }], }; } ); // 2. List Manager server.tool( "mcp_kanban_list_manager", "Manage kanban lists with various operations", { action: z .enum(["get_all", "create", "update", "delete", "get_one"]) .describe("The action to perform"), id: z.string().optional().describe("The ID of the list"), boardId: z.string().optional().describe("The ID of the board"), name: z.string().optional().describe("The name of the list"), position: z.number().optional().describe("The position of the list"), }, async (args) => { let result; switch (args.action) { case "get_all": if (!args.boardId) throw new Error("boardId is required for get_all action"); result = await lists.getLists(args.boardId); break; case "create": if (!args.boardId || !args.name || args.position === undefined) throw new Error( "boardId, name, and position are required for create action" ); result = await lists.createList({ boardId: args.boardId, name: args.name, position: args.position, }); break; case "get_one": if (!args.id) throw new Error("id is required for get_one action"); result = await lists.getList(args.id); break; case "update": if (!args.id || !args.name || args.position === undefined) throw new Error( "id, name, and position are required for update action" ); const { id, ...updateOptions } = args; result = await lists.updateList(id, { name: args.name, position: args.position, }); break; case "delete": if (!args.id) throw new Error("id is required for delete action"); result = await lists.deleteList(args.id); break; default: throw new Error(`Unknown action: ${args.action}`); } return { content: [{ type: "text", text: JSON.stringify(result) }], }; } ); // 3. Card Manager server.tool( "mcp_kanban_card_manager", "Manage kanban cards with various operations", { action: z .enum([ "get_all", "create", "get_one", "update", "move", "duplicate", "delete", "create_with_tasks", "get_details", ]) .describe("The action to perform"), id: z.string().optional().describe("The ID of the card"), listId: z.string().optional().describe("The ID of the list"), boardId: z .string() .optional() .describe("The ID of the board (if moving between boards)"), projectId: z .string() .optional() .describe("The ID of the project (if moving between projects)"), name: z.string().optional().describe("The name of the card"), description: z.string().optional().describe("The description of the card"), position: z.number().optional().describe("The position of the card"), dueDate: z .string() .optional() .describe("The due date for the card (ISO format)"), isCompleted: z .boolean() .optional() .describe("Whether the card is completed"), tasks: z .array(z.string()) .optional() .describe( "Array of task descriptions to create for create_with_tasks action" ), comment: z .string() .optional() .describe("Optional comment to add to the card"), cardId: z .string() .optional() .describe("The ID of the card to get details for"), }, async (args) => { let result; switch (args.action) { case "get_all": if (!args.listId) throw new Error("listId is required for get_all action"); result = await cards.getCards(args.listId); break; case "create": if (!args.listId || !args.name) throw new Error("listId and name are required for create action"); result = await cards.createCard({ listId: args.listId, name: args.name, description: args.description || "", position: args.position || 0, }); break; case "get_one": if (!args.id) throw new Error("id is required for get_one action"); result = await cards.getCard(args.id); break; case "update": if (!args.id) throw new Error("id is required for update action"); const cardUpdateOptions = {} as any; // Use type assertion to avoid TypeScript errors if (args.name !== undefined) cardUpdateOptions.name = args.name; if (args.description !== undefined) cardUpdateOptions.description = args.description; if (args.position !== undefined) cardUpdateOptions.position = args.position; if (args.dueDate !== undefined) cardUpdateOptions.dueDate = args.dueDate; if (args.isCompleted !== undefined) cardUpdateOptions.isCompleted = args.isCompleted; result = await cards.updateCard(args.id, cardUpdateOptions); break; case "move": if (!args.id || !args.listId || args.position === undefined) throw new Error( "id, listId, and position are required for move action" ); result = await cards.moveCard( args.id, args.listId, args.position, args.boardId, args.projectId ); break; case "duplicate": if (!args.id || args.position === undefined) throw new Error("id and position are required for duplicate action"); result = await cards.duplicateCard(args.id, args.position); break; case "delete": if (!args.id) throw new Error("id is required for delete action"); result = await cards.deleteCard(args.id); break; case "create_with_tasks": if (!args.listId || !args.name) throw new Error( "listId and name are required for create_with_tasks action" ); result = await createCardWithTasks({ listId: args.listId, name: args.name, description: args.description, tasks: args.tasks, comment: args.comment, position: args.position, }); break; case "get_details": if (!args.cardId) throw new Error("cardId is required for get_details action"); result = await getCardDetails({ cardId: args.cardId, }); break; default: throw new Error(`Unknown action: ${args.action}`); } return { content: [{ type: "text", text: JSON.stringify(result) }], }; } ); // 4. Stopwatch Manager server.tool( "mcp_kanban_stopwatch", "Manage card stopwatches for time tracking", { action: z .enum(["start", "stop", "get", "reset"]) .describe("The action to perform"), id: z.string().describe("The ID of the card"), }, async (args) => { let result; switch (args.action) { case "start": result = await cards.startCardStopwatch(args.id); break; case "stop": result = await cards.stopCardStopwatch(args.id); break; case "get": result = await cards.getCardStopwatch(args.id); break; case "reset": result = await cards.resetCardStopwatch(args.id); break; default: throw new Error(`Unknown action: ${args.action}`); } return { content: [{ type: "text", text: JSON.stringify(result) }], }; } ); // 5. Label Manager server.tool( "mcp_kanban_label_manager", "Manage kanban labels with various operations", { action: z .enum([ "get_all", "create", "update", "delete", "add_to_card", "remove_from_card", ]) .describe("The action to perform"), id: z.string().optional().describe("The ID of the label"), boardId: z.string().optional().describe("The ID of the board"), cardId: z.string().optional().describe("The ID of the card"), labelId: z .string() .optional() .describe("The ID of the label (for card operations)"), name: z.string().optional().describe("The name of the label"), color: z .enum([ "berry-red", "pumpkin-orange", "lagoon-blue", "pink-tulip", "light-mud", "orange-peel", "bright-moss", "antique-blue", "dark-granite", "lagune-blue", "sunny-grass", "morning-sky", "light-orange", "midnight-blue", "tank-green", "gun-metal", "wet-moss", "red-burgundy", "light-concrete", "apricot-red", "desert-sand", "navy-blue", "egg-yellow", "coral-green", "light-cocoa", ]) .optional() .describe("The color of the label"), position: z.number().optional().describe("The position of the label"), }, async (args) => { let result; switch (args.action) { case "get_all": if (!args.boardId) throw new Error("boardId is required for get_all action"); result = await labels.getLabels(args.boardId); break; case "create": if ( !args.boardId || !args.name || !args.color || args.position === undefined ) throw new Error( "boardId, name, color, and position are required for create action" ); result = await labels.createLabel({ boardId: args.boardId, name: args.name, color: args.color, position: args.position, }); break; case "update": if ( !args.id || !args.name || !args.color || args.position === undefined ) throw new Error( "id, name, color, and position are required for update action" ); result = await labels.updateLabel(args.id, { name: args.name, color: args.color, position: args.position, }); break; case "delete": if (!args.id) throw new Error("id is required for delete action"); result = await labels.deleteLabel(args.id); break; case "add_to_card": if (!args.cardId || !args.labelId) throw new Error( "cardId and labelId are required for add_to_card action" ); result = await labels.addLabelToCard(args.cardId, args.labelId); break; case "remove_from_card": if (!args.cardId || !args.labelId) throw new Error( "cardId and labelId are required for remove_from_card action" ); result = await labels.removeLabelFromCard(args.cardId, args.labelId); break; default: throw new Error(`Unknown action: ${args.action}`); } return { content: [{ type: "text", text: JSON.stringify(result) }], }; } ); // 6. Task Manager server.tool( "mcp_kanban_task_manager", "Manage kanban tasks with various operations", { action: z .enum([ "get_all", "create", "batch_create", "get_one", "update", "delete", "complete_task", ]) .describe("The action to perform"), id: z.string().optional().describe("The ID of the task"), cardId: z.string().optional().describe("The ID of the card"), name: z.string().optional().describe("The name of the task"), isCompleted: z .boolean() .optional() .describe("Whether the task is completed"), position: z.number().optional().describe("The position of the task"), tasks: z .array( z.object({ cardId: z.string().describe("The ID of the card for this task"), name: z.string().describe("The name of this task"), position: z.number().optional().describe("The position of this task"), }) ) .optional() .describe("Array of tasks to create in batch"), }, async (args) => { let result; switch (args.action) { case "get_all": if (!args.cardId) throw new Error("cardId is required for get_all action"); result = await tasks.getTasks(args.cardId); break; case "create": if (!args.cardId || !args.name) throw new Error("cardId and name are required for create action"); result = await tasks.createTask({ cardId: args.cardId, name: args.name, position: args.position, }); break; case "batch_create": if (!args.tasks || args.tasks.length === 0) throw new Error("tasks array is required for batch_create action"); result = await tasks.batchCreateTasks({ tasks: args.tasks }); break; case "get_one": if (!args.id) throw new Error("id is required for get_one action"); result = await tasks.getTask(args.id); break; case "update": if (!args.id) throw new Error("id is required for update action"); const taskUpdateOptions = {} as any; if (args.name !== undefined) taskUpdateOptions.name = args.name; if (args.position !== undefined) taskUpdateOptions.position = args.position; if (args.isCompleted !== undefined) taskUpdateOptions.isCompleted = args.isCompleted; result = await tasks.updateTask(args.id, taskUpdateOptions); break; case "complete_task": if (!args.id) throw new Error("id is required for complete_task action"); result = await tasks.updateTask(args.id, { isCompleted: true } as any); break; case "delete": if (!args.id) throw new Error("id is required for delete action"); result = await tasks.deleteTask(args.id); break; default: throw new Error(`Unknown action: ${args.action}`); } return { content: [{ type: "text", text: JSON.stringify(result) }], }; } ); // 7. Comment Manager server.tool( "mcp_kanban_comment_manager", "Manage card comments with various operations", { action: z .enum(["get_all", "create", "get_one", "update", "delete"]) .describe("The action to perform"), id: z.string().optional().describe("The ID of the comment"), cardId: z.string().optional().describe("The ID of the card"), text: z.string().optional().describe("The text content of the comment"), }, async (args) => { let result; switch (args.action) { case "get_all": if (!args.cardId) throw new Error("cardId is required for get_all action"); result = await comments.getComments(args.cardId); break; case "create": if (!args.cardId || !args.text) throw new Error("cardId and text are required for create action"); result = await comments.createComment({ cardId: args.cardId, text: args.text, }); break; case "get_one": if (!args.id) throw new Error("id is required for get_one action"); result = await comments.getComment(args.id); break; case "update": if (!args.id || !args.text) throw new Error("id and text are required for update action"); result = await comments.updateComment(args.id, { text: args.text, }); break; case "delete": if (!args.id) throw new Error("id is required for delete action"); result = await comments.deleteComment(args.id); break; default: throw new Error(`Unknown action: ${args.action}`); } return { content: [{ type: "text", text: JSON.stringify(result) }], }; } ); // 8. Membership Manager server.tool( "mcp_kanban_membership_manager", "Manage board memberships with various operations", { action: z .enum(["get_all", "create", "get_one", "update", "delete"]) .describe("The action to perform"), id: z.string().optional().describe("The ID of the membership"), boardId: z.string().optional().describe("The ID of the board"), userId: z.string().optional().describe("The ID of the user"), role: z .enum(["editor", "viewer"]) .optional() .describe("The role of the user in the board"), canComment: z .boolean() .optional() .describe("Whether the user can comment on the board"), }, async (args) => { let result; switch (args.action) { case "get_all": if (!args.boardId) throw new Error("boardId is required for get_all action"); result = await boardMemberships.getBoardMemberships(args.boardId); break; case "create": if (!args.boardId || !args.userId || !args.role) throw new Error( "boardId, userId, and role are required for create action" ); result = await boardMemberships.createBoardMembership({ boardId: args.boardId, userId: args.userId, role: args.role, }); break; case "get_one": if (!args.id) throw new Error("id is required for get_one action"); result = await boardMemberships.getBoardMembership(args.id); break; case "update": if (!args.id) throw new Error("id is required for update action"); const membershipUpdateOptions = {} as any; if (args.role !== undefined) membershipUpdateOptions.role = args.role; if (args.canComment !== undefined) membershipUpdateOptions.canComment = args.canComment; result = await boardMemberships.updateBoardMembership( args.id, membershipUpdateOptions ); break; case "delete": if (!args.id) throw new Error("id is required for delete action"); result = await boardMemberships.deleteBoardMembership(args.id); break; default: throw new Error(`Unknown action: ${args.action}`); } return { content: [{ type: "text", text: JSON.stringify(result) }], }; } ); async function validateEnvironment() { // Validate environment variables console.error("Creating Planka MCP Server..."); console.error("Server info: planka-mcp-server"); console.error("Version:", VERSION); if (!process.env.PLANKA_BASE_URL) { console.error("Warning: PLANKA_BASE_URL environment variable not set"); } else { console.error("PLANKA_BASE_URL:", process.env.PLANKA_BASE_URL); } if (!process.env.PLANKA_AGENT_EMAIL) { console.error("Warning: PLANKA_AGENT_EMAIL environment variable not set"); } else { console.error("PLANKA_AGENT_EMAIL:", process.env.PLANKA_AGENT_EMAIL); } if (!process.env.PLANKA_AGENT_PASSWORD) { console.error("Warning: PLANKA_AGENT_PASSWORD environment variable not set"); } else { console.error("PLANKA_AGENT_PASSWORD:", "***"); } } async function startStdioServer() { try { console.error("Starting Planka MCP Server in stdio mode..."); // Create transport const transport = new StdioServerTransport(); console.error("Connecting server to transport..."); // Connect server to transport - this should keep the process alive await server.connect(transport); console.error("MCP Server connected and ready!"); console.error("Available tools:", [ "mcp_kanban_project_board_manager", "mcp_kanban_list_manager", "mcp_kanban_card_manager", "mcp_kanban_stopwatch", "mcp_kanban_label_manager", "mcp_kanban_task_manager", "mcp_kanban_comment_manager", "mcp_kanban_membership_manager" ]); } catch (error) { console.error("Error starting stdio server:", error); console.error("Stack trace:", (error as Error).stack); process.exit(1); } } async function startHttpServer(port = 3000) { try { console.error("Starting Planka MCP Server in HTTP mode..."); const app = express(); app.use(express.json()); // Define una función para obtener el servidor MCP const getServer = () => server; // Create HTTP endpoints for MCP app.post('/mcp', async (req: Request, res: Response) => { // En el modo HTTP, debemos usar una implementación simple de transporte // Debido a que no disponemos del módulo específico, usaremos un enfoque simplificado try { console.log('Received POST MCP request'); // Implementamos una versión simplificada del manejo de la petición // Esto responderá con un mensaje indicando que se debe usar el transporte de stdio res.status(200).json({ jsonrpc: '2.0', result: { message: 'MCP Server is running. This HTTP endpoint is for testing only. Please use stdio transport for full functionality.' }, id: req.body.id || null }); } catch (error) { console.error('Error handling MCP request:', error); if (!res.headersSent) { res.status(500).json({ jsonrpc: '2.0', error: { code: -32603, message: 'Internal server error', }, id: req.body.id || null, }); } } }); app.get('/mcp', async (req: Request, res: Response) => { console.log('Received GET MCP request'); res.status(405).json({ jsonrpc: "2.0", error: { code: -32000, message: "Method not allowed. Use POST for MCP requests." }, id: null }); }); // Add a status endpoint app.get('/status', (_req: Request, res: Response) => { res.status(200).json({ status: 'ok', server: 'planka-mcp-server', version: VERSION }); }); // Start the server app.listen(port, () => { console.error(`MCP Server listening on port ${port}`); console.error(`Status endpoint available at http://localhost:${port}/status`); }); } catch (error) { console.error("Error starting HTTP server:", error); console.error("Stack trace:", (error as Error).stack); process.exit(1); } } async function runServer() { try { // Validate environment await validateEnvironment(); // Determine server type based on environment variables const serverType = process.env.MCP_SERVER_TYPE || 'stdio'; const httpPort = parseInt(process.env.MCP_HTTP_PORT || '3000', 10); if (serverType === 'http') { await startHttpServer(httpPort); } else { await startStdioServer(); } } catch (error) { console.error("Error starting server:", error); console.error("Stack trace:", (error as Error).stack); process.exit(1); } } // Start the server runServer();

MCP directory API

We provide all the information about MCP servers via our MCP API.

curl -X GET 'https://glama.ai/api/mcp/v1/servers/gcorroto/mcp-planka'

If you have feedback or need assistance with the MCP directory API, please join our Discord server