UNPKG

planka-mcp

Version:

A Model Context Protocol (MCP) server for managing Planka boards, cards, lists, and labels via CLI and NPX.

594 lines (593 loc) 20.8 kB
// Type definitions for Planka API responses export const plankaGradients = [ 'old-lime', 'ocean-dive', 'tzepesch-style', 'jungle-mesh', 'strawberry-dust', 'purple-rose', 'sun-scream', 'warm-rust', 'sky-change', 'green-eyes', 'blue-xchange', 'blood-orange', 'sour-peel', 'green-ninja', 'algae-green', 'coral-reef', 'steel-grey', 'heat-waves', 'velvet-lounge', 'purple-rain', 'blue-steel', 'blueish-curve', 'prism-light', 'green-mist', 'red-curtain' ]; export const plankaColors = ['berry-red', 'pumpkin-orange', 'lagoon-blue', 'pink-tulip', 'light-mud', 'orange-peel', 'bright-moss', 'antique-blue', 'dark-granite', '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']; export class Planka { emailOrUsername; password; baseUrl; authKey; lastUpdatedProjects = null; selectedProject = null; constructor(emailOrUsername, password, baseUrl, authKey) { this.emailOrUsername = emailOrUsername; this.password = password; this.baseUrl = baseUrl; this.authKey = authKey; } async getProject(projectId) { if (projectId) { const project = await this.getProjectById(projectId); if (!project) throw new Error(`Project with ID ${projectId} not found`); this.selectedProject = project; this.lastUpdatedProjects = new Date(); return project; } if (!this.selectedProject) { throw new Error("No project selected. Use selectProject(projectId) to select a project or provide projectId."); } // if (this.lastUpdatedProjects && (new Date().getTime() - this.lastUpdatedProjects.getTime() > 5 * 60 * 1000)) { this.selectedProject = await this.getProjectById(this.selectedProject.item.id); this.lastUpdatedProjects = new Date(); // } return this.selectedProject; } async plankaFetch(url, options) { try { // Add Bearer token if available options.headers = options.headers || {}; if (this.authKey) { options.headers["Authorization"] = `Bearer ${this.authKey}`; } const response = await fetch(url, options); const responseJson = await response.json(); if (responseJson.code && responseJson.message) { console.error(`Planka API Error: ${responseJson.message}`); throw new Error(responseJson.message + "\nProblems: " + JSON.stringify(responseJson.problems || [])); } if (responseJson.code) { console.error(`Planka API Error: ${responseJson.code}`); } return responseJson; } catch (err) { console.error('Planka fetch failed:', err); throw err; } } static async init(emailOrUsername, password, baseUrl) { // Use a temporary instance to call plankaFetch for auth const tempPlanka = new Planka(emailOrUsername, password, baseUrl, null); const responseJson = await tempPlanka.plankaFetch(`${baseUrl}/access-tokens`, { method: 'POST', headers: { 'Content-Type': 'application/json', }, body: JSON.stringify({ emailOrUsername, password }) }); const authKey = responseJson.item || null; return new Planka(emailOrUsername, password, baseUrl, authKey); } // Simple update functions async moveCardToList(cardId, newListId) { // Only update listId const responseJson = await this.plankaFetch(`${this.baseUrl}/cards/${cardId}`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ listId: newListId, position: 1 }) }); return responseJson.item; } async renameList(listId, newName) { const responseJson = await this.plankaFetch(`${this.baseUrl}/lists/${listId}`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ name: newName }) }); return responseJson.item; } async archiveList(listId) { // Set type to 'archive' const responseJson = await this.plankaFetch(`${this.baseUrl}/lists/${listId}`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ type: 'archive' }) }); return responseJson.item; } async renameCard(cardId, newName) { const responseJson = await this.plankaFetch(`${this.baseUrl}/cards/${cardId}`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ name: newName }) }); return responseJson.item; } async updateCardDescription(cardId, description) { const responseJson = await this.plankaFetch(`${this.baseUrl}/cards/${cardId}`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ description }) }); return responseJson.item; } async renameBoard(boardId, newName) { const responseJson = await this.plankaFetch(`${this.baseUrl}/boards/${boardId}`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ name: newName }) }); return responseJson.item; } async renameLabel(labelId, newName) { const responseJson = await this.plankaFetch(`${this.baseUrl}/labels/${labelId}`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ name: newName }) }); return responseJson.item; } async changeLabelColor(labelId, newColor) { const responseJson = await this.plankaFetch(`${this.baseUrl}/labels/${labelId}`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ color: newColor }) }); return responseJson.item; } // Project-related methods async getProjects() { // get {{baseurl}}/projects for all projects const responseJson = await this.plankaFetch(`${this.baseUrl}/projects`, { method: 'GET', headers: { 'Content-Type': 'application/json' }, }); return responseJson; } async getProjectById(projectId) { // get {{baseurl}}/projects/{projectId} for a specific project const responseJson = await this.plankaFetch(`${this.baseUrl}/projects/${projectId}`, { method: 'GET', headers: { 'Content-Type': 'application/json' }, }); if (!responseJson.item) return null; return responseJson; } async selectProject(projectId) { const project = await this.getProjectById(projectId); if (!project) { throw new Error(`Project with ID ${projectId} not found`); } this.selectedProject = project; this.lastUpdatedProjects = new Date(); return this.selectedProject; } async changeProjectBackgroundGradient(projectId, gradient) { const responseJson = await this.plankaFetch(`${this.baseUrl}/projects/${projectId}`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ backgroundType: "gradient", backgroundGradient: gradient }) }); return responseJson.item; } // Board-related methods async getBoards(projectId) { const project = await this.getProject(projectId); return project?.included.boards.filter(board => board.projectId === project.item.id) || []; } async createBoard(projectId, board) { if (!projectId && !this.selectedProject) { throw new Error("No project selected. Use selectProject(projectId) to select a project or provide projectId."); } if (!projectId) { projectId = this.selectedProject?.item.id; } // Remove null/undefined values Object.keys(board).forEach(key => { if (board[key] === null || board[key] === undefined) { delete board[key]; } }); const responseJson = await this.plankaFetch(`${this.baseUrl}/projects/${projectId}/boards`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(board) }); if (!responseJson.item) { throw new Error('Failed to create board'); } return responseJson.item; } async getBoardById(boardId) { const board = this.plankaFetch(`${this.baseUrl}/boards/${boardId}`, { method: 'GET', headers: { 'Content-Type': 'application/json' } }); if (!board) { throw new Error(`Board with ID ${boardId} not found`); } return board; } async updateBoard(boardId, updates, projectId) { const project = await this.getProject(projectId); const board = project?.included.boards.find(b => b.id === boardId); if (!board) { throw new Error(`Board with ID ${boardId} not found`); } const responseJson = await this.plankaFetch(`${this.baseUrl}/boards/${boardId}`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(updates) }); return responseJson.item; } async deleteBoard(boardId, projectId) { const project = await this.getProject(projectId); const board = project?.included.boards.find(b => b.id === boardId); if (!board) { throw new Error(`Board with ID ${boardId} not found`); } await this.plankaFetch(`${this.baseUrl}/boards/${boardId}`, { method: 'DELETE', headers: { 'Content-Type': 'application/json' } }); // Remove from local cache if (project) { project.included.boards = project.included.boards.filter(b => b.id !== boardId); if (!projectId) { this.selectedProject = project; this.lastUpdatedProjects = new Date(); } } } // Label-related methods async getLabelsForBoard(boardId) { const board = await this.getBoardById(boardId); return board.included.labels.filter(label => label.boardId === boardId) || []; } async createLabel(boardId, label) { // Default type if not provided if (!label.type) { label.type = 'label'; } // Remove null/undefined values Object.keys(label).forEach(key => { if (label[key] === null || label[key] === undefined) { delete label[key]; } }); const responseJson = await this.plankaFetch(`${this.baseUrl}/boards/${boardId}/labels`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(label) }); return responseJson.item; } async updateLabel(labelId, updates) { const responseJson = await this.plankaFetch(`${this.baseUrl}/labels/${labelId}`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(updates) }); return responseJson.item; } async deleteLabel(labelId) { const responseJson = await this.plankaFetch(`${this.baseUrl}/labels/${labelId}`, { method: 'DELETE', headers: { 'Content-Type': 'application/json' } }); return responseJson; } async assignLabelToCard(cardId, labelId) { const responseJson = await this.plankaFetch(`${this.baseUrl}/cards/${cardId}/card-labels`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ labelId }) }); console.log(`Assigned label ${labelId} to card ${cardId}`); return responseJson.item; } // List-related methods async getLists(boardId, projectId) { const project = await this.getProject(projectId); const board = project?.included.boards.find(b => b.id === boardId); if (!board) { throw new Error(`Board with ID ${boardId} not found`); } const responseJson = await this.plankaFetch(`${this.baseUrl}/boards/${boardId}`, { method: 'GET', headers: { 'Content-Type': 'application/json' } }); return responseJson.included.lists .filter(list => list.boardId === boardId && list.type !== 'trash' && list.type !== 'archive') || []; } async createList(boardId, list, projectId) { const project = await this.getProject(projectId); const board = project?.included.boards.find(b => b.id === boardId); if (!board) { throw new Error(`Board with ID ${boardId} not found`); } // Default type if not provided if (!list.type) { list.type = 'active'; } // Remove null/undefined values Object.keys(list).forEach(key => { if (list[key] === null || list[key] === undefined) { delete list[key]; } }); const responseJson = await this.plankaFetch(`${this.baseUrl}/boards/${boardId}/lists`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(list) }); if (!responseJson.item) { throw new Error('Failed to create list'); } return responseJson.item; } async changeListColor(listId, color) { const responseJson = await this.plankaFetch(`${this.baseUrl}/lists/${listId}`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ color }) }); if (!responseJson.item) { throw new Error('Failed to change list color'); } return responseJson.item; } async updateList(listId, updates) { const responseJson = await this.plankaFetch(`${this.baseUrl}/lists/${listId}`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(updates) }); return responseJson.item; } // Card-related methods async getCards(boardId, listId) { const board = await this.getBoardById(boardId); const cards = board.included.cards.filter(card => card.boardId === boardId); if (listId) { return cards.filter(card => card.listId === listId); } return cards; } async deleteCard(cardId) { await this.plankaFetch(`${this.baseUrl}/cards/${cardId}`, { method: 'DELETE', headers: { 'Content-Type': 'application/json' } }); } async createCard(listId, card) { // Convert dueDate to ISO string if present if (card.dueDate) { card.dueDate = new Date(card.dueDate).toISOString(); } // Default type if not provided if (!card.type) { card.type = 'project'; } // Remove null/undefined values Object.keys(card).forEach(key => { if (card[key] === null || card[key] === undefined) { delete card[key]; } }); const responseJson = await this.plankaFetch(`${this.baseUrl}/lists/${listId}/cards`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(card) }); return responseJson.item; } async updateCard(cardId, updates) { // If dueDate is present, convert to ISO string if (updates.dueDate) { updates.dueDate = new Date(updates.dueDate).toISOString(); } const responseJson = await this.plankaFetch(`${this.baseUrl}/cards/${cardId}`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(updates) }); return responseJson.item; } // member-related methods async getMembers(projectId) { const project = await this.getProject(projectId); if (!project) { throw new Error("No project selected. Use selectProject(projectId) to select a project or provide projectId."); } const members = project.included.users.filter(user => user.isDeactivated === false); return members; } async assignMemberToCard(cardId, userId) { const responseJson = await this.plankaFetch(`${this.baseUrl}/cards/${cardId}/card-memberships`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ userId }) }); return responseJson.item; } async removeMemberFromCard(cardId, userId) { const responseJson = await this.plankaFetch(`${this.baseUrl}/cards/${cardId}/card-memberships/userid:${userId}`, { method: 'DELETE', headers: { 'Content-Type': 'application/json' } }); return responseJson; } // task-list related methods async createTaskList(cardId, name, showOnFrontOfCard = true, position = 131072) { const responseJson = await this.plankaFetch(`${this.baseUrl}/cards/${cardId}/task-lists`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ name, showOnFrontOfCard, position }) }); return responseJson.item; } async createTask(taskListId, name, position = 65536) { const responseJson = await this.plankaFetch(`${this.baseUrl}/task-lists/${taskListId}/tasks`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ name, position }) }); return responseJson.item; } /** * Delete a task list by ID * @param taskListId The ID of the task list */ async deleteTaskList(taskListId) { const responseJson = await this.plankaFetch(`${this.baseUrl}/task-lists/${taskListId}`, { method: 'DELETE', headers: { 'Content-Type': 'application/json' } }); return responseJson; } /** * Update a task's completion status (toggle complete/incomplete) * @param taskId The ID of the task * @param isCompleted Whether the task is completed */ async updateTaskCompletion(taskId, isCompleted) { const responseJson = await this.plankaFetch(`${this.baseUrl}/tasks/${taskId}`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ isCompleted }) }); return responseJson.item; } /** * Delete a task by ID * @param taskId The ID of the task */ async deleteTask(taskId) { const responseJson = await this.plankaFetch(`${this.baseUrl}/tasks/${taskId}`, { method: 'DELETE', headers: { 'Content-Type': 'application/json' } }); return responseJson; } }