Skip to main content
Glama
build-trigger-manager.ts31.4 kB
/** * BuildTriggerManager - Manages build triggers in TeamCity */ import { debug, error as logError } from '@/utils/logger'; import { type TeamCityErrorResponse, type TeamCityTriggerResponse, type TeamCityTriggersResponse, isTeamCityTriggerResponse, isTeamCityTriggersResponse, isTeamCityVcsRootEntriesResponse, normalizeProperties, normalizeTriggers, normalizeVcsRootEntries, propertiesToRecord, } from './api-types'; import type { TeamCityClientAdapter } from './client-adapter'; import { BuildConfigurationNotFoundError, CircularDependencyError, TeamCityAPIError, TriggerNotFoundError, ValidationError, } from './errors'; /** * Supported trigger types in TeamCity */ export type TriggerType = 'vcsTrigger' | 'schedulingTrigger' | 'buildDependencyTrigger'; /** * Base trigger interface */ export interface BuildTrigger { id: string; type: TriggerType; enabled: boolean; properties: Record<string, string>; // Parsed properties for dependency triggers dependsOn?: string | string[]; afterSuccessfulBuildOnly?: boolean; artifactRules?: string; dependOnStartedBuild?: boolean; promoteArtifacts?: boolean; } /** * VCS trigger specific properties */ export interface VcsTriggerProperties { branchFilter?: string; quietPeriodMode?: 'DO_NOT_USE' | 'USE_DEFAULT' | 'USE_CUSTOM'; quietPeriod?: number; triggerRules?: string; enableQueueOptimization?: boolean; vcsRootId?: string; // Optional specific VCS root to monitor } /** * Schedule trigger specific properties */ export interface ScheduleTriggerProperties { schedulingPolicy: string; // Cron or TeamCity format timezone?: string; triggerBuildWithPendingChangesOnly?: boolean; promoteWatchedBuild?: boolean; buildParameters?: Record<string, string>; } /** * Dependency trigger specific properties */ export interface DependencyTriggerProperties { dependsOn: string | string[]; // Build configuration ID(s) afterSuccessfulBuildOnly?: boolean; branchFilter?: string; artifactRules?: string; // Artifact rules in TeamCity format artifactDependencies?: string[]; dependOnStartedBuild?: boolean; promoteArtifacts?: boolean; } /** * Options for listing triggers */ export interface ListTriggersOptions { configId: string; fields?: string; } /** * Options for creating a trigger */ export interface CreateTriggerOptions { configId: string; type: TriggerType; enabled?: boolean; properties: VcsTriggerProperties | ScheduleTriggerProperties | DependencyTriggerProperties; } /** * Options for updating a trigger */ export interface UpdateTriggerOptions { configId: string; triggerId: string; enabled?: boolean; properties?: Partial< VcsTriggerProperties | ScheduleTriggerProperties | DependencyTriggerProperties >; } /** * Options for deleting a trigger */ export interface DeleteTriggerOptions { configId: string; triggerId: string; } /** * Options for validating a trigger */ export interface ValidateTriggerOptions { type: TriggerType; properties: VcsTriggerProperties | ScheduleTriggerProperties | DependencyTriggerProperties; } /** * Result for list triggers operation */ export interface ListTriggersResult { success: boolean; triggers: BuildTrigger[]; configId: string; } /** * Result for trigger operations */ export interface TriggerOperationResult { success: boolean; trigger?: BuildTrigger; message?: string; } /** * Result for trigger validation */ export interface ValidateTriggerResult { valid: boolean; errors: string[]; warnings?: string[]; } /** * Manages build triggers in TeamCity */ export class BuildTriggerManager { constructor(private readonly client: TeamCityClientAdapter) {} /** * List all triggers for a build configuration */ async listTriggers(options: ListTriggersOptions): Promise<ListTriggersResult> { const { configId, fields } = options; try { debug('Listing triggers for build configuration', { configId }); const response = await this.client.modules.buildTypes.getAllTriggers(configId, fields); if (!isTeamCityTriggersResponse(response.data)) { throw new TeamCityAPIError( 'Unexpected triggers response shape', 'INVALID_RESPONSE', undefined, { configId, } ); } const triggers = this.parseTriggerList(response.data); return { success: true, triggers, configId, }; } catch (err) { if (this.isNotFoundError(err)) { throw new BuildConfigurationNotFoundError( `Build configuration '${configId}' not found`, configId ); } throw this.handleApiError(err, 'Failed to list triggers'); } } /** * Create a new trigger */ async createTrigger(options: CreateTriggerOptions): Promise<TriggerOperationResult> { const { configId, type, enabled = true, properties } = options; try { debug('Creating trigger', { configId, type }); // Validate trigger before creation const validation = this.validateTrigger({ type, properties }); if (!validation.valid) { throw new ValidationError( `Invalid trigger configuration: ${validation.errors.join(', ')}`, { errors: validation.errors } ); } // Additional VCS root validation for VCS triggers if (type === 'vcsTrigger') { const vcsProps = properties as VcsTriggerProperties; if (vcsProps.vcsRootId) { await this.validateVcsRoot(configId, vcsProps.vcsRootId); } } // Check for circular dependencies if it's a dependency trigger if (type === 'buildDependencyTrigger') { const depProps = properties as DependencyTriggerProperties; const dependsOn = Array.isArray(depProps.dependsOn) ? depProps.dependsOn[0] : depProps.dependsOn; if (dependsOn) { await this.checkCircularDependency(configId, dependsOn); } } const triggerData = this.buildTriggerPayload(type, properties, !enabled); const response = await this.client.modules.buildTypes.addTriggerToBuildType( configId, undefined, triggerData ); if (!isTeamCityTriggerResponse(response.data)) { throw new TeamCityAPIError( 'Unexpected trigger response shape', 'INVALID_RESPONSE', undefined, { configId, type, } ); } const trigger = this.parseTrigger(response.data); return { success: true, trigger, message: `Trigger created successfully`, }; } catch (err) { if (err instanceof ValidationError || err instanceof CircularDependencyError) { throw err; } if (this.isNotFoundError(err)) { throw new BuildConfigurationNotFoundError( `Build configuration '${configId}' not found`, configId ); } throw this.handleApiError(err, 'Failed to create trigger'); } } /** * Update an existing trigger */ async updateTrigger(options: UpdateTriggerOptions): Promise<TriggerOperationResult> { const { configId, triggerId, enabled, properties } = options; try { debug('Updating trigger', { configId, triggerId }); // Get existing trigger first const existingResponse = await this.client.modules.buildTypes.getTrigger(configId, triggerId); if (!isTeamCityTriggerResponse(existingResponse.data)) { throw new TeamCityAPIError( 'Unexpected trigger response shape', 'INVALID_RESPONSE', undefined, { configId, triggerId, context: 'update-existing', } ); } const existingTrigger = this.parseTrigger(existingResponse.data); // Merge properties const mergedProperties = properties ? { ...existingTrigger.properties, ...this.propertiesToRecord( properties as | VcsTriggerProperties | ScheduleTriggerProperties | DependencyTriggerProperties ), } : existingTrigger.properties; const triggerData = this.buildTriggerPayload( existingTrigger.type, mergedProperties as | VcsTriggerProperties | ScheduleTriggerProperties | DependencyTriggerProperties, enabled !== undefined ? !enabled : !existingTrigger.enabled ); const response = await this.client.modules.buildTypes.replaceTrigger( configId, triggerId, undefined, triggerData ); if (!isTeamCityTriggerResponse(response.data)) { throw new TeamCityAPIError( 'Unexpected trigger response shape', 'INVALID_RESPONSE', undefined, { configId, triggerId, context: 'update-result', } ); } const trigger = this.parseTrigger(response.data); return { success: true, trigger, message: `Trigger updated successfully`, }; } catch (err) { if (this.isNotFoundError(err)) { throw new TriggerNotFoundError(`Trigger '${triggerId}' not found`, triggerId); } throw this.handleApiError(err, 'Failed to update trigger'); } } /** * Delete a trigger */ async deleteTrigger(options: DeleteTriggerOptions): Promise<TriggerOperationResult> { const { configId, triggerId } = options; try { debug('Deleting trigger', { configId, triggerId }); await this.client.modules.buildTypes.deleteTrigger(configId, triggerId); return { success: true, message: `Trigger deleted successfully`, }; } catch (err) { if (this.isNotFoundError(err)) { throw new TriggerNotFoundError(`Trigger '${triggerId}' not found`, triggerId); } throw this.handleApiError(err, 'Failed to delete trigger'); } } /** * Validate trigger configuration */ validateTrigger(options: ValidateTriggerOptions): ValidateTriggerResult { const { type, properties } = options; const errors: string[] = []; const warnings: string[] = []; switch (type) { case 'vcsTrigger': { const vcsProps = properties as VcsTriggerProperties; if (vcsProps.branchFilter && !this.isValidBranchFilter(vcsProps.branchFilter)) { errors.push('Invalid branch filter pattern'); } if (vcsProps.quietPeriod !== undefined && vcsProps.quietPeriod < 0) { errors.push('Quiet period must be non-negative'); } if (vcsProps.quietPeriodMode === 'USE_CUSTOM' && vcsProps.quietPeriod === undefined) { errors.push('Quiet period is required when using USE_CUSTOM mode'); } if (vcsProps.triggerRules && !this.isValidPathFilterRules(vcsProps.triggerRules)) { errors.push('Invalid path filter rules syntax'); } break; } case 'schedulingTrigger': { const scheduleProps = properties as ScheduleTriggerProperties; if (!this.isValidSchedule(scheduleProps.schedulingPolicy)) { errors.push('Invalid schedule format'); } if (scheduleProps.timezone && !this.isValidTimezone(scheduleProps.timezone)) { warnings.push('Unrecognized timezone'); } break; } case 'buildDependencyTrigger': { const depProps = properties as DependencyTriggerProperties; if (depProps.dependsOn == null) { errors.push('Dependency trigger requires dependsOn property'); } if (depProps.artifactRules && !this.isValidArtifactRules(depProps.artifactRules)) { errors.push('Invalid artifact rule format'); } if (depProps.branchFilter && !this.isValidBranchFilter(depProps.branchFilter)) { errors.push('Invalid branch filter pattern'); } break; } default: errors.push(`Unknown trigger type: ${type as string}`); } return { valid: errors.length === 0, errors, warnings, }; } /** * Parse trigger list from API response */ private parseTriggerList(data: TeamCityTriggersResponse): BuildTrigger[] { const triggers = normalizeTriggers(data); return triggers.map((t) => this.parseTrigger(t)); } /** * Parse single trigger from API response */ private parseTrigger(data: TeamCityTriggerResponse): BuildTrigger { const propsArray = normalizeProperties(data.properties); const properties = propertiesToRecord(propsArray); const trigger: BuildTrigger = { id: data.id ?? '', type: data.type as TriggerType, enabled: data.disabled !== true, properties, }; // Parse dependency trigger specific properties if (data.type === 'buildDependencyTrigger') { const dependsOn = properties['dependsOn']; if (dependsOn) { // Check if it's a comma-separated list trigger.dependsOn = dependsOn.includes(',') ? dependsOn.split(',').map((s) => s.trim()) : dependsOn; } if (properties['afterSuccessfulBuildOnly']) { trigger.afterSuccessfulBuildOnly = properties['afterSuccessfulBuildOnly'] === 'true'; } const artifactRules = properties['artifactRules']; if (artifactRules) { trigger.artifactRules = artifactRules; } if (properties['dependOnStartedBuild']) { trigger.dependOnStartedBuild = properties['dependOnStartedBuild'] === 'true'; } if (properties['promoteArtifacts']) { trigger.promoteArtifacts = properties['promoteArtifacts'] === 'true'; } } return trigger; } /** * Build trigger payload for API */ private buildTriggerPayload( type: TriggerType, properties: VcsTriggerProperties | ScheduleTriggerProperties | DependencyTriggerProperties, disabled: boolean ): { type: TriggerType; disabled: boolean; properties: { property: Array<{ name: string; value: string }> }; } { const propertyArray = Object.entries(this.propertiesToRecord(properties)).map( ([name, value]) => ({ name, value }) ); return { type, disabled, properties: { property: propertyArray, }, }; } /** * Convert typed properties to record */ private propertiesToRecord( properties: | VcsTriggerProperties | ScheduleTriggerProperties | DependencyTriggerProperties | Record<string, string | number | boolean | undefined | null | string[]> ): Record<string, string> { const record: Record<string, string> = {}; Object.entries(properties).forEach(([key, value]) => { // Handle build parameters specially if (key === 'buildParameters' && typeof value === 'object' && value !== null) { // Convert buildParameters object to individual properties Object.entries(value as Record<string, unknown>).forEach(([paramKey, paramValue]) => { if (paramValue != null) { record[`buildParams.${paramKey}`] = String(paramValue); } }); } // Handle array values (e.g., multiple dependsOn configurations) else if (Array.isArray(value)) { record[key] = value.join(','); } else if (value != null) { record[key] = String(value); } }); return record; } /** * Validate VCS root exists and is attached to the build configuration */ private async validateVcsRoot(configId: string, vcsRootId: string): Promise<void> { try { // Check if VCS root is attached to this build configuration const response = await this.client.modules.buildTypes.getAllVcsRootsOfBuildType( configId, 'vcs-root(id)' ); if (!isTeamCityVcsRootEntriesResponse(response.data)) { throw new TeamCityAPIError( 'Unexpected VCS root response shape', 'INVALID_RESPONSE', undefined, { configId, vcsRootId, } ); } const rootEntries = normalizeVcsRootEntries(response.data); const hasVcsRoot = rootEntries.some((entry) => entry['vcs-root']?.id === vcsRootId); if (!hasVcsRoot) { throw new ValidationError( `VCS root '${vcsRootId}' is not attached to build configuration '${configId}'`, { vcsRootId, configId } ); } } catch (err) { if (err instanceof ValidationError) { throw err; } // If we can't validate, log a warning but proceed debug('Could not validate VCS root', { error: err }); } } /** * Check for circular dependencies */ private async checkCircularDependency(sourceConfig: string, targetConfig: string): Promise<void> { // Get triggers from target config try { const response = await this.client.modules.buildTypes.getAllTriggers(targetConfig); if (!isTeamCityTriggersResponse(response.data)) { throw new TeamCityAPIError( 'Unexpected triggers response shape', 'INVALID_RESPONSE', undefined, { configId: targetConfig, context: 'circular-dependency-check', } ); } const triggers = this.parseTriggerList(response.data); // Check if target already depends on source const hasCycle = triggers.some( (t) => t.type === 'buildDependencyTrigger' && t.properties['dependsOn'] === sourceConfig ); if (hasCycle) { throw new CircularDependencyError( `Circular dependency detected between ${sourceConfig} and ${targetConfig}`, { source: sourceConfig, target: targetConfig } ); } } catch (err) { if (err instanceof CircularDependencyError) { throw err; } // Ignore other errors - we'll let the creation proceed debug('Could not check for circular dependencies', { error: err }); } } /** * Validate branch filter pattern */ private isValidBranchFilter(filter: string): boolean { // Enhanced validation for branch filter patterns // Format: [+|-]:pattern with support for wildcards and refs const patterns = filter.trim().split(/\s+/); for (const pattern of patterns) { // Must start with + or - if (!/^[+-]:/.test(pattern)) { return false; } // Extract the actual pattern after the prefix const branchPattern = pattern.substring(2); // Pattern must not be empty if (!branchPattern || branchPattern.length === 0) { return false; } // Check for common invalid characters if (/[\s]/.test(branchPattern)) { return false; } } return true; } /** * Validate artifact rules format */ private isValidArtifactRules(rules: string): boolean { // Artifact rules format: source => target // Multiple rules separated by newlines const lines = rules.trim().split(/\n/); for (const line of lines) { const trimmedLine = line.trim(); // Skip empty lines if (!trimmedLine) { continue; } // Check for valid pattern - must have path-like structure // or => separator for target mapping if (trimmedLine.includes('=>')) { const parts = trimmedLine.split('=>'); if (parts.length !== 2) { return false; } const source = parts[0]?.trim(); const target = parts[1]?.trim(); // Both source and target must be non-empty if => is used if (source === undefined || source === '' || target === undefined || target === '') { return false; } // Source should look like a path pattern if (!this.isValidPathPattern(source)) { return false; } } else { // Single path pattern without target if (!this.isValidPathPattern(trimmedLine)) { return false; } } } return true; } /** * Check if a string looks like a valid path pattern */ private isValidPathPattern(pattern: string): boolean { // Must contain at least one of: /, *, ., or be a simple filename // Cannot be just random text return /[/*.]|^[\w-]+\.\w+$/.test(pattern); } /** * Validate path filter rules syntax */ private isValidPathFilterRules(rules: string): boolean { // Path filter rules use similar syntax to branch filters // Format: [+|-]:path/pattern on each line const lines = rules.trim().split(/\n/); for (const line of lines) { const trimmedLine = line.trim(); // Skip empty lines if (!trimmedLine) { continue; } // Must start with + or - if (!/^[+-]:/.test(trimmedLine)) { return false; } // Extract the path pattern const pathPattern = trimmedLine.substring(2); // Pattern must not be empty if (!pathPattern || pathPattern.length === 0) { return false; } // Check for invalid double colons if (pathPattern.includes('::')) { return false; } } return true; } /** * Validate schedule format */ private isValidSchedule(schedule: string): boolean { // Check for TeamCity simple formats const simpleFormats = ['daily', 'weekly', 'nightly', 'hourly']; if (simpleFormats.includes(schedule.toLowerCase())) { return true; } // Enhanced cron validation (6 or 7 fields) const cronParts = schedule.trim().split(/\s+/); if (cronParts.length !== 6 && cronParts.length !== 7) { return false; } // Validate each cron field const [seconds, minutes, hours, dayOfMonth, month, dayOfWeek] = cronParts; // Validate seconds (0-59) if (seconds === undefined || !this.isValidCronField(seconds, 0, 59)) { return false; } // Validate minutes (0-59) if (minutes === undefined || !this.isValidCronField(minutes, 0, 59)) { return false; } // Validate hours (0-23) if (hours === undefined || !this.isValidCronField(hours, 0, 23)) { return false; } // Validate day of month (1-31) if (dayOfMonth === undefined || !this.isValidCronField(dayOfMonth, 1, 31, true)) { return false; } // Validate month (1-12) if (month === undefined || !this.isValidCronField(month, 1, 12, true)) { return false; } // Validate day of week (0-7, where 0 and 7 are Sunday) if (dayOfWeek === undefined || !this.isValidCronField(dayOfWeek, 0, 7, true)) { return false; } return true; } /** * Validate individual cron field */ private isValidCronField( field: string, min: number, max: number, allowWildcard = false ): boolean { // Handle wildcards if (field === '*') { return true; // Wildcard is always allowed } if (field === '?') { return allowWildcard; // Question mark only for day fields } // Handle ranges (e.g., 1-5) if (field.includes('-')) { const parts = field.split('-'); if (parts.length !== 2) { return false; } const start = Number(parts[0]); const end = Number(parts[1]); if (isNaN(start) || isNaN(end)) { return false; } return start >= min && start <= max && end >= min && end <= max && start <= end; } // Handle steps (e.g., */5) if (field.includes('/')) { const parts = field.split('/'); if (parts.length !== 2) { return false; } const range = parts[0]; const step = parts[1]; if (range === undefined || step === undefined) { return false; } const stepNum = Number(step); if (isNaN(stepNum) || stepNum <= 0) { return false; } if (range === '*') { return true; } return this.isValidCronField(range, min, max, allowWildcard); } // Handle lists (e.g., 1,3,5) if (field.includes(',')) { const values = field.split(','); return values.every((v) => this.isValidCronField(v, min, max, allowWildcard)); } // Handle single number const num = Number(field); if (isNaN(num)) { return false; } return num >= min && num <= max; } /** * Validate timezone */ private isValidTimezone(timezone: string): boolean { // Common timezone patterns const commonTimezones = [ 'UTC', 'GMT', 'America/New_York', 'America/Chicago', 'America/Denver', 'America/Los_Angeles', 'Europe/London', 'Europe/Paris', 'Europe/Berlin', 'Asia/Tokyo', 'Asia/Shanghai', 'Australia/Sydney', ]; // Check if it's a common timezone if (commonTimezones.includes(timezone)) { return true; } // Check for valid timezone format (Continent/City or abbreviation) return /^([A-Z]{2,4}|[A-Za-z]+\/[A-Za-z_]+)$/.test(timezone); } /** * Calculate next run time for a schedule */ public calculateNextRunTime(schedule: string, timezone?: string): Date { const now = new Date(); // Handle TeamCity simple formats const simpleFormats: Record<string, () => Date> = { daily: () => { const next = new Date(now); next.setDate(next.getDate() + 1); next.setHours(0, 0, 0, 0); return next; }, weekly: () => { const next = new Date(now); next.setDate(next.getDate() + 7); next.setHours(0, 0, 0, 0); return next; }, nightly: () => { const next = new Date(now); if (next.getHours() >= 2) { next.setDate(next.getDate() + 1); } next.setHours(2, 0, 0, 0); return next; }, hourly: () => { const next = new Date(now); next.setHours(next.getHours() + 1, 0, 0, 0); return next; }, }; const lowerSchedule = schedule.toLowerCase(); const formatFunc = simpleFormats[lowerSchedule]; if (formatFunc !== undefined) { return formatFunc(); } // Parse cron expression for next run time return this.calculateNextCronRun(schedule, now, timezone); } /** * Calculate next run time for cron expression */ private calculateNextCronRun(cron: string, from: Date, _timezone?: string): Date { // Simple implementation - in production, use a library like node-cron const parts = cron.trim().split(/\s+/); if (parts.length < 6) { return new Date(from.getTime() + 3600000); // Default to 1 hour } const [seconds, minutes, hours] = parts; const next = new Date(from); // Parse hours if (hours !== undefined && hours !== '*' && hours !== '?') { const hour = parseInt(hours, 10); if (!isNaN(hour)) { next.setHours(hour); } } // Parse minutes if (minutes !== undefined && minutes !== '*' && minutes !== '?') { const minute = parseInt(minutes, 10); if (!isNaN(minute)) { next.setMinutes(minute); } } // Parse seconds if (seconds !== undefined && seconds !== '*' && seconds !== '?') { const second = parseInt(seconds, 10); if (!isNaN(second)) { next.setSeconds(second); } } // If the calculated time is in the past, add one day if (next <= from) { next.setDate(next.getDate() + 1); } return next; } /** * Check if error is a 404 */ private isNotFoundError(err: unknown): boolean { return this.extractErrorResponse(err)?.status === 404; } /** * Validate dependency chain for circular dependencies * Check if adding sourceConfig -> targetConfig would create a cycle */ async validateDependencyChain( sourceConfig: string, targetConfig: string, visited: Set<string> = new Set() ): Promise<{ hasCircularDependency: boolean; chain: string[] }> { // We're checking if targetConfig (or its dependencies) eventually depend on sourceConfig // which would create a cycle when sourceConfig depends on targetConfig // Start from the target and follow its dependencies const chain: string[] = [sourceConfig, targetConfig]; // Helper function to check dependencies recursively const checkDependencies = async ( configId: string, lookingFor: string, path: string[] ): Promise<{ found: boolean; fullPath: string[] }> => { // If we've already visited this node, skip it to avoid infinite loops if (visited.has(configId)) { return { found: false, fullPath: path }; } visited.add(configId); try { const result = await this.listTriggers({ configId }); const triggers = result.triggers; for (const trigger of triggers) { if ( trigger.type === 'buildDependencyTrigger' && trigger.dependsOn !== undefined && trigger.dependsOn !== null ) { const dependencies = Array.isArray(trigger.dependsOn) ? trigger.dependsOn : [trigger.dependsOn]; for (const dep of dependencies) { const newPath = [...path, dep]; // Found a cycle! if (dep === lookingFor) { return { found: true, fullPath: newPath }; } // Continue searching // Sequential DFS is intentional to avoid deep concurrent recursion // eslint-disable-next-line no-await-in-loop const result = await checkDependencies(dep, lookingFor, newPath); if (result.found) { return result; } } } } } catch (err) { debug(`Could not check dependencies for ${configId}`, { error: err }); } return { found: false, fullPath: path }; }; // Check if targetConfig (or its dependencies) depends on sourceConfig const result = await checkDependencies(targetConfig, sourceConfig, chain); return { hasCircularDependency: result.found, chain: result.found ? result.fullPath : chain, }; } /** * Handle API errors */ private handleApiError(err: unknown, context: string): Error { const normalizedError = err instanceof Error ? err : new Error(String(err)); logError(context, normalizedError); const response = this.extractErrorResponse(err); if (response != null) { const errorData = response.data as TeamCityErrorResponse | undefined; const status = response.status ?? 500; const message = errorData?.message ?? normalizedError.message ?? 'Request failed'; return new TeamCityAPIError(`${context}: ${message}`, `HTTP_${status}`, status); } return normalizedError; } private extractErrorResponse( err: unknown ): { status?: number; data?: { message?: string } } | undefined { if (typeof err === 'object' && err !== null && 'response' in err) { const response = (err as { response?: { status?: number; data?: { message?: string } } }) .response; if (response != null) { return response; } } return undefined; } }

Latest Blog Posts

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/Daghis/teamcity-mcp'

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