UNPKG

aipm-mcp

Version:

Complete AIPM integration for Cursor IDE - Get tasks, manage features, track time, and build features with AI. Supports both MCP stdio mode and HTTP server mode.

415 lines 18.4 kB
import axios, { AxiosError } from 'axios'; import { TicketSchema, FeatureSchema, ProductSchema, UserSchema, TimeLogSchema } from './types.js'; // Enhanced error handling class AIPMError extends Error { statusCode; errorCode; details; requestContext; constructor(message, statusCode, errorCode, details, requestContext) { super(message); this.name = 'AIPMError'; this.statusCode = statusCode; this.errorCode = errorCode; this.details = details; this.requestContext = requestContext; } } // Error extraction helper function extractErrorDetails(error, context) { if (error instanceof AxiosError) { const statusCode = error.response?.status; const responseData = error.response?.data; // Handle different HTTP status codes switch (statusCode) { case 400: if (responseData?.errors && Array.isArray(responseData.errors)) { const validationErrors = responseData.errors.map((err) => `${err.path?.join('.') || 'field'}: ${err.msg || err.message}`).join(', '); return new AIPMError(`Validation failed: ${validationErrors}`, statusCode, 'VALIDATION_ERROR', responseData.errors, context); } return new AIPMError(responseData?.error || 'Bad request', statusCode, 'BAD_REQUEST', responseData, context); case 401: return new AIPMError('Authentication failed. Please check your credentials.', statusCode, 'UNAUTHORIZED', responseData, context); case 403: if (responseData?.error?.includes('pending approval')) { return new AIPMError('Access denied. Your account may be pending approval.', statusCode, 'PENDING_APPROVAL', responseData, context); } return new AIPMError(responseData?.error || 'Access forbidden', statusCode, 'FORBIDDEN', responseData, context); case 404: return new AIPMError(responseData?.error || 'Resource not found', statusCode, 'NOT_FOUND', responseData, context); case 422: return new AIPMError('Request validation failed', statusCode, 'VALIDATION_ERROR', responseData, context); case 500: return new AIPMError('Internal server error. Please try again later.', statusCode, 'SERVER_ERROR', responseData, context); default: return new AIPMError(responseData?.error || error.message || 'Unknown error occurred', statusCode, 'UNKNOWN_ERROR', responseData, context); } } // Handle non-HTTP errors if (error instanceof Error) { return new AIPMError(error.message, undefined, 'NETWORK_ERROR', error, context); } return new AIPMError('An unexpected error occurred', undefined, 'UNKNOWN_ERROR', error, context); } export class AIPMClient { client; currentUser = null; constructor(apiUrl, token) { this.client = axios.create({ baseURL: apiUrl, headers: { 'Authorization': `Bearer ${token}`, 'Content-Type': 'application/json' }, timeout: 30000 }); // Add response interceptor for error handling this.client.interceptors.response.use((response) => response, (error) => { if (error.response?.status === 401) { throw new Error('Authentication failed. Please check your AIPM token.'); } if (error.response?.status === 403) { throw new Error('Access denied. Your account may be pending approval.'); } throw new Error(error.response?.data?.message || error.message || 'API request failed'); }); } async getCurrentUser() { if (this.currentUser) { return this.currentUser; } try { const response = await this.client.get('/api/auth/me'); this.currentUser = UserSchema.parse(response.data.user); return this.currentUser; } catch (error) { throw extractErrorDetails(error, 'getCurrentUser()'); } } async getTasks(params = {}) { try { const queryParams = new URLSearchParams(); if (params.status) queryParams.append('status', params.status); if (params.priority) queryParams.append('priority', params.priority); if (params.productId) queryParams.append('productId', params.productId); if (params.featureId) queryParams.append('featureId', params.featureId); if (params.limit) queryParams.append('limit', params.limit.toString()); const response = await this.client.get(`/api/tickets?${queryParams.toString()}`); if (!response.data.tickets) { throw new Error('Invalid response format from AIPM API'); } return response.data.tickets.map((ticket) => TicketSchema.parse(ticket)); } catch (error) { throw extractErrorDetails(error, `getTasks(${JSON.stringify(params)})`); } } async getTaskById(taskId) { try { const response = await this.client.get(`/api/tickets/${taskId}`); return TicketSchema.parse(response.data.ticket); } catch (error) { throw new Error(`Failed to get task ${taskId}: ${error instanceof Error ? error.message : 'Unknown error'}`); } } async startTask(taskId, notes) { try { const response = await this.client.put(`/api/tickets/${taskId}`, { status: 'IN_PROGRESS', startedAt: new Date().toISOString(), notes }); // Also create a time log entry const timeLogResponse = await this.client.post('/api/time-logs/start', { ticketId: taskId, description: notes || `Started working on task: ${taskId}` }); return { success: true, timeLogId: timeLogResponse.data?.id }; } catch (error) { throw new Error(`Failed to start task ${taskId}: ${error instanceof Error ? error.message : 'Unknown error'}`); } } async completeTask(taskId, options = {}) { try { // Update task status await this.client.put(`/api/tickets/${taskId}`, { status: 'COMPLETED', completedAt: new Date().toISOString(), generatedCode: options.codeChanges, implementationDetails: options.implementationDetails, testResults: options.testResults, ...(options.completionNotes && { description: options.completionNotes }) }); // Stop any active time logs const activeTimeLogs = await this.client.get(`/api/time-logs?ticketId=${taskId}&isActive=true`); if (activeTimeLogs.data.timeLogs?.length > 0) { for (const timeLog of activeTimeLogs.data.timeLogs) { await this.client.put(`/api/time-logs/${timeLog.id}`, { isActive: false, endedAt: new Date().toISOString(), hours: options.timeSpent || timeLog.hours, description: options.completionNotes || timeLog.description }); } } return { success: true }; } catch (error) { throw new Error(`Failed to complete task ${taskId}: ${error instanceof Error ? error.message : 'Unknown error'}`); } } async updateTaskImplementation(taskId, options) { try { const response = await this.client.put(`/api/tickets/${taskId}`, { implementationDetails: options.implementationDetails, testResults: options.testResults, codeChanges: options.codeChanges, }); return { success: true }; } catch (error) { throw new Error(`Failed to update task implementation ${taskId}: ${error instanceof Error ? error.message : 'Unknown error'}`); } } async createTask(taskData) { try { const user = await this.getCurrentUser(); const response = await this.client.post('/api/tickets', { ...taskData, createdById: user.id, organizationId: user.organizationId, assignedToId: taskData.assignedToId || user.id }); return TicketSchema.parse(response.data.ticket); } catch (error) { throw extractErrorDetails(error, `createTask(${taskData.title})`); } } async getFeatures(params = {}) { try { const queryParams = new URLSearchParams(); if (params.productId) queryParams.append('productId', params.productId); if (params.status) queryParams.append('status', params.status); if (params.includeTickets) queryParams.append('includeTickets', 'true'); const response = await this.client.get(`/api/features?${queryParams.toString()}`); if (!response.data.features) { throw new Error('Invalid response format from AIPM API'); } return response.data.features.map((feature) => FeatureSchema.parse(feature)); } catch (error) { throw new Error(`Failed to get features: ${error instanceof Error ? error.message : 'Unknown error'}`); } } async breakdownFeature(featureId, additionalContext) { try { const response = await this.client.post('/api/ai/breakdown', { featureId, additionalContext }); return { tasks: response.data.tickets?.map((ticket) => TicketSchema.parse(ticket)) || [], breakdown: response.data.breakdown || 'Feature breakdown completed' }; } catch (error) { throw new Error(`Failed to breakdown feature ${featureId}: ${error instanceof Error ? error.message : 'Unknown error'}`); } } async getProducts() { try { const response = await this.client.get('/api/products'); if (!response.data.products) { throw new Error('Invalid response format from AIPM API'); } return response.data.products.map((product) => ProductSchema.parse(product)); } catch (error) { throw new Error(`Failed to get products: ${error instanceof Error ? error.message : 'Unknown error'}`); } } async logTime(timeData) { try { const response = await this.client.post('/api/time-logs', { ...timeData, date: timeData.date || new Date().toISOString().split('T')[0] }); return TimeLogSchema.parse(response.data); } catch (error) { throw new Error(`Failed to log time: ${error instanceof Error ? error.message : 'Unknown error'}`); } } async getTimeLogs(params = {}) { try { const queryParams = new URLSearchParams(); if (params.ticketId) queryParams.append('ticketId', params.ticketId); if (params.featureId) queryParams.append('featureId', params.featureId); if (params.startDate) queryParams.append('startDate', params.startDate); if (params.endDate) queryParams.append('endDate', params.endDate); const response = await this.client.get(`/api/time-logs?${queryParams.toString()}`); if (!response.data.timeLogs) { throw new Error('Invalid response format from AIPM API'); } return response.data.timeLogs.map((timeLog) => TimeLogSchema.parse(timeLog)); } catch (error) { throw new Error(`Failed to get time logs: ${error instanceof Error ? error.message : 'Unknown error'}`); } } async searchTasks(query, limit = 10) { try { const response = await this.client.get(`/api/search?q=${encodeURIComponent(query)}&type=tickets&limit=${limit}`); if (!response.data.results?.tickets) { return []; } return response.data.results.tickets.map((ticket) => TicketSchema.parse(ticket)); } catch (error) { throw new Error(`Failed to search tasks: ${error instanceof Error ? error.message : 'Unknown error'}`); } } // Health check method async healthCheck() { try { // Health endpoint doesn't require authentication, so we make a direct request const axios = await import('axios'); // Ensure baseURL doesn't have trailing slash to avoid double slashes const baseURL = this.client.defaults.baseURL?.replace(/\/$/, '') || ''; const response = await axios.default.get(`${baseURL}/health`); return response.status === 200; } catch (error) { return false; } } // Update feature with build results async updateFeature(featureId, options) { try { const response = await this.client.patch(`/api/features/${featureId}`, options); return { success: true }; } catch (error) { throw new Error(`Failed to update feature ${featureId}: ${error instanceof Error ? error.message : 'Unknown error'}`); } } // Create feature build record async createFeatureBuild(buildData) { try { // Use the existing build endpoint with required fields const response = await this.client.post('/api/features/build', { featureId: buildData.featureId, requirements: buildData.metadata?.message || 'Feature implementation completed', acceptanceCriteria: buildData.metadata?.acceptanceCriteria || '', openCursor: false, // Already handled by MCP showProgress: false }); return { success: true }; } catch (error) { throw new Error(`Failed to create feature build: ${error instanceof Error ? error.message : 'Unknown error'}`); } } // Get pending build requests for a user async getPendingBuildRequests(userId, lastPolledAt) { try { const params = new URLSearchParams({ userId }); if (lastPolledAt) { params.append('lastPolledAt', lastPolledAt); } const response = await this.client.get(`/api/features/build-requests/pending?${params.toString()}`); // Handle nested response structure return response.data?.pendingRequests || response.data || []; } catch (error) { // If endpoint doesn't exist yet, return empty array if (error instanceof Error && error.message.includes('404')) { return []; } throw new Error(`Failed to get pending build requests: ${error instanceof Error ? error.message : 'Unknown error'}`); } } // Mark a build request as processed async markBuildRequestProcessed(requestId, result) { try { const response = await this.client.post(`/api/features/build-requests/${requestId}/processed`, { result, processedAt: new Date().toISOString() }); // Check if the response indicates success if (response.data && response.data.success) { console.log(`✅ Successfully marked build request ${requestId} as processed`); return { success: true }; } else { console.warn(`⚠️ Backend returned non-success for build request ${requestId}:`, response.data); return { success: false }; } } catch (error) { // If endpoint doesn't exist yet, just log and continue if (error instanceof AxiosError && error.response?.status === 404) { console.warn(`⚠️ Build request processing endpoint not found (404) - this may be expected in development`); return { success: false }; } console.error(`❌ Failed to mark build request ${requestId} as processed:`, error instanceof Error ? error.message : 'Unknown error'); return { success: false }; } } // Get build request status async getBuildRequestStatus(requestId) { try { const response = await this.client.get(`/api/features/build-requests/${requestId}/status`); // Handle the new response format if (response.data && response.data.success) { return { status: response.data.status, processedAt: response.data.processedAt }; } return null; } catch (error) { // If endpoint doesn't exist or request not found, return null if (error instanceof AxiosError && (error.response?.status === 404 || error.response?.status === 500)) { return null; } throw new Error(`Failed to get build request status: ${error instanceof Error ? error.message : 'Unknown error'}`); } } // Update build status with detailed information async updateBuildStatus(featureId, status, detailedStatus, message, metadata) { try { const response = await this.client.patch(`/api/features/${featureId}/build-status`, { status, detailedStatus, message, metadata }); return { success: true }; } catch (error) { throw new Error(`Failed to update build status: ${error instanceof Error ? error.message : 'Unknown error'}`); } } } //# sourceMappingURL=aipm-client.js.map