mcp-planes-server
Version:
Plane API client and server
255 lines (254 loc) • 10.5 kB
JavaScript
import axios from 'axios';
import fetch from 'node-fetch';
import { McpError } from '@modelcontextprotocol/sdk/types.js';
export class PlanesClient {
workspaceSlug;
client;
constructor(baseURL, apiKey, workspaceSlug) {
if (!baseURL) {
throw new Error('Base URL is required');
}
if (!apiKey) {
throw new Error('API key is required');
}
if (!workspaceSlug) {
throw new Error('Workspace slug is required');
}
this.workspaceSlug = workspaceSlug;
this.client = axios.create({
baseURL,
headers: {
'X-API-Key': apiKey,
'Content-Type': 'application/json',
},
});
}
async request(path, method = "GET", body) {
try {
const url = `${this.client.defaults.baseURL}/api/v1/workspaces/${this.workspaceSlug}${path}`;
const response = await fetch(url, {
method,
headers: {
"Content-Type": "application/json",
"X-API-Key": this.client.defaults.headers['X-API-Key'],
},
body: body ? JSON.stringify(body) : undefined,
});
if (!response.ok) {
const errorText = await response.text();
throw new Error(`HTTP error! status: ${response.status}, message: ${errorText}`);
}
return await response.json();
}
catch (error) {
console.error('Request failed:', error);
throw error;
}
}
// Project operations
async getProjects() {
return this.request("/projects");
}
// 可根据语义相似度查询项目,即关键字,需要查出来所有的项目后,然后根据关键字,查出来相似度最高的项目
// identifier 是项目唯一标识,可以用来查询项目
async getProject(input) {
// 如果有projectId,直接查询
if (input.projectId) {
return this.request(`/projects/${input.projectId}/`, "GET");
}
// 如果有identifier,使用identifier查询
if (input.identifier) {
const projects = await this.getProjects();
const project = projects.results.find((p) => p.identifier === input.identifier);
if (project) {
return project;
}
throw new Error(`Project with identifier ${input.identifier} not found`);
}
// 如果有keyword,进行语义相似度搜索
if (input.keyword) {
const projects = await this.getProjects();
if (projects.results.length === 0) {
throw new Error('No projects found');
}
// 计算每个项目名称和描述与关键字的相似度
const projectsWithScore = projects.results.map((project) => {
const nameScore = this.calculateSimilarity(project.name.toLowerCase(), input.keyword.toLowerCase());
const descScore = project.description ?
this.calculateSimilarity(project.description.toLowerCase(), input.keyword.toLowerCase()) : 0;
// 名称相似度权重更高
return {
project,
score: nameScore * 0.7 + descScore * 0.3
};
}).filter((p) => p.score > 0.5);
if (projectsWithScore.length === 0) {
return null;
}
// 按相似度降序排序
projectsWithScore.sort((a, b) => b.score - a.score);
// 返回最相似的项目
return projectsWithScore[0].project;
}
throw new Error('Must provide either projectId, identifier, or keyword');
}
// 计算两个字符串的相似度(使用 Levenshtein 距离的简化版本)
calculateSimilarity(str1, str2) {
const len1 = str1.length;
const len2 = str2.length;
// 如果字符串完全相同,返回1
if (str1 === str2)
return 1;
// 如果其中一个字符串为空,返回0
if (len1 === 0 || len2 === 0)
return 0;
// 计算包含关系的分数
const contains = str1.includes(str2) || str2.includes(str1);
if (contains)
return 0.8;
// 计算公共子串的长度
let commonChars = 0;
const str2Chars = new Set(str2);
for (const char of str1) {
if (str2Chars.has(char)) {
commonChars++;
}
}
// 返回基于公共字符的相似度分数
return commonChars / Math.max(len1, len2);
}
// 创建项目,如果存在,则更新
// keyword 是关键字,通过关键字查询项目,如果存在,则更新,否则创建
async createProject(input) {
try {
// 如果keyword和name都存在,则使用keyword查询
const project = await this.getProject({ keyword: input.keyword || input.name });
if (project) {
throw new Error(`Project with keyword ${input.keyword} already exists`);
}
return this.request("/projects/", "POST", input);
}
catch (error) {
if (error instanceof McpError) {
throw error;
}
throw error;
}
}
async updateProject(input) {
return this.request(`/projects/${input.projectId}/`, "PATCH", input);
}
async deleteProject(input) {
return this.request(`/projects/${input.projectId}/`, "DELETE");
}
// Issue operations
async getIssues(input) {
return this.request(`/projects/${input.projectId}/issues/`);
}
async getIssue(input) {
if (input.issueId) {
return this.request(`/projects/${input.projectId}/issues/${input.issueId}/`);
}
if (input.sequenceId) {
return this.request(`/projects/${input.projectId}/issues/${input.sequenceId}/`);
}
throw new Error('Must provide either issueId or sequenceId');
}
async createIssue(input) {
return this.request(`/projects/${input.projectId}/issues/`, "POST", input);
}
async updateIssue(input) {
return this.request(`/projects/${input.projectId}/issues/${input.issueId}/`, "PATCH", input);
}
async deleteIssue(input) {
return this.request(`/projects/${input.projectId}/issues/${input.issueId}/`, "DELETE");
}
// Label operations
async getLabels(input) {
return this.request(`/projects/${input.projectId}/labels/`);
}
async createLabel(input) {
return this.request(`/projects/${input.projectId}/labels/`, "POST", input);
}
async updateLabel(input) {
return this.request(`/projects/${input.projectId}/labels/${input.labelId}/`, "PATCH", input);
}
async deleteLabel(input) {
return this.request(`/projects/${input.projectId}/labels/${input.labelId}/`, "DELETE");
}
// Comment operations
async getComments(input) {
return this.request(`/projects/${input.projectId}/issues/${input.issueId}/comments/`);
}
async createComment(input) {
return this.request(`/projects/${input.projectId}/issues/${input.issueId}/comments/`, "POST", input);
}
async updateComment(input) {
return this.request(`/projects/${input.projectId}/issues/${input.issueId}/comments/${input.commentId}/`, "PATCH", input);
}
async deleteComment(input) {
return this.request(`/projects/${input.projectId}/issues/${input.issueId}/comments/${input.commentId}/`, "DELETE");
}
// member operations
async getMembers(input) {
return this.request(`/projects/${input.projectId}/members/`);
}
async getMember(input) {
return this.request(`/projects/${input.projectId}/members/${input.memberId}/`);
}
async createMember(input) {
return this.request(`/projects/${input.projectId}/members/`, "POST", input);
}
async updateMember(input) {
return this.request(`/projects/${input.projectId}/members/${input.memberId}/`, "PATCH", input);
}
async deleteMember(input) {
return this.request(`/projects/${input.projectId}/members/${input.memberId}/`, "DELETE");
}
// States
async getStates(input) {
return this.request(`/projects/${input.projectId}/states/`);
}
async createState(input) {
return this.request(`/projects/${input.projectId}/states/`, "POST", input);
}
async updateState(input) {
return this.request(`/projects/${input.projectId}/states/${input.stateId}/`, "PATCH", input);
}
async deleteState(input) {
return this.request(`/projects/${input.projectId}/states/${input.stateId}/`, "DELETE");
}
// Cycle operations
async getCycles(input) {
return this.request(`/projects/${input.projectId}/cycles/`);
}
// 可根据语义相似度查询项目,即关键字,需要查出来所有的项目后,然后根据关键字,查出来相似度最高的项目
// identifier 是项目唯一标识,可以用来查询项目
async getCycle(input) {
if (input.cycleId) {
return this.request(`/projects/${input.projectId}/cycles/${input.cycleId}/`);
}
if (input.keyword) {
const cycles = await this.getCycles(input);
if (cycles.results.length === 0) {
throw new Error('No cycles found');
}
const cycle = cycles.results.find((cycle) => this.calculateSimilarity(cycle.name.toLowerCase(), input.keyword.toLowerCase()) > 0.5);
if (cycle) {
return cycle;
}
throw new Error(`Cycle with keyword ${input.keyword} not found`);
}
throw new Error('Must provide either cycleId or keyword');
}
async createCycle(input) {
return this.request(`/projects/${input.projectId}/cycles/`, "POST", input);
}
async updateCycle(input) {
return this.request(`/projects/${input.projectId}/cycles/${input.cycleId}/`, "PATCH", input);
}
async deleteCycle(input) {
return this.request(`/projects/${input.projectId}/cycles/${input.cycleId}/`, "DELETE");
}
}
export const client = new PlanesClient(process.env.PLANE_API_URL || "https://app.plane.so", process.env.PLANE_API_KEY || "", process.env.PLANE_WORKSPACE_SLUG || "");