@vibeplanner/mcp-server
Version:
MCP server for VibePlanner AI - Integrate project management and collaborative memory into Claude Desktop
1,171 lines • 48.5 kB
JavaScript
/**
* 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