Skip to main content
Glama

Targetprocess MCP Server

tp.service.ts16.5 kB
import { McpError, ErrorCode } from '@modelcontextprotocol/sdk/types.js'; 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'; import { EntityRegistry, EntityCategory } from '../../core/entity-registry.js'; import { logger } from '../../utils/logger.js'; import { HttpClient, AuthConfig } from '../http/http-client.js'; import { QueryBuilder } from '../query/query-builder.js'; import { EntityValidator } from '../validation/entity-validator.js'; import { CommentService, CommentData } from '../comments/comment.service.js'; interface TPServiceCommonConfig { domain: string; retry?: { maxRetries: number; delayMs: number; backoffFactor: number; }; } interface TPServiceApiKeyConfig extends TPServiceCommonConfig { apiKey: string; } interface TPServiceBasicAuthConfig extends TPServiceCommonConfig { credentials: { username: string; password: string; }; } export type TPServiceConfig = TPServiceApiKeyConfig | TPServiceBasicAuthConfig; function isApiKeyConfig(config: TPServiceConfig): config is TPServiceApiKeyConfig { return (config as TPServiceApiKeyConfig).apiKey !== undefined; } /** * Service layer for interacting with TargetProcess API * Orchestrates HttpClient, QueryBuilder, EntityValidator, and CommentService */ export class TPService { private readonly httpClient: HttpClient; private readonly queryBuilder: QueryBuilder; private readonly entityValidator: EntityValidator; private readonly commentService: CommentService; constructor(config: TPServiceConfig) { // Setup authentication configuration const authConfig: AuthConfig = isApiKeyConfig(config) ? { type: 'apikey', token: config.apiKey } : { type: 'basic', token: Buffer.from(`${config.credentials.username}:${config.credentials.password}`).toString('base64') }; // Initialize HTTP client this.httpClient = new HttpClient({ baseUrl: `https://${config.domain}/api/v1`, retry: config.retry }, authConfig); // Initialize query builder this.queryBuilder = new QueryBuilder(authConfig); // Initialize entity validator with callback to fetch entity types this.entityValidator = new EntityValidator(() => this.getValidEntityTypes()); // Initialize comment service with dependencies this.commentService = new CommentService({ executeWithRetry: this.httpClient.executeWithRetry.bind(this.httpClient), handleApiResponse: this.httpClient.handleApiResponse.bind(this.httpClient), getHeaders: () => this.getHeaders(), baseUrl: this.httpClient.getBaseUrl(), entityValidator: this.entityValidator }); } /** * Search entities with filtering and includes */ async searchEntities<T>( type: string, where?: string, include?: string[], take: number = 25, orderBy?: string[] ): Promise<T[]> { try { // Validate entity type const validatedType = await this.entityValidator.validateEntityTypeOrThrow(type); // Build query using QueryBuilder const queryString = this.queryBuilder .reset() .format('json') .take(take) .where(where || '') .include(include || []) .orderBy(orderBy || []) .buildQueryString(); // Get the appropriate endpoint const endpoint = this.entityValidator.getEndpointForEntityType(validatedType); // Make the request const data = await this.httpClient.get<ApiResponse<T>>(`${endpoint}?${queryString}`); return data.Items || []; } 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 and ID const validatedType = await this.entityValidator.validateEntityTypeOrThrow(type); const idValidation = this.entityValidator.validateEntityId(id); if (!idValidation.isValid) { throw new McpError(ErrorCode.InvalidRequest, idValidation.errors.join('; ')); } // Build query using QueryBuilder const queryString = this.queryBuilder .reset() .format('json') .include(include || []) .buildQueryString(); // Get the appropriate endpoint const endpoint = this.entityValidator.getEndpointForEntityType(validatedType); // Make the request return await this.httpClient.get<T>(`${endpoint}/${id}?${queryString}`); } 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 const validatedType = await this.entityValidator.validateEntityTypeOrThrow(type); // Get the appropriate endpoint const endpoint = this.entityValidator.getEndpointForEntityType(validatedType); // Make the request return await this.httpClient.post<T>(endpoint, data); } 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 and ID const validatedType = await this.entityValidator.validateEntityTypeOrThrow(type); const idValidation = this.entityValidator.validateEntityId(id); if (!idValidation.isValid) { throw new McpError(ErrorCode.InvalidRequest, idValidation.errors.join('; ')); } // Get the appropriate endpoint const endpoint = this.entityValidator.getEndpointForEntityType(validatedType); // Make the request (TargetProcess uses POST for updates) return await this.httpClient.post<T>(`${endpoint}/${id}`, data); } 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)}` ); } } /** * Get comments for an entity (delegates to CommentService) */ async getComments(entityType: string, entityId: number): Promise<CommentData[]> { return this.commentService.getComments(entityType, entityId); } /** * Create a comment on an entity (delegates to CommentService) */ async createComment(entityId: number, description: string, isPrivate?: boolean, parentCommentId?: number): Promise<CommentData> { return this.commentService.createComment({ entityId, description, isPrivate, parentCommentId }); } /** * Delete a comment by ID (delegates to CommentService) */ async deleteComment(commentId: number): Promise<boolean> { return this.commentService.deleteComment(commentId); } /** * Helper method to get user stories with related data */ async getUserStories( where?: string, include: string[] = ['Project', 'Team', 'Feature', 'Tasks', 'Bugs'] ): Promise<(UserStoryData & AssignableEntityData)[]> { return this.searchEntities<UserStoryData & AssignableEntityData>( 'UserStory', where, include ); } /** * 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> { return this.getEntity<UserStoryData & AssignableEntityData>( 'UserStory', id, include ); } /** * Fetch detailed metadata about entity types and their properties */ async fetchMetadata(): Promise<any> { try { // Step 1: Get basic entity types from /EntityTypes (fast, reliable) const entityTypesData = await this.fetchEntityTypes(); // Step 2: Try to get relationship metadata from /meta (may fail due to JSON issues) let metaData = null; try { metaData = await this.fetchMetaEndpoint(); } catch (error) { logger.warn('Failed to fetch /meta endpoint, using EntityTypes only:', error); } // Step 3: Combine and enhance the data return this.createHybridMetadata(entityTypesData, 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)}` ); } } /** * Fetch entity types from /EntityTypes endpoint (faster, smaller response) */ async fetchEntityTypes(): Promise<any> { try { const allItems: any[] = []; let skip = 0; const take = 100; let hasMore = true; while (hasMore) { const queryString = this.queryBuilder .reset() .format('json') .take(take) .buildParams(); queryString.append('skip', skip.toString()); const batch = await this.httpClient.get<{ Items: any[] }>(`EntityTypes?${queryString}`); if (batch.Items && batch.Items.length > 0) { allItems.push(...batch.Items); skip += take; hasMore = batch.Items.length === take; } else { hasMore = false; } } return { Items: allItems }; } catch (error) { if (error instanceof McpError) { throw error; } throw new McpError( ErrorCode.InvalidRequest, `Failed to fetch entity types: ${error instanceof Error ? error.message : String(error)}` ); } } /** * Get valid entity types (delegates to EntityValidator) */ async getValidEntityTypes(): Promise<string[]> { try { logger.info('Fetching valid entity types from Target Process API...'); // Start with static entity types from registry const staticEntityTypes = EntityRegistry.getAllEntityTypes(); const entityTypes = new Set<string>(staticEntityTypes); try { // Fetch from /EntityTypes endpoint const entityTypesResponse = await this.fetchEntityTypes(); if (entityTypesResponse && entityTypesResponse.Items) { logger.info(`EntityTypes response received with ${entityTypesResponse.Items.length} items`); // Add all entity types from the API for (const item of entityTypesResponse.Items) { if (item.Name && typeof item.Name === 'string') { entityTypes.add(item.Name); // Register custom entity types that aren't in the static registry if (!EntityRegistry.isValidEntityType(item.Name)) { logger.info(`Registering custom entity type: ${item.Name}`); EntityRegistry.registerCustomEntityType(item.Name); } } } } else { logger.warn('EntityTypes response missing Items array'); } } catch (apiError) { logger.warn('Failed to fetch from /EntityTypes endpoint, using static list only:', apiError); } const finalEntityTypes = Array.from(entityTypes).sort(); logger.info(`Total valid entity types: ${finalEntityTypes.length} (${finalEntityTypes.length - staticEntityTypes.length} from API)`); return finalEntityTypes; } catch (error) { logger.error('Error in getValidEntityTypes:', error); logger.warn('Falling back to static entity type list'); return EntityRegistry.getAllEntityTypes(); } } /** * Initialize the entity type cache on server startup */ async initializeEntityTypeCache(): Promise<void> { await this.entityValidator.initializeEntityTypeCache(); } /** * Get attachment metadata information */ async getAttachmentInfo(attachmentId: number): Promise<any> { try { const idValidation = this.entityValidator.validateEntityId(attachmentId); if (!idValidation.isValid) { throw new McpError(ErrorCode.InvalidRequest, idValidation.errors.join('; ')); } return await this.httpClient.get(`Attachments/${attachmentId}`); } catch (error) { if (error instanceof McpError) throw error; throw new McpError( ErrorCode.InternalError, `Failed to get attachment info: ${error instanceof Error ? error.message : String(error)}` ); } } /** * Download attachment content as base64 */ async downloadAttachment(attachmentId: number): Promise<any> { try { const idValidation = this.entityValidator.validateEntityId(attachmentId); if (!idValidation.isValid) { throw new McpError(ErrorCode.InvalidRequest, idValidation.errors.join('; ')); } // First get attachment metadata const attachmentInfo = await this.getAttachmentInfo(attachmentId); // Download the actual file content const arrayBuffer = await this.httpClient.downloadBinary(attachmentInfo.Uri); // Convert to base64 const base64Content = Buffer.from(arrayBuffer).toString('base64'); return { attachmentId, filename: attachmentInfo.Name, mimeType: attachmentInfo.MimeType, size: attachmentInfo.Size, base64Content, uploadDate: attachmentInfo.Date, owner: attachmentInfo.Owner ? { id: attachmentInfo.Owner.Id, name: `${attachmentInfo.Owner.FirstName || ''} ${attachmentInfo.Owner.LastName || ''}`.trim() } : null }; } catch (error) { if (error instanceof McpError) throw error; throw new McpError( ErrorCode.InternalError, `Failed to download attachment: ${error instanceof Error ? error.message : String(error)}` ); } } /** * Get headers for authentication (for backward compatibility) */ private getHeaders(): Record<string, string> { const headers: Record<string, string> = { 'Content-Type': 'application/json', 'Accept': 'application/json' }; if (this.httpClient.getAuthType() === 'basic') { const authConfig = this.queryBuilder['authConfig'] as AuthConfig; headers['Authorization'] = `Basic ${authConfig.token}`; } return headers; } /** * Private methods for metadata processing (simplified versions) */ private async fetchMetaEndpoint(): Promise<any> { try { return await this.httpClient.get('meta?format=json'); } catch (error) { throw new McpError( ErrorCode.InvalidRequest, 'Failed to parse /meta response: malformed JSON' ); } } private createHybridMetadata(entityTypesData: any, metaData: any): any { const result = { Items: [...entityTypesData.Items] }; // Enhance with EntityRegistry system types const systemTypes = EntityRegistry.getEntityTypesByCategory(EntityCategory.SYSTEM); const existingNames = new Set(result.Items.map((item: any) => item.Name)); for (const systemType of systemTypes) { if (!existingNames.has(systemType)) { const entityInfo = EntityRegistry.getEntityTypeInfo(systemType); if (entityInfo) { result.Items.push({ Name: systemType, Description: entityInfo.description, IsAssignable: entityInfo.category === EntityCategory.ASSIGNABLE, IsGlobal: entityInfo.category === EntityCategory.SYSTEM, SupportsCustomFields: entityInfo.supportsCustomFields, Source: 'EntityRegistry' }); } } } return result; } }

MCP directory API

We provide all the information about MCP servers via our MCP API.

curl -X GET 'https://glama.ai/api/mcp/v1/servers/aaronsb/apptio-target-process-mcp'

If you have feedback or need assistance with the MCP directory API, please join our Discord server