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
JavaScript
// 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;
}
}