UNPKG

@vibeplanner/mcp-server

Version:

MCP server for VibePlanner AI - Integrate project management and collaborative memory into Claude Desktop

673 lines 25.7 kB
#!/usr/bin/env node /** * Purpose: Main MCP client entry point implementing Model Context Protocol * Dependencies: @modelcontextprotocol/sdk, axios, shared types * * Example Input: * ``` * Tools called from Claude via MCP protocol * ``` * * Expected Output: * ``` * Tool responses with project, task, and document data * ``` */ import { Server } from '@modelcontextprotocol/sdk/server/index.js'; import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'; import { CallToolRequestSchema, ListToolsRequestSchema, ErrorCode, McpError, } from '@modelcontextprotocol/sdk/types.js'; import axios from 'axios'; import { config } from 'dotenv'; // Load environment variables from multiple locations config({ path: '.env' }); config({ path: '.env.local' }); config({ path: `${process.env.HOME}/.claude-collab/config` }); // API Configuration const API_BASE_URL = process.env.CLAUDE_COLLAB_API_URL ?? 'http://localhost:8100'; const API_KEY = process.env.API_KEY; const API_TIMEOUT = parseInt(process.env.CLAUDE_COLLAB_TIMEOUT ?? '30000'); // Create axios instance with defaults const api = axios.create({ baseURL: `${API_BASE_URL}/api`, timeout: API_TIMEOUT, headers: { 'Content-Type': 'application/json', ...(API_KEY !== null && API_KEY !== undefined && API_KEY !== '' ? { 'Authorization': `Bearer ${API_KEY}` } : {}) } }); // Response interceptor to parse date strings from API api.interceptors.response.use((response) => { // Parse date strings to Date objects for consistent handling const parseDate = (value) => { if (typeof value === 'string') { // Check if it's an ISO date string const dateRegex = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d{3})?Z?$/; if (dateRegex.test(value)) { return new Date(value); } } else if (value !== null && typeof value === 'object') { // Recursively parse objects const parsed = Array.isArray(value) ? [] : {}; for (const key in value) { parsed[key] = parseDate(value[key]); } return parsed; } return value; }; // Apply date parsing to response data if (response.data) { response.data = parseDate(response.data); } return response; }, (error) => { return Promise.reject(error); }); // Add request/response interceptors for better error handling api.interceptors.response.use(response => response, error => { const errorObj = error; // Extract detailed validation errors if available let message; const apiError = errorObj.response?.data?.error; if (apiError?.validationErrors && apiError.validationErrors.length > 0) { // Format validation errors with field names and specific messages const validationMessages = apiError.validationErrors .map(err => `${err.field}: ${err.message}`) .join('; '); message = validationMessages; } else { // Fall back to generic message message = apiError?.message ?? errorObj.message ?? 'Unknown API error'; } const status = errorObj.response?.status ?? 500; throw new McpError(status >= 500 ? ErrorCode.InternalError : ErrorCode.InvalidRequest, `API Error (${status}): ${message}`); }); // Initialize MCP server const server = new Server({ name: 'claude-collab-memory', version: '1.0.0', }, { capabilities: { tools: {}, }, }); // Tool definitions const TOOLS = [ { name: 'list_projects', description: 'List all projects in the collaborative memory system', inputSchema: { type: 'object', properties: { active_only: { type: 'boolean', description: 'Only return active projects', default: true }, limit: { type: 'number', description: 'Maximum number of projects to return', default: 20 } } } }, { name: 'get_project', description: 'Get detailed information about a specific project', inputSchema: { type: 'object', properties: { project_id: { type: 'string', description: 'UUID of the project to retrieve' } }, required: ['project_id'] } }, { name: 'create_project', description: 'Create a new project in the collaborative memory system', inputSchema: { type: 'object', properties: { name: { type: 'string', description: 'Name of the project' }, description: { type: 'string', description: 'Description of the project' }, tags: { type: 'array', items: { type: 'string' }, description: 'Tags for the project' } }, required: ['name'] } }, { name: 'list_tasks', description: 'List tasks with optional filtering', inputSchema: { type: 'object', properties: { project_id: { type: 'string', description: 'Filter tasks by project ID' }, status: { type: 'array', items: { type: 'string', enum: ['todo', 'in_progress', 'review', 'done', 'blocked'] }, description: 'Filter by task status' }, assigned_to: { type: 'string', description: 'Filter tasks assigned to specific person' }, limit: { type: 'number', description: 'Maximum number of tasks to return', default: 50 } } } }, { name: 'get_task', description: 'Get detailed information about a specific task', inputSchema: { type: 'object', properties: { task_id: { type: 'string', description: 'UUID of the task to retrieve' } }, required: ['task_id'] } }, { name: 'create_task', description: 'Create a new task', inputSchema: { type: 'object', properties: { project_id: { type: 'string', description: 'Project ID for the task' }, title: { type: 'string', description: 'Title of the task' }, description: { type: 'string', description: 'Detailed description of the task' }, task_type: { type: 'string', enum: ['epic', 'story', 'task', 'bug', 'component'], description: 'Type of task' }, priority: { type: 'string', enum: ['low', 'medium', 'high', 'critical'], description: 'Priority level', default: 'medium' }, status: { type: 'string', enum: ['todo', 'in_progress', 'review', 'done', 'blocked'], description: 'Initial status', default: 'todo' }, assigned_to: { type: 'string', description: 'Person assigned to the task' }, epic_id: { type: 'string', description: 'Parent epic ID if this is a story or task' }, story_id: { type: 'string', description: 'Parent story ID if this is a task' }, component_type: { type: 'string', enum: ['page', 'component', 'service', 'api_endpoint', 'other'], description: 'Type of component for development tasks' }, component_path: { type: 'string', description: 'File path for component-related tasks' }, tags: { type: 'array', items: { type: 'string' }, description: 'Tags for the task' } }, required: ['project_id', 'title', 'task_type'] } }, { name: 'update_task', description: 'Update an existing task', inputSchema: { type: 'object', properties: { task_id: { type: 'string', description: 'UUID of the task to update' }, title: { type: 'string', description: 'New title' }, description: { type: 'string', description: 'New description' }, status: { type: 'string', enum: ['todo', 'in_progress', 'review', 'done', 'blocked'], description: 'New status' }, priority: { type: 'string', enum: ['low', 'medium', 'high', 'critical'], description: 'New priority' }, assigned_to: { type: 'string', description: 'New assignee' }, tags: { type: 'array', items: { type: 'string' }, description: 'New tags' } }, required: ['task_id'] } }, { name: 'search_documents', description: 'Search for documents using text or vector search', inputSchema: { type: 'object', properties: { query: { type: 'string', description: 'Search query' }, search_type: { type: 'string', enum: ['text', 'vector', 'hybrid'], description: 'Type of search to perform', default: 'hybrid' }, project_id: { type: 'string', description: 'Filter by project ID' }, document_type: { type: 'array', items: { type: 'string', enum: ['plan', 'specification', 'notes', 'code', 'other'] }, description: 'Filter by document types' }, limit: { type: 'number', description: 'Maximum number of results', default: 20 } }, required: ['query'] } }, { name: 'get_document', description: 'Get a specific document by ID', inputSchema: { type: 'object', properties: { document_id: { type: 'string', description: 'UUID of the document to retrieve' } }, required: ['document_id'] } }, { name: 'create_document', description: 'Create a new document', inputSchema: { type: 'object', properties: { project_id: { type: 'string', description: 'Project ID (required if no task_id)' }, task_id: { type: 'string', description: 'Task ID (required if no project_id)' }, title: { type: 'string', description: 'Document title' }, content: { type: 'string', description: 'Document content' }, type: { type: 'string', enum: ['plan', 'specification', 'notes', 'code', 'other'], description: 'Type of document' } }, required: ['title', 'type'] } }, { name: 'update_document', description: 'Update an existing document', inputSchema: { type: 'object', properties: { document_id: { type: 'string', description: 'UUID of the document to update' }, title: { type: 'string', description: 'New title' }, content: { type: 'string', description: 'New content' }, type: { type: 'string', enum: ['plan', 'specification', 'notes', 'code', 'other'], description: 'New document type' } }, required: ['document_id'] } } ]; // Register tool handlers server.setRequestHandler(ListToolsRequestSchema, () => { return { tools: TOOLS }; }); server.setRequestHandler(CallToolRequestSchema, async (request) => { const { name, arguments: args } = request.params; const toolArgs = args ?? {}; try { switch (name) { case 'list_projects': return await handleListProjects(toolArgs); case 'get_project': return await handleGetProject(toolArgs); case 'create_project': return await handleCreateProject(toolArgs); case 'list_tasks': return await handleListTasks(toolArgs); case 'get_task': return await handleGetTask(toolArgs); case 'create_task': return await handleCreateTask(toolArgs); case 'update_task': return await handleUpdateTask(toolArgs); case 'search_documents': return await handleSearchDocuments(toolArgs); case 'get_document': return await handleGetDocument(toolArgs); case 'create_document': return await handleCreateDocument(toolArgs); case 'update_document': return await handleUpdateDocument(toolArgs); default: throw new McpError(ErrorCode.MethodNotFound, `Unknown tool: ${name}`); } } catch (error) { if (error instanceof McpError) { throw error; } throw new McpError(ErrorCode.InternalError, `Tool execution failed: ${error instanceof Error ? error.message : String(error)}`); } }); // Tool handlers async function handleListProjects(args) { const { active_only = true, limit = 20 } = args; const response = await api.get('/projects', { params: { is_active: active_only, limit, page: 1 } }); const projects = response.data.data.data; return { content: [{ type: 'text', text: `Found ${projects.length} projects:\n\n${projects.map((p) => `• **${p.name}** (${p.id})\n ${p.description ?? 'No description'}\n Tags: ${p.tags.join(', ') !== '' ? p.tags.join(', ') : 'None'}`).join('\n\n')}` }] }; } async function handleGetProject(args) { const { project_id } = args; if (typeof project_id !== 'string') { throw new McpError(ErrorCode.InvalidParams, 'project_id must be a string'); } const response = await api.get(`/projects/${project_id}`); const project = response.data.data; return { content: [{ type: 'text', text: `**Project: ${project.name}**\n\nID: ${project.id}\nDescription: ${project.description ?? 'No description'}\nTags: ${project.tags.join(', ') !== '' ? project.tags.join(', ') : 'None'}\nActive: ${project.is_active}\nCreated: ${project.created_at instanceof Date ? project.created_at.toISOString() : new Date(project.created_at).toISOString()}\nCreated by: ${project.created_by}` }] }; } async function handleCreateProject(args) { const { name, description, tags } = args; if (typeof name !== 'string') { throw new McpError(ErrorCode.InvalidParams, 'name must be a string'); } const projectData = { name, description: typeof description === 'string' ? description : undefined, tags: Array.isArray(tags) ? tags : [], created_by: 'claude-ai' }; const response = await api.post('/projects', projectData); const project = response.data.data; return { content: [{ type: 'text', text: `✅ Created project: **${project.name}** (${project.id})` }] }; } async function handleListTasks(args) { const { project_id, status, assigned_to, limit = 50 } = args; const params = { limit, page: 1 }; if (project_id !== null && project_id !== undefined && project_id !== '') params.project_id = project_id; if (Array.isArray(status)) params.status = status; if (assigned_to !== null && assigned_to !== undefined && assigned_to !== '') params.assigned_to = assigned_to; const response = await api.get('/tasks', { params }); const tasks = response.data.data.data; return { content: [{ type: 'text', text: `Found ${tasks.length} tasks:\n\n${tasks.map((t) => `• **${t.title}** [${t.status.toUpperCase()}] (${t.id})\n Type: ${t.task_type} | Priority: ${t.priority}\n ${t.description !== null && t.description !== undefined && t.description !== '' ? t.description.substring(0, 100) + '...' : 'No description'}\n Assigned: ${t.assigned_to ?? 'Unassigned'}`).join('\n\n')}` }] }; } async function handleGetTask(args) { const { task_id } = args; if (typeof task_id !== 'string') { throw new McpError(ErrorCode.InvalidParams, 'task_id must be a string'); } const response = await api.get(`/tasks/${task_id}`); const task = response.data.data; return { content: [{ type: 'text', text: `**Task: ${task.title}**\n\nID: ${task.id}\nType: ${task.task_type}\nStatus: ${task.status}\nPriority: ${task.priority}\nAssigned: ${task.assigned_to ?? 'Unassigned'}\nProject: ${task.project_id}\n\nDescription:\n${task.description !== null && task.description !== undefined && task.description !== '' ? task.description : 'No description'}\n\nTags: ${task.tags.join(', ') !== null && task.tags.join(', ') !== undefined && task.tags.join(', ') !== '' ? task.tags.join(', ') : 'None'}` }] }; } async function handleCreateTask(args) { const { project_id, title, description, task_type, priority = 'medium', status = 'todo', assigned_to, epic_id, story_id, component_type, component_path, tags } = args; if (typeof project_id !== 'string' || typeof title !== 'string' || typeof task_type !== 'string') { throw new McpError(ErrorCode.InvalidParams, 'project_id, title, and task_type are required'); } const taskData = { project_id, title, description: typeof description === 'string' ? description : undefined, task_type: task_type, priority: priority, status: status, assigned_to: typeof assigned_to === 'string' ? assigned_to : undefined, epic_id: typeof epic_id === 'string' ? epic_id : undefined, story_id: typeof story_id === 'string' ? story_id : undefined, component_type: typeof component_type === 'string' ? component_type : undefined, component_path: typeof component_path === 'string' ? component_path : undefined, tags: Array.isArray(tags) ? tags : [], created_by: 'claude-ai' }; const response = await api.post('/tasks', taskData); const task = response.data.data; return { content: [{ type: 'text', text: `✅ Created task: **${task.title}** [${task.status.toUpperCase()}] (${task.id})` }] }; } async function handleUpdateTask(args) { const { task_id, ...updates } = args; if (typeof task_id !== 'string') { throw new McpError(ErrorCode.InvalidParams, 'task_id must be a string'); } const response = await api.put(`/tasks/${task_id}`, updates); const task = response.data.data; return { content: [{ type: 'text', text: `✅ Updated task: **${task.title}** [${task.status.toUpperCase()}] (${task.id})` }] }; } async function handleSearchDocuments(args) { const { query, search_type = 'hybrid', project_id, document_type, limit = 20 } = args; if (typeof query !== 'string') { throw new McpError(ErrorCode.InvalidParams, 'query must be a string'); } const params = { query, type: search_type, limit, page: 1 }; if (project_id !== null && project_id !== undefined && project_id !== '') params.project_id = project_id; if (Array.isArray(document_type)) params.document_type = document_type; const response = await api.get('/documents/search', { params }); const searchResults = response.data.data; return { content: [{ type: 'text', text: `Found ${searchResults.data.length} documents for "${query}":\n\n${searchResults.data.map((result) => { const doc = result.item; return `• **${doc.title}** (Score: ${result.score.toFixed(2)}) [${doc.type}]\n ID: ${doc.id}\n ${(doc.content !== null && doc.content !== undefined && doc.content !== '') ? doc.content.substring(0, 150) + '...' : 'No content preview'}`; }).join('\n\n')}` }] }; } async function handleGetDocument(args) { const { document_id } = args; if (typeof document_id !== 'string') { throw new McpError(ErrorCode.InvalidParams, 'document_id must be a string'); } const response = await api.get(`/documents/${document_id}`); const document = response.data.data; return { content: [{ type: 'text', text: `**Document: ${document.title}**\n\nID: ${document.id}\nType: ${document.type}\nVersion: ${document.version}\nProject: ${document.project_id ?? 'N/A'}\nTask: ${document.task_id ?? 'N/A'}\nCreated: ${document.created_at instanceof Date ? document.created_at.toISOString() : new Date(document.created_at).toISOString()}\n\nContent:\n${document.content !== null && document.content !== undefined && document.content !== '' ? document.content : 'No content'}` }] }; } async function handleCreateDocument(args) { const { project_id, task_id, title, content, type } = args; if (typeof title !== 'string' || typeof type !== 'string') { throw new McpError(ErrorCode.InvalidParams, 'title and type are required'); } if ((project_id === null || project_id === undefined || project_id === '') && (task_id === null || task_id === undefined || task_id === '')) { throw new McpError(ErrorCode.InvalidParams, 'Either project_id or task_id must be provided'); } const documentData = { project_id: typeof project_id === 'string' ? project_id : undefined, task_id: typeof task_id === 'string' ? task_id : undefined, title, content: typeof content === 'string' ? content : undefined, type: type, created_by: 'claude-ai' }; const response = await api.post('/documents', documentData); const document = response.data.data; return { content: [{ type: 'text', text: `✅ Created document: **${document.title}** [${document.type}] (${document.id})` }] }; } async function handleUpdateDocument(args) { const { document_id, ...updates } = args; if (typeof document_id !== 'string') { throw new McpError(ErrorCode.InvalidParams, 'document_id must be a string'); } const response = await api.put(`/documents/${document_id}`, updates); const document = response.data.data; return { content: [{ type: 'text', text: `✅ Updated document: **${document.title}** [${document.type}] (${document.id})` }] }; } // Start the server async function main() { const transport = new StdioServerTransport(); await server.connect(transport); console.error('Claude Collaborative Memory MCP Client started'); } main().catch((error) => { console.error('Server failed to start:', error); process.exit(1); }); //# sourceMappingURL=index.js.map