UNPKG

@vibeplanner/mcp-server

Version:

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

1,171 lines 48.5 kB
/** * Purpose: MCP Server entry point for Claude Desktop integration * Dependencies: @modelcontextprotocol/sdk, shared types * * This file provides the main entry point for the MCP server that Claude Desktop * can connect to via stdio transport. It's designed to be run as a standalone * process that communicates with Claude via stdin/stdout. */ 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'; import { readFileSync } from 'fs'; import { fileURLToPath } from 'url'; import { dirname, join } from 'path'; // Get package version dynamically const __filename = fileURLToPath(import.meta.url); const __dirname = dirname(__filename); const packageJson = JSON.parse(readFileSync(join(__dirname, '..', 'package.json'), 'utf-8')); const VERSION = packageJson.version; // Check for command-line arguments first if (process.argv.includes('--version') || process.argv.includes('-v')) { console.log(VERSION); process.exit(0); } if (process.argv.includes('--help') || process.argv.includes('-h')) { console.log(`VibePlanner MCP Server v${VERSION}`); console.log('Integrate project management and collaborative memory into Claude Desktop'); console.log(''); console.log('Usage: npx @vibeplanner/mcp-server'); console.log(''); console.log('Environment variables:'); console.log(' CLAUDE_COLLAB_API_URL - API base URL (default: http://localhost:8100)'); console.log(' API_KEY - Authentication API key (required)'); console.log(' CLAUDE_COLLAB_TIMEOUT - Request timeout in ms (default: 30000)'); process.exit(0); } // Check if running in TTY mode (not being piped or used by Claude Desktop) // When Claude Desktop runs us, stdin won't be a TTY and we should NOT output anything // to stdout as it would corrupt the JSON-RPC protocol if (process.stdin.isTTY && !process.env.MCP_RUN) { // Only show this message when directly run in a terminal console.error(`VibePlanner MCP Server v${VERSION}`); console.error('This server is designed to be run by Claude Desktop via stdio transport.'); console.error(''); console.error('To use with Claude Desktop, add to your configuration:'); console.error(' "vibeplanner": {'); console.error(' "command": "npx",'); console.error(' "args": ["@vibeplanner/mcp-server@latest"]'); console.error(' }'); console.error(''); console.error('Use --help for more information.'); process.exit(0); } // Suppress dotenv output to avoid corrupting MCP protocol process.env.DOTENV_QUIET = 'true'; // Load environment variables config({ path: '.env' }); config({ path: '.env.local' }); config({ path: `${process.env.HOME}/.claude-collab/config` }); // Validate environment configuration const validateMcpEnvironment = () => { const apiBaseUrl = process.env.CLAUDE_COLLAB_API_URL ?? 'http://localhost:8100'; const apiKey = process.env.API_KEY; const timeout = parseInt(process.env.CLAUDE_COLLAB_TIMEOUT ?? '30000'); if (apiKey === null || apiKey === undefined || typeof apiKey !== 'string' || apiKey.trim().length === 0) { throw new Error('API_KEY environment variable is required'); } if (apiKey.length < 32) { throw new Error('API_KEY must be at least 32 characters for security'); } // Prevent insecure development tokens const insecureKeys = ['dev-token', 'test-key', 'development', 'localhost', 'admin', 'password']; if (insecureKeys.some(key => apiKey.toLowerCase().includes(key))) { throw new Error('API_KEY contains insecure patterns and must be changed'); } if (timeout < 1000 || timeout > 300000) { throw new Error('CLAUDE_COLLAB_TIMEOUT must be between 1000ms and 300000ms'); } return { apiBaseUrl, apiKey, timeout }; }; // API Configuration with validation const { apiBaseUrl: API_BASE_URL, apiKey: API_KEY, timeout: API_TIMEOUT } = validateMcpEnvironment(); // Create axios instance with proper error handling const api = axios.create({ baseURL: `${API_BASE_URL}/api`, timeout: API_TIMEOUT, headers: { 'Content-Type': 'application/json', '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); }); // Enhanced error handling with circuit breaker pattern let consecutiveErrors = 0; const MAX_CONSECUTIVE_ERRORS = 5; const CIRCUIT_BREAKER_TIMEOUT = 30000; // 30 seconds api.interceptors.response.use(response => { consecutiveErrors = 0; // Reset on successful response return response; }, (error) => { consecutiveErrors++; if (consecutiveErrors >= MAX_CONSECUTIVE_ERRORS) { console.error(`Circuit breaker activated after ${MAX_CONSECUTIVE_ERRORS} consecutive errors`); setTimeout(() => { consecutiveErrors = 0; console.error('Circuit breaker reset'); }, CIRCUIT_BREAKER_TIMEOUT); } 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 ?? 'API connection failed'; } const status = errorObj.response?.status ?? 500; // Log error for debugging console.error(`API Error (${status}): ${message}`); throw new McpError(status >= 500 ? ErrorCode.InternalError : ErrorCode.InvalidRequest, `API Error (${status}): ${message}`); }); // Initialize MCP server with proper capabilities const server = new Server({ name: 'claude-collab-memory', version: '1.0.0', }, { capabilities: { tools: {}, }, }); // Tool definitions with comprehensive schemas 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, minimum: 1, maximum: 100 } }, additionalProperties: false } }, { 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', pattern: '^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$' } }, required: ['project_id'], additionalProperties: false } }, { 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', minLength: 1, }, description: { type: 'string', description: 'Description of the project', }, tags: { type: 'array', items: { type: 'string' }, description: 'Tags for the project', maxItems: 20 } }, required: ['name'], additionalProperties: false } }, { name: 'list_tasks', description: 'List tasks with optional filtering', inputSchema: { type: 'object', properties: { project_id: { type: 'string', description: 'Filter tasks by project ID', pattern: '^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$' }, status: { type: 'array', items: { type: 'string', enum: ['todo', 'in_progress', 'review', 'done', 'blocked'] }, description: 'Filter by task status', maxItems: 5 }, assigned_to: { type: 'string', description: 'Filter tasks assigned to specific person', }, limit: { type: 'number', description: 'Maximum number of tasks to return', default: 50, minimum: 1, maximum: 200 } }, additionalProperties: false } }, { name: 'list_epics', description: 'List all epics (tasks with type "epic") to see available parent epics for creating tasks', inputSchema: { type: 'object', properties: { project_id: { type: 'string', description: 'Filter epics by project ID', pattern: '^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$' }, limit: { type: 'number', description: 'Maximum number of epics to return', default: 20, minimum: 1, maximum: 100 } }, additionalProperties: false } }, { 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', pattern: '^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$' } }, required: ['task_id'], additionalProperties: false } }, { name: 'create_task', description: 'Create a new task. IMPORTANT: For task_type "task", epic_id is required due to database constraints.', inputSchema: { type: 'object', properties: { project_id: { type: 'string', description: 'Project ID for the task', pattern: '^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$' }, title: { type: 'string', description: 'Title of the task', minLength: 1, }, 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 - REQUIRED for task_type "task". Create an epic first if needed.', pattern: '^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$' }, story_id: { type: 'string', description: 'Parent story ID if this is a task', pattern: '^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$' }, 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', maxItems: 20 } }, required: ['project_id', 'title', 'task_type'], additionalProperties: false } }, { name: 'update_task', description: 'Update an existing task', inputSchema: { type: 'object', properties: { task_id: { type: 'string', description: 'UUID of the task to update', pattern: '^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$' }, title: { type: 'string', description: 'New title', minLength: 1, }, 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', maxItems: 20 } }, required: ['task_id'], additionalProperties: false } }, { name: 'search_documents', description: 'Search for documents using text or vector search', inputSchema: { type: 'object', properties: { query: { type: 'string', description: 'Search query', minLength: 1, }, search_type: { type: 'string', enum: ['fulltext', 'semantic', 'hybrid', 'unified'], description: 'Type of search to perform', default: 'hybrid' }, project_id: { type: 'string', description: 'Filter by project ID', pattern: '^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$' }, document_type: { type: 'array', items: { type: 'string', enum: ['plan', 'specification', 'notes', 'code', 'other'] }, description: 'Filter by document types', maxItems: 5 }, limit: { type: 'number', description: 'Maximum number of results', default: 20, minimum: 1, maximum: 100 } }, required: ['query'], additionalProperties: false } }, { 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', pattern: '^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$' } }, required: ['document_id'], additionalProperties: false } }, { name: 'create_document', description: 'Create a new document', inputSchema: { type: 'object', properties: { project_id: { type: 'string', description: 'Project ID (required if no task_id)', pattern: '^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$' }, task_id: { type: 'string', description: 'Task ID (required if no project_id)', pattern: '^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$' }, title: { type: 'string', description: 'Document title', minLength: 1, }, content: { type: 'string', description: 'Document content', }, type: { type: 'string', enum: ['plan', 'specification', 'notes', 'code', 'other'], description: 'Type of document' } }, required: ['title', 'type'], additionalProperties: false } }, { name: 'update_document', description: 'Update an existing document', inputSchema: { type: 'object', properties: { document_id: { type: 'string', description: 'UUID of the document to update', pattern: '^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$' }, title: { type: 'string', description: 'New title', minLength: 1, }, content: { type: 'string', description: 'New content', }, type: { type: 'string', enum: ['plan', 'specification', 'notes', 'code', 'other'], description: 'New document type' } }, required: ['document_id'], additionalProperties: false } }, { name: 'list_task_comments', description: 'List all comments for a specific task', inputSchema: { type: 'object', properties: { task_id: { type: 'string', description: 'UUID of the task to get comments for', pattern: '^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$' }, limit: { type: 'number', description: 'Maximum number of comments to return', default: 20, minimum: 1, maximum: 100 }, page: { type: 'number', description: 'Page number for pagination', default: 1, minimum: 1 } }, required: ['task_id'], additionalProperties: false } }, { name: 'create_task_comment', description: 'Add a new comment to a task. Supports replying to existing comments using parent_comment_id.', inputSchema: { type: 'object', properties: { task_id: { type: 'string', description: 'UUID of the task to comment on', pattern: '^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$' }, content: { type: 'string', description: 'Comment content', minLength: 1, }, author: { type: 'string', description: 'Name of the comment author', minLength: 1, }, parent_comment_id: { type: 'string', description: 'UUID of parent comment if this is a reply', pattern: '^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$' } }, required: ['task_id', 'content', 'author'], additionalProperties: false } }, { name: 'get_comment', description: 'Get a specific comment by ID', inputSchema: { type: 'object', properties: { comment_id: { type: 'string', description: 'UUID of the comment to retrieve', pattern: '^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$' } }, required: ['comment_id'], additionalProperties: false } }, { name: 'update_comment', description: 'Update an existing comment', inputSchema: { type: 'object', properties: { comment_id: { type: 'string', description: 'UUID of the comment to update', pattern: '^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$' }, content: { type: 'string', description: 'New comment content', minLength: 1, } }, required: ['comment_id', 'content'], additionalProperties: false } }, { name: 'delete_comment', description: 'Delete a comment permanently', inputSchema: { type: 'object', properties: { comment_id: { type: 'string', description: 'UUID of the comment to delete', pattern: '^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$' } }, required: ['comment_id'], additionalProperties: false } } ]; // Register request handlers server.setRequestHandler(ListToolsRequestSchema, () => { return { tools: TOOLS }; }); server.setRequestHandler(CallToolRequestSchema, async (request) => { const { name, arguments: args } = request.params; const toolArgs = args ?? {}; // Circuit breaker check if (consecutiveErrors >= MAX_CONSECUTIVE_ERRORS) { throw new McpError(ErrorCode.InternalError, 'Service temporarily unavailable due to repeated errors'); } 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 'list_epics': return await handleListEpics(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); case 'list_task_comments': return await handleListTaskComments(toolArgs); case 'create_task_comment': return await handleCreateTaskComment(toolArgs); case 'get_comment': return await handleGetComment(toolArgs); case 'update_comment': return await handleUpdateComment(toolArgs); case 'delete_comment': return await handleDeleteComment(toolArgs); default: throw new McpError(ErrorCode.MethodNotFound, `Unknown tool: ${name}`); } } catch (error) { if (error instanceof McpError) { throw error; } const errorMessage = error instanceof Error ? error.message : String(error); console.error(`Tool execution failed for ${name}:`, errorMessage); throw new McpError(ErrorCode.InternalError, `Tool execution failed: ${errorMessage}`); } }); // Tool handler implementations (imported from main index.ts) 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; return { content: [{ type: 'text', text: `Found ${projects.length} projects:\n\n${projects.map((p) => `• **${p.name}** (${p.id})\n ${p.description !== undefined && p.description !== null && p.description !== '' ? 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 !== undefined && project.description !== null && project.description !== '' ? 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 !== undefined && project_id !== null && project_id !== '') params.project_id = project_id; if (Array.isArray(status)) params.status = status; if (assigned_to !== undefined && assigned_to !== null && assigned_to !== '') params.assigned_to = assigned_to; const response = await api.get('/tasks', { params }); const tasks = response.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 !== undefined && t.description !== null && t.description !== '' ? t.description.substring(0, 100) + '...' : 'No description'}\n Assigned: ${t.assigned_to ?? 'Unassigned'}`).join('\n\n')}` }] }; } async function handleListEpics(args) { const { project_id, limit = 20 } = args; const params = { task_type: 'epic', limit, page: 1 }; if (project_id !== undefined && project_id !== null && project_id !== '') params.project_id = project_id; const response = await api.get('/tasks', { params }); const epics = response.data.data; return { content: [{ type: 'text', text: `Found ${epics.length} epics:\n\n${epics.map((epic) => `• **${epic.title}** [${epic.status.toUpperCase()}] (${epic.id})\n Priority: ${epic.priority}\n ${epic.description !== null && epic.description !== undefined ? epic.description.substring(0, 100) + '...' : 'No description'}\n Assigned: ${epic.assigned_to ?? 'Unassigned'}`).join('\n\n')}\n\n💡 Use these epic IDs when creating tasks with task_type "task"` }] }; } 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 ?? 'No description'}\n\nTags: ${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, parent_task_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'); } // Validate epic_id requirements based on task hierarchy rules if (task_type === 'epic' && epic_id !== undefined && epic_id !== null && epic_id !== '') { throw new McpError(ErrorCode.InvalidParams, 'Epic tasks cannot have epic_id'); } if (task_type === 'story' && (epic_id === undefined || epic_id === null || epic_id === '')) { throw new McpError(ErrorCode.InvalidParams, 'Story tasks must have epic_id'); } if (task_type === 'task' && (epic_id === undefined || epic_id === null || epic_id === '')) { throw new McpError(ErrorCode.InvalidParams, 'Task records must have epic_id'); } if ((task_type === 'bug' || task_type === 'component') && (epic_id === undefined || epic_id === null || epic_id === '') && (story_id === undefined || story_id === null || story_id === '') && (parent_task_id === undefined || parent_task_id === null || parent_task_id === '')) { throw new McpError(ErrorCode.InvalidParams, `${task_type} tasks must have either epic_id, story_id, or parent_task_id`); } 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, parent_task_id: typeof parent_task_id === 'string' ? parent_task_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'); } // Map MCP search types to API search types (including backward compatibility) const searchTypeMap = { 'text': 'fulltext', // Backward compatibility 'vector': 'semantic', // Backward compatibility 'fulltext': 'fulltext', 'semantic': 'semantic', 'hybrid': 'hybrid', 'unified': 'unified' }; const apiSearchType = searchTypeMap[search_type] ?? 'hybrid'; const params = { query, type: apiSearchType, // API expects 'type' not 'search_type' limit, page: 1 }; if (project_id !== null && project_id !== undefined) params.project_id = project_id; if (Array.isArray(document_type)) params.document_type = document_type; const response = await api.get('/search', { params }); // API returns structure: { data: { documents: [], tasks: [], projects: [], total_results: number } } const searchData = response.data.data; // Combine all results from documents, tasks, and projects const allResults = []; let totalCount = 0; // Add documents if (searchData.documents && searchData.documents.length > 0) { allResults.push('**Documents:**'); searchData.documents.forEach((doc) => { const score = doc.similarity !== undefined ? ` (Similarity: ${doc.similarity.toFixed(3)})` : doc.relevance_score !== undefined ? ` (Relevance: ${doc.relevance_score.toFixed(3)})` : ''; const content = (doc.content && doc.content.trim() !== '') ? doc.content.substring(0, 150) + (doc.content.length > 150 ? '...' : '') : 'No content preview'; allResults.push(`• **${doc.title}**${score} [${doc.type}]\n ID: ${doc.id}\n ${content}`); }); totalCount += searchData.documents.length; } // Add tasks if (searchData.tasks && searchData.tasks.length > 0) { if (allResults.length > 0) allResults.push(''); // Add spacing allResults.push('**Tasks:**'); searchData.tasks.forEach((task) => { const score = task.similarity !== undefined ? ` (Similarity: ${task.similarity.toFixed(3)})` : task.relevance_score !== undefined ? ` (Relevance: ${task.relevance_score.toFixed(3)})` : ''; const description = (task.description && task.description.trim() !== '') ? task.description.substring(0, 150) + (task.description.length > 150 ? '...' : '') : 'No description'; allResults.push(`• **${task.title}**${score} [${task.status}]\n ID: ${task.id}\n ${description}`); }); totalCount += searchData.tasks.length; } // Add projects if (searchData.projects && searchData.projects.length > 0) { if (allResults.length > 0) allResults.push(''); // Add spacing allResults.push('**Projects:**'); searchData.projects.forEach((project) => { const score = project.similarity !== undefined ? ` (Similarity: ${project.similarity.toFixed(3)})` : project.relevance_score !== undefined ? ` (Relevance: ${project.relevance_score.toFixed(3)})` : ''; const description = (project.description && project.description.trim() !== '') ? project.description.substring(0, 150) + (project.description.length > 150 ? '...' : '') : 'No description'; allResults.push(`• **${project.name}**${score}\n ID: ${project.id}\n ${description}`); }); totalCount += searchData.projects.length; } // Use total_results from API if available, otherwise use our count const finalCount = searchData.total_results ?? totalCount; // Add metadata if available let metadataText = ''; if (searchData.search_metadata) { metadataText = `\n\n*Search type: ${searchData.search_metadata.search_type}, Time: ${searchData.search_metadata.execution_time_ms}ms*`; } return { content: [{ type: 'text', text: finalCount === 0 ? `No results found for "${query}"${metadataText}` : `Found ${finalCount} results for "${query}":\n\n${allResults.join('\n')}${metadataText}` }] }; } 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 ?? '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})` }] }; } async function handleListTaskComments(args) { const { task_id, limit = 20, page = 1 } = 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}/comments`, { params: { limit, page } }); const responseData = response.data; const comments = responseData.data; const total = responseData.pagination.total; const formatComment = (comment, isReply = false) => { const indent = isReply ? ' ' : ''; const replyIndicator = isReply ? '↳ ' : ''; const dateValue = comment.created_at instanceof Date ? comment.created_at : new Date(comment.created_at); return `${indent}${replyIndicator}**${comment.author}** (${dateValue.toLocaleDateString()})\n${indent} ${comment.content}\n${indent} ID: ${comment.id}`; }; // Organize comments hierarchically const topLevel = []; const replies = {}; comments.forEach((comment) => { if (comment.parent_comment_id !== null && comment.parent_comment_id !== undefined && comment.parent_comment_id !== '') { replies[comment.parent_comment_id] ??= []; replies[comment.parent_comment_id].push(comment); } else { topLevel.push(comment); } }); const formattedComments = topLevel.map(comment => { let formatted = formatComment(comment); const commentReplies = replies[comment.id] ?? []; if (commentReplies.length > 0) { formatted += '\n' + commentReplies.map(reply => formatComment(reply, true)).join('\n'); } return formatted; }).join('\n\n'); return { content: [{ type: 'text', text: `Found ${total} comments for task:\n\n${formattedComments || 'No comments yet'}` }] }; } async function handleCreateTaskComment(args) { const { task_id, content, author, parent_comment_id } = args; if (typeof task_id !== 'string' || typeof content !== 'string' || typeof author !== 'string') { throw new McpError(ErrorCode.InvalidParams, 'task_id, content, and author are required'); } const commentData = { content, author }; if (parent_comment_id !== null && parent_comment_id !== undefined && parent_comment_id !== '') { commentData.parent_comment_id = parent_comment_id; } const response = await api.post(`/tasks/${task_id}/comments`, commentData); const comment = response.data.data; const actionType = parent_comment_id !== null && parent_comment_id !== undefined && parent_comment_id !== '' ? 'replied to comment' : 'added comment'; return { content: [{ type: 'text', text: `✅ ${actionType}: **${comment.author}** (${comment.id})\n\n"${comment.content}"` }] }; } async function handleGetComment(args) { const { comment_id } = args; if (typeof comment_id !== 'string') { throw new McpError(ErrorCode.InvalidParams, 'comment_id must be a string'); } const response = await api.get(`/comments/${comment_id}`); const comment = response.data.data; return { content: [{ type: 'text', text: `**Comment by ${comment.author}**\n\nID: ${comment.id}\nTask: ${comment.task_id ?? 'N/A'}\nDocument: ${comment.document_id ?? 'N/A'}\nParent Comment: ${comment.parent_comment_id ?? 'None (top-level)'}\nCreated: ${comment.created_at instanceof Date ? comment.created_at.toISOString() : new Date(comment.created_at).toISOString()}\nUpdated: ${comment.updated_at instanceof Date ? comment.updated_at.toISOString() : new Date(comment.updated_at).toISOString()}\n\nContent:\n${comment.content}` }] }; } async function handleUpdateComment(args) { const { comment_id, content } = args; if (typeof comment_id !== 'string' || typeof content !== 'string') { throw new McpError(ErrorCode.InvalidParams, 'comment_id and content are required'); } const response = await api.put(`/comments/${comment_id}`, { content }); const comment = response.data.data; return { content: [{ type: 'text', text: `✅ Updated comment by **${comment.author}** (${comment.id})\n\nNew content:\n"${comment.content}"` }] }; } async function handleDeleteComment(args) { const { comment_id } = args; if (typeof comment_id !== 'string') { throw new McpError(ErrorCode.InvalidParams, 'comment_id must be a string'); } await api.delete(`/comments/${comment_id}`); return { content: [{ type: 'text', text: `✅ Deleted comment (${comment_id})` }] }; } // Main server startup function async function startServer() { const transport = new StdioServerTransport(); // Set up graceful shutdown process.on('SIGINT', () => { console.error('Received SIGINT, shutting down gracefully...'); process.exit(0); }); process.on('SIGTERM', () => { console.error('Received SIGTERM, shutting down gracefully...'); process.exit(0); }); // Enhanced error handling process.on('uncaughtException', (error) => { console.error('Uncaught exception:', error); process.exit(1); }); process.on('unhandledRejection', (reason, promise) => { console.error('Unhandled rejection at:', promise, 'reason:', reason); process.exit(1); }); try { await server.connect(transport); console.error('Claude Collaborative Memory MCP Server started successfully'); console.error(`API URL: ${API_BASE_URL}`); console.error(`API Key: Configured securely`); console.error(`Timeout: ${API_TIMEOUT}ms`); } catch (error) { console.error('Failed to start MCP server:', error); process.exit(1); } } // Start the server startServer().catch((error) => { console.error('Server startup failed:', error); process.exit(1); }); //# sourceMappingURL=server.js.map