import fetch from 'node-fetch';
export class TrelloClient {
constructor({ apiKey, token, knowledgeBoardId }) {
this.apiKey = apiKey;
this.token = token;
this.knowledgeBoardId = knowledgeBoardId;
this.baseUrl = 'https://api.trello.com/1';
// Cache for labels and lists
this.labelsCache = new Map();
this.listsCache = new Map();
}
async makeRequest(endpoint, options = {}) {
const url = `${this.baseUrl}${endpoint}`;
const params = new URLSearchParams({
key: this.apiKey,
token: this.token,
...options.params
});
const response = await fetch(`${url}?${params}`, {
method: options.method || 'GET',
headers: {
'Content-Type': 'application/json',
...options.headers
},
body: options.body ? JSON.stringify(options.body) : undefined
});
if (!response.ok) {
throw new Error(`Trello API error: ${response.status} ${response.statusText}`);
}
return response.json();
}
async getBoardLabels() {
if (this.labelsCache.size === 0) {
const labels = await this.makeRequest(`/boards/${this.knowledgeBoardId}/labels`);
labels.forEach(label => {
this.labelsCache.set(label.name.toLowerCase(), label);
});
}
return this.labelsCache;
}
async getBoardLists() {
if (this.listsCache.size === 0) {
const lists = await this.makeRequest(`/boards/${this.knowledgeBoardId}/lists`);
lists.forEach(list => {
this.listsCache.set(list.name.toLowerCase(), list);
});
}
return this.listsCache;
}
async getOrCreateLabel(tagName) {
const labels = await this.getBoardLabels();
const existing = labels.get(tagName.toLowerCase());
if (existing) {
return existing;
}
// Create new label
const newLabel = await this.makeRequest(`/boards/${this.knowledgeBoardId}/labels`, {
method: 'POST',
body: {
name: tagName,
color: this.getRandomLabelColor()
}
});
labels.set(tagName.toLowerCase(), newLabel);
return newLabel;
}
async getOrCreateList(categoryName) {
const lists = await this.getBoardLists();
const listName = categoryName || 'general';
const existing = lists.get(listName.toLowerCase());
if (existing) {
return existing;
}
// Create new list
const newList = await this.makeRequest(`/boards/${this.knowledgeBoardId}/lists`, {
method: 'POST',
body: {
name: this.capitalizeFirst(listName),
pos: 'bottom'
}
});
lists.set(listName.toLowerCase(), newList);
return newList;
}
async createKnowledgeCard({ title, content, tags = [], category }) {
// Get or create the appropriate list
const list = await this.getOrCreateList(category);
// Create the card
const card = await this.makeRequest('/cards', {
method: 'POST',
body: {
name: title,
desc: this.formatCardDescription(content, tags, category),
idList: list.id
}
});
// Add labels for tags
if (tags.length > 0) {
for (const tag of tags) {
const label = await this.getOrCreateLabel(tag);
await this.makeRequest(`/cards/${card.id}/idLabels`, {
method: 'POST',
body: { value: label.id }
});
}
}
return {
id: card.id,
title: card.name,
content,
tags,
category: category || 'general',
url: card.shortUrl
};
}
async searchKnowledge({ query, category, tags = [] }) {
// Get all cards from the board
const cards = await this.makeRequest(`/boards/${this.knowledgeBoardId}/cards`, {
params: {
fields: 'name,desc,labels,list',
labels: 'true',
list: 'true'
}
});
// Filter and search cards
const results = cards
.filter(card => {
// Filter by category if specified
if (category && card.list.name.toLowerCase() !== category.toLowerCase()) {
return false;
}
// Filter by tags if specified
if (tags.length > 0) {
const cardTags = card.labels.map(label => label.name.toLowerCase());
const hasAllTags = tags.every(tag =>
cardTags.includes(tag.toLowerCase())
);
if (!hasAllTags) return false;
}
// Search in title and description
const searchText = `${card.name} ${card.desc}`.toLowerCase();
return searchText.includes(query.toLowerCase());
})
.map(card => this.parseCardToKnowledge(card));
return results;
}
async getKnowledgeById(cardId) {
const card = await this.makeRequest(`/cards/${cardId}`, {
params: {
fields: 'name,desc,labels,list',
labels: 'true',
list: 'true'
}
});
return this.parseCardToKnowledge(card);
}
async updateKnowledgeCard(cardId, { title, content, tags }) {
const updates = {};
if (title) updates.name = title;
if (content) {
// Get current card to preserve existing metadata
const currentCard = await this.getKnowledgeById(cardId);
updates.desc = this.formatCardDescription(
content,
tags || currentCard.tags,
currentCard.category
);
}
// Update card
const updatedCard = await this.makeRequest(`/cards/${cardId}`, {
method: 'PUT',
body: updates
});
// Update labels if tags provided
if (tags) {
// Remove all current labels
const currentCard = await this.makeRequest(`/cards/${cardId}`, {
params: { labels: 'true' }
});
for (const label of currentCard.labels) {
await this.makeRequest(`/cards/${cardId}/idLabels/${label.id}`, {
method: 'DELETE'
});
}
// Add new labels
for (const tag of tags) {
const label = await this.getOrCreateLabel(tag);
await this.makeRequest(`/cards/${cardId}/idLabels`, {
method: 'POST',
body: { value: label.id }
});
}
}
return {
id: updatedCard.id,
title: updatedCard.name
};
}
async getAllTopics() {
const lists = await this.makeRequest(`/boards/${this.knowledgeBoardId}/lists`);
return lists.map(list => list.name);
}
async getCardById(cardId) {
const card = await this.makeRequest(`/cards/${cardId}`, {
params: {
fields: 'name,desc,labels,list,board,url,shortUrl,due,members',
labels: 'true',
list: 'true',
board: 'true',
members: 'true'
}
});
return {
id: card.id,
title: card.name,
description: card.desc,
url: card.url,
shortUrl: card.shortUrl,
board: card.board ? {
id: card.board.id,
name: card.board.name
} : null,
list: card.list ? {
id: card.list.id,
name: card.list.name
} : null,
labels: card.labels.map(label => ({
id: label.id,
name: label.name,
color: label.color
})),
due: card.due,
members: card.members ? card.members.map(member => ({
id: member.id,
fullName: member.fullName,
username: member.username
})) : []
};
}
static parseCardIdFromUrl(urlOrId) {
if (!urlOrId || typeof urlOrId !== 'string') {
throw new Error('Invalid input: must provide a Trello card URL or card ID');
}
const trimmed = urlOrId.trim();
const urlPattern = /trello\.com\/c\/([a-zA-Z0-9]+)/;
const match = trimmed.match(urlPattern);
if (match) {
return match[1];
}
if (/^[a-zA-Z0-9]+$/.test(trimmed)) {
return trimmed;
}
throw new Error('Invalid Trello card URL or ID format');
}
parseCardToKnowledge(card) {
const { content, tags } = this.parseCardDescription(card.desc);
return {
id: card.id,
title: card.name,
content,
tags: card.labels.map(label => label.name),
category: card.list ? card.list.name : 'Unknown',
url: card.shortUrl
};
}
formatCardDescription(content, tags, category) {
const metadata = {
category: category || 'general',
tags: tags || [],
created: new Date().toISOString()
};
return `${content}\n\n---\n<!-- MCP Metadata: ${JSON.stringify(metadata)} -->`;
}
parseCardDescription(description) {
// Extract metadata if present
const metadataMatch = description.match(/<!-- MCP Metadata: (.*?) -->/);
let metadata = { tags: [], category: 'general' };
if (metadataMatch) {
try {
metadata = JSON.parse(metadataMatch[1]);
} catch (e) {
// Ignore parsing errors
}
}
// Remove metadata from content
const content = description.replace(/\n\n---\n<!-- MCP Metadata:.*?-->/, '').trim();
return {
content,
tags: metadata.tags || [],
category: metadata.category || 'general'
};
}
getRandomLabelColor() {
const colors = ['green', 'yellow', 'orange', 'red', 'purple', 'blue', 'sky', 'lime', 'pink', 'black'];
return colors[Math.floor(Math.random() * colors.length)];
}
capitalizeFirst(str) {
return str.charAt(0).toUpperCase() + str.slice(1);
}
}