Targetprocess MCP Server

by aaronsb
Verified
import fetch, { Response } from 'node-fetch'; import { McpError, ErrorCode } from '@modelcontextprotocol/sdk/types.js'; import { URLSearchParams } from 'node:url'; import { setTimeout } from 'node:timers/promises'; import { AssignableEntityData } from '../../entities/assignable/assignable.entity.js'; import { UserStoryData } from '../../entities/assignable/user-story.entity.js'; import { ApiResponse, CreateEntityRequest, UpdateEntityRequest } from './api.types.js'; type OrderByOption = string | { field: string; direction: 'asc' | 'desc' }; interface RetryConfig { maxRetries: number; delayMs: number; backoffFactor: number; } interface ApiErrorResponse { Message?: string; ErrorMessage?: string; Description?: string; } export interface TPServiceConfig { domain: string; credentials: { username: string; password: string; }; retry?: RetryConfig; } /** * Service layer for interacting with TargetProcess API */ export class TPService { private readonly baseUrl: string; private readonly auth: string; private readonly retryConfig: RetryConfig; /** * Formats a value for use in a where clause based on its type */ private formatWhereValue(value: unknown): string { if (value === null) { return 'null'; } if (typeof value === 'boolean') { return value.toString().toLowerCase(); } if (value instanceof Date) { return `'${value.toISOString().split('T')[0]}'`; } if (Array.isArray(value)) { return `[${value.map(v => this.formatWhereValue(v)).join(',')}]`; } // Handle strings const strValue = String(value); // Remove any existing quotes const unquoted = strValue.replace(/^['"]|['"]$/g, ''); // Escape single quotes by doubling them const escaped = unquoted.replace(/'/g, "''"); // Always wrap in single quotes as per TargetProcess API requirements return `'${escaped}'`; } /** * Formats a field name for use in a where clause */ private formatWhereField(field: string): string { // Handle custom fields that match native fields if (field.startsWith('CustomField.')) { return `cf_${field.substring(12)}`; } // Remove spaces from custom field names return field.replace(/\s+/g, ''); } /** * Validates and formats a where clause according to TargetProcess rules */ private validateWhereClause(where: string): string { try { // Handle empty/null cases if (!where || !where.trim()) { throw new McpError(ErrorCode.InvalidRequest, 'Empty where clause'); } // Split on 'and' while preserving quoted strings const conditions: string[] = []; let currentCondition = ''; let inQuote = false; let quoteChar = ''; for (let i = 0; i < where.length; i++) { const char = where[i]; if ((char === "'" || char === '"') && where[i - 1] !== '\\') { if (!inQuote) { inQuote = true; quoteChar = char; } else if (char === quoteChar) { inQuote = false; } } if (!inQuote && where.slice(i, i + 4).toLowerCase() === ' and') { conditions.push(currentCondition.trim()); currentCondition = ''; i += 3; // Skip 'and' continue; } currentCondition += char; } conditions.push(currentCondition.trim()); return conditions.map(condition => { // Handle null checks if (/\bis\s+null\b/i.test(condition)) { const field = condition.split(/\bis\s+null\b/i)[0].trim(); return `${this.formatWhereField(field)} is null`; } if (/\bis\s+not\s+null\b/i.test(condition)) { const field = condition.split(/\bis\s+not\s+null\b/i)[0].trim(); return `${this.formatWhereField(field)} is not null`; } // Match field and operator while preserving quoted values const match = condition.match(/^([^\s]+)\s+(eq|ne|gt|gte|lt|lte|in|contains|not\s+contains)\s+(.+)$/i); if (!match) { throw new McpError(ErrorCode.InvalidRequest, `Invalid condition format: ${condition}`); } const [, field, operator, value] = match; const formattedField = this.formatWhereField(field); const formattedValue = this.formatWhereValue(value.trim()); return `${formattedField} ${operator.toLowerCase()} ${formattedValue}`; }).join(' and '); } catch (error) { if (error instanceof McpError) throw error; throw new McpError( ErrorCode.InvalidRequest, `Invalid where clause: ${error instanceof Error ? error.message : String(error)}` ); } } /** * Formats orderBy parameters according to TargetProcess rules */ private formatOrderBy(orderBy: OrderByOption[]): string { return orderBy.map(item => { if (typeof item === 'string') { return this.formatWhereField(item); } return `${this.formatWhereField(item.field)} ${item.direction}`; }).join(','); } /** * Validates and formats include parameters */ private validateInclude(include: string[]): string { const validIncludes = include .filter(Boolean) .map(i => i.trim()) .map(i => this.formatWhereField(i)); validIncludes.forEach(inc => { if (!/^[A-Za-z.]+$/.test(inc)) { throw new McpError( ErrorCode.InvalidRequest, `Invalid include parameter: ${inc}` ); } }); return `[${validIncludes.join(',')}]`; } constructor(config: TPServiceConfig) { const { domain, credentials: { username, password }, retry } = config; this.baseUrl = `https://${domain}/api/v1`; this.auth = Buffer.from(`${username}:${password}`).toString('base64'); this.retryConfig = retry || { maxRetries: 3, delayMs: 1000, backoffFactor: 2 }; } private async executeWithRetry<T>( operation: () => Promise<T>, context: string ): Promise<T> { let lastError: Error | null = null; let delay = this.retryConfig.delayMs; for (let attempt = 1; attempt <= this.retryConfig.maxRetries; attempt++) { try { return await operation(); } catch (error) { lastError = error as Error; // Don't retry on 400 (bad request) or 401 (unauthorized) if (error instanceof McpError && (error.message.includes('status: 400') || error.message.includes('status: 401'))) { throw error; } if (attempt === this.retryConfig.maxRetries) { break; } // Wait before retrying await setTimeout(delay); delay *= this.retryConfig.backoffFactor; } } throw new McpError( ErrorCode.InvalidRequest, `Failed to ${context} after ${this.retryConfig.maxRetries} attempts: ${lastError?.message}` ); } private async extractErrorMessage(response: Response): Promise<string> { try { const data = await response.json() as ApiErrorResponse; return data.Message || data.ErrorMessage || data.Description || response.statusText; } catch { return response.statusText; } } /** * Search entities with filtering and includes */ private async handleApiResponse<T>( response: Response, context: string ): Promise<T> { if (!response.ok) { const errorMessage = await this.extractErrorMessage(response); throw new McpError( ErrorCode.InvalidRequest, `${context} failed: ${response.status} - ${errorMessage}` ); } return await response.json() as T; } // Cache for valid entity types to avoid repeated API calls private validEntityTypesCache: string[] | null = null; private cacheInitPromise: Promise<string[]> | null = null; private readonly cacheExpiryMs = 3600000; // Cache expires after 1 hour private cacheTimestamp: number = 0; /** * Validates that the entity type is supported by Target Process * Uses dynamic validation with caching for better accuracy */ private async validateEntityType(type: string): Promise<string> { // Static list of known entity types in Target Process as fallback const staticValidEntityTypes = [ 'UserStory', 'Bug', 'Task', 'Feature', 'Epic', 'PortfolioEpic', 'Solution', 'Request', 'Impediment', 'TestCase', 'TestPlan', 'Project', 'Team', 'Iteration', 'TeamIteration', 'Release', 'Program', 'Comment', 'Attachment', 'EntityState', 'Priority', 'Process', 'GeneralUser' ]; try { // Check if cache is expired const isCacheExpired = Date.now() - this.cacheTimestamp > this.cacheExpiryMs; // Initialize cache if needed if (!this.validEntityTypesCache || isCacheExpired) { // If initialization is already in progress, wait for it if (this.cacheInitPromise) { this.validEntityTypesCache = await this.cacheInitPromise; } else { // Start new initialization this.cacheInitPromise = this.getValidEntityTypes(); try { this.validEntityTypesCache = await this.cacheInitPromise; this.cacheTimestamp = Date.now(); } catch (error) { console.error('Failed to fetch valid entity types:', error); // Fall back to static list if API call fails this.validEntityTypesCache = staticValidEntityTypes; } finally { this.cacheInitPromise = null; } } } // Validate against the cache if (!this.validEntityTypesCache.includes(type)) { throw new McpError( ErrorCode.InvalidRequest, `Invalid entity type: '${type}'. Valid entity types are: ${this.validEntityTypesCache.join(', ')}` ); } return type; } catch (error) { // If error is already a McpError, rethrow it if (error instanceof McpError) { throw error; } // Fall back to static validation if dynamic validation fails if (!staticValidEntityTypes.includes(type)) { throw new McpError( ErrorCode.InvalidRequest, `Invalid entity type: '${type}'. Valid entity types are: ${staticValidEntityTypes.join(', ')}` ); } return type; } } async searchEntities<T>( type: string, where?: string, include?: string[], take: number = 25, orderBy?: string[] ): Promise<T[]> { try { // Validate entity type (now async) const validatedType = await this.validateEntityType(type); const params = new URLSearchParams({ format: 'json', take: take.toString() }); if (where) { params.append('where', this.validateWhereClause(where)); } if (include?.length) { params.append('include', this.validateInclude(include)); } if (orderBy?.length) { params.append('orderBy', this.formatOrderBy(orderBy as OrderByOption[])); } return await this.executeWithRetry(async () => { const response = await fetch(`${this.baseUrl}/${validatedType}s?${params}`, { headers: { 'Authorization': `Basic ${this.auth}`, 'Accept': 'application/json' } }); const data = await this.handleApiResponse<ApiResponse<T>>( response, `search ${validatedType}s` ); return data.Items || []; }, `search ${validatedType}s`); } catch (error) { if (error instanceof McpError) { throw error; } throw new McpError( ErrorCode.InvalidRequest, `Failed to search ${type}s: ${error instanceof Error ? error.message : String(error)}` ); } } /** * Get a single entity by ID */ async getEntity<T>( type: string, id: number, include?: string[] ): Promise<T> { try { // Validate entity type (now async) const validatedType = await this.validateEntityType(type); const params = new URLSearchParams({ format: 'json' }); if (include?.length) { params.append('include', this.validateInclude(include)); } return await this.executeWithRetry(async () => { const response = await fetch(`${this.baseUrl}/${validatedType}s/${id}?${params}`, { headers: { 'Authorization': `Basic ${this.auth}`, 'Accept': 'application/json' } }); return await this.handleApiResponse<T>( response, `get ${validatedType} ${id}` ); }, `get ${validatedType} ${id}`); } catch (error) { if (error instanceof McpError) { throw error; } throw new McpError( ErrorCode.InvalidRequest, `Failed to get ${type} ${id}: ${error instanceof Error ? error.message : String(error)}` ); } } /** * Create a new entity */ async createEntity<T>( type: string, data: CreateEntityRequest ): Promise<T> { try { // Validate entity type (now async) const validatedType = await this.validateEntityType(type); return await this.executeWithRetry(async () => { const response = await fetch(`${this.baseUrl}/${validatedType}s`, { method: 'POST', headers: { 'Authorization': `Basic ${this.auth}`, 'Content-Type': 'application/json', 'Accept': 'application/json' }, body: JSON.stringify(data) }); return await this.handleApiResponse<T>( response, `create ${validatedType}` ); }, `create ${validatedType}`); } catch (error) { if (error instanceof McpError) { throw error; } throw new McpError( ErrorCode.InvalidRequest, `Failed to create ${type}: ${error instanceof Error ? error.message : String(error)}` ); } } /** * Update an existing entity */ async updateEntity<T>( type: string, id: number, data: UpdateEntityRequest ): Promise<T> { try { // Validate entity type (now async) const validatedType = await this.validateEntityType(type); return await this.executeWithRetry(async () => { const response = await fetch(`${this.baseUrl}/${validatedType}s/${id}`, { method: 'POST', headers: { 'Authorization': `Basic ${this.auth}`, 'Content-Type': 'application/json', 'Accept': 'application/json' }, body: JSON.stringify(data) }); return await this.handleApiResponse<T>( response, `update ${validatedType} ${id}` ); }, `update ${validatedType} ${id}`); } catch (error) { if (error instanceof McpError) { throw error; } throw new McpError( ErrorCode.InvalidRequest, `Failed to update ${type} ${id}: ${error instanceof Error ? error.message : String(error)}` ); } } /** * Helper method to get user stories with related data */ async getUserStories( where?: string, include: string[] = ['Project', 'Team', 'Feature', 'Tasks', 'Bugs'] ): Promise<(UserStoryData & AssignableEntityData)[]> { const results = await this.searchEntities<UserStoryData & AssignableEntityData>( 'UserStory', where, include ); return results; } /** * Helper method to get a single user story with related data */ async getUserStory( id: number, include: string[] = ['Project', 'Team', 'Feature', 'Tasks', 'Bugs'] ): Promise<UserStoryData & AssignableEntityData> { const result = await this.getEntity<UserStoryData & AssignableEntityData>( 'UserStory', id, include ); return result; } /** * Fetch metadata about entity types and their properties */ async fetchMetadata(): Promise<any> { try { return await this.executeWithRetry(async () => { // Explicitly request JSON format in the URL const response = await fetch(`${this.baseUrl}/Index/meta?format=json`, { headers: { 'Authorization': `Basic ${this.auth}`, 'Accept': 'application/json' } }); // Check if response is OK before trying to parse JSON if (!response.ok) { const errorMessage = await this.extractErrorMessage(response); throw new McpError( ErrorCode.InvalidRequest, `fetch metadata failed: ${response.status} - ${errorMessage}` ); } // Get the text response and manually fix the JSON format if needed const text = await response.text(); try { // Try to parse as-is first return JSON.parse(text); } catch (parseError) { console.error('Failed to parse JSON response, attempting to fix format...'); // If parsing fails, try to fix the JSON by adding missing commas between objects const fixedText = text .replace(/}"/g, '},"') // Add comma between objects .replace(/}}/g, '}}'); // Fix any double closing braces try { return JSON.parse(fixedText); } catch (fixError) { console.error('Failed to fix and parse JSON response:', fixError); throw new McpError( ErrorCode.InvalidRequest, `Failed to parse metadata response: ${fixError instanceof Error ? fixError.message : String(fixError)}` ); } } }, 'fetch metadata'); } catch (error) { if (error instanceof McpError) { throw error; } throw new McpError( ErrorCode.InvalidRequest, `Failed to fetch metadata: ${error instanceof Error ? error.message : String(error)}` ); } } /** * Get a list of all valid entity types from the API * This can be used to dynamically validate entity types */ async getValidEntityTypes(): Promise<string[]> { try { console.error('Fetching valid entity types from Target Process API...'); console.error(`Using domain: ${this.baseUrl}`); const metadata = await this.fetchMetadata(); const entityTypes: string[] = []; if (metadata && metadata.Items) { console.error(`Metadata response received with ${metadata.Items.length} items`); for (const item of metadata.Items) { if (item.Name && !entityTypes.includes(item.Name)) { entityTypes.push(item.Name); } } } else { console.error('Metadata response missing Items array:', JSON.stringify(metadata).substring(0, 200) + '...'); } if (entityTypes.length === 0) { console.error('No entity types found in API response, falling back to static list'); // Comprehensive list of common Target Process entity types return [ 'UserStory', 'Bug', 'Task', 'Feature', 'Epic', 'PortfolioEpic', 'Solution', 'Request', 'Impediment', 'TestCase', 'TestPlan', 'Project', 'Team', 'Iteration', 'TeamIteration', 'Release', 'Program', 'Comment', 'Attachment', 'EntityState', 'Priority', 'Process', 'GeneralUser', 'TestCase', 'TestPlan', 'TestCaseRun', 'Build', 'Assignable', 'General', 'Relation', 'Role', 'CustomField', 'Milestone', 'TimeSheet', 'Context' ]; } console.error(`Found ${entityTypes.length} valid entity types from API`); return entityTypes.sort(); } catch (error) { console.error('Error fetching valid entity types:', error); // Provide more detailed error information if (error instanceof Error) { console.error(`Error details: ${error.message}`); console.error(`Error stack: ${error.stack}`); } if (error instanceof McpError) { throw error; } // Fall back to static list on error instead of throwing console.error('Falling back to static entity type list due to error'); return [ 'UserStory', 'Bug', 'Task', 'Feature', 'Epic', 'PortfolioEpic', 'Solution', 'Request', 'Impediment', 'TestCase', 'TestPlan', 'Project', 'Team', 'Iteration', 'TeamIteration', 'Release', 'Program', 'Comment', 'Attachment', 'EntityState', 'Priority', 'Process', 'GeneralUser', 'TestCase', 'TestPlan', 'TestCaseRun', 'Build', 'Assignable', 'General', 'Relation', 'Role' ]; } } /** * Initialize the entity type cache on server startup * This helps avoid delays on the first API call */ async initializeEntityTypeCache(): Promise<void> { try { if (!this.validEntityTypesCache) { console.error('Pre-initializing entity type cache...'); this.cacheInitPromise = this.getValidEntityTypes(); this.validEntityTypesCache = await this.cacheInitPromise; this.cacheTimestamp = Date.now(); this.cacheInitPromise = null; console.error('Entity type cache initialized successfully'); } } catch (error) { console.error('Failed to initialize entity type cache:', error); // Don't throw - we'll retry on first use } } }