Skip to main content
Glama
tool-handlers.ts45.7 kB
/** * Tool Handlers for Azure DevOps Operations * Implements MCP tool handlers with dynamic environment switching * Enhanced with iteration path TF401347 error fix */ import { AzureDevOpsConfig } from '../types/index.js'; import * as https from 'https'; import * as url from 'url'; export class ToolHandlers { private currentConfig: AzureDevOpsConfig | null = null; /** * Set the current Azure DevOps configuration */ setCurrentConfig(config: AzureDevOpsConfig): void { this.currentConfig = config; } /** * Handle tool calls with current environment context */ async handleToolCall(request: any): Promise<any> { if (!this.currentConfig) { throw new Error('No Azure DevOps configuration available'); } const { name, arguments: args } = request.params; try { switch (name) { case 'get-work-items': return await this.getWorkItems(args || {}); case 'create-work-item': return await this.createWorkItem(args || {}); case 'update-work-item': return await this.updateWorkItem(args || {}); case 'add-work-item-comment': return await this.addWorkItemComment(args || {}); case 'get-repositories': return await this.getRepositories(args || {}); case 'get-builds': return await this.getBuilds(args || {}); case 'get-pull-requests': return await this.getPullRequests(args || {}); case 'trigger-pipeline': return await this.triggerPipeline(args || {}); case 'get-pipeline-status': return await this.getPipelineStatus(args || {}); default: throw new Error(`Unknown tool: ${name}`); } } catch (error) { // Sanitize tool name to prevent log injection const sanitizedName = typeof name === 'string' ? name.replace(/[\r\n\t]/g, '_') : 'unknown'; console.error(`Error in tool handler ${sanitizedName}:`, error instanceof Error ? error.message : 'Unknown error'); return { content: [{ type: 'text', text: `Error executing ${sanitizedName}: ${error instanceof Error ? error.message : 'Unknown error'}`, }], isError: true, }; } } /** * Make authenticated API request to Azure DevOps */ private async makeApiRequest(endpoint: string, method: string = 'GET', body?: any): Promise<any> { if (!this.currentConfig) { throw new Error('No configuration available'); } const { organizationUrl, pat, project } = this.currentConfig; const baseUrl = `${organizationUrl}/${project}/_apis`; const requestUrl = `${baseUrl}${endpoint}`; return new Promise((resolve, reject) => { const urlParts = new url.URL(requestUrl); const postData = body ? JSON.stringify(body) : undefined; const options = { hostname: urlParts.hostname, port: urlParts.port || 443, path: urlParts.pathname + urlParts.search, method, headers: { 'Authorization': `Basic ${Buffer.from(`:${pat}`).toString('base64')}`, 'Content-Type': method === 'PATCH' && endpoint.includes('/wit/workitems/') ? 'application/json-patch+json' : 'application/json', 'Accept': 'application/json', // For preview APIs, we need to properly handle the API version in the URL, not headers ...(postData && { 'Content-Length': Buffer.byteLength(postData) }), }, }; const req = https.request(options, (res) => { let data = ''; res.on('data', (chunk) => { data += chunk; }); res.on('end', () => { try { if (res.statusCode && res.statusCode >= 200 && res.statusCode < 300) { const result = data ? JSON.parse(data) : {}; resolve(result); } else { reject(new Error(`HTTP ${res.statusCode}: ${data}`)); } } catch (error) { reject(new Error(`Failed to parse response: ${error}`)); } }); }); req.on('error', (error) => { reject(new Error(`Request failed: ${error.message}`)); }); if (postData) { req.write(postData); } req.end(); }); } /** * Get work items from Azure DevOps */ private async getWorkItems(args: any): Promise<any> { try { let result; if (args.wiql) { // Query using WIQL const wiqlResult = await this.makeApiRequest('/wit/wiql?api-version=7.1', 'POST', { query: args.wiql }); if (wiqlResult.workItems && wiqlResult.workItems.length > 0) { const ids = wiqlResult.workItems.map((wi: any) => wi.id); const fields = args.fields ? args.fields.join(',') : undefined; const fieldsParam = fields ? `&fields=${encodeURIComponent(fields)}` : ''; result = await this.makeApiRequest( `/wit/workitems?ids=${ids.join(',')}${fieldsParam}&api-version=7.1` ); } else { result = { value: [] }; } } else if (args.ids && args.ids.length > 0) { // Get specific work items by ID const fields = args.fields ? args.fields.join(',') : undefined; const fieldsParam = fields ? `&fields=${encodeURIComponent(fields)}` : ''; result = await this.makeApiRequest( `/wit/workitems?ids=${args.ids.join(',')}${fieldsParam}&api-version=7.1` ); } else { // Default query for recent work items const defaultWiql = `SELECT [System.Id], [System.Title], [System.State], [System.AssignedTo] FROM WorkItems WHERE [System.TeamProject] = '${this.currentConfig!.project}' ORDER BY [System.ChangedDate] DESC`; const wiqlResult = await this.makeApiRequest('/wit/wiql?api-version=7.1', 'POST', { query: defaultWiql }); if (wiqlResult.workItems && wiqlResult.workItems.length > 0) { const ids = wiqlResult.workItems.slice(0, 20).map((wi: any) => wi.id); // Limit to 20 items result = await this.makeApiRequest( `/wit/workitems?ids=${ids.join(',')}&api-version=7.1` ); } else { result = { value: [] }; } } return { content: [{ type: 'text', text: JSON.stringify(result, null, 2), }], }; } catch (error) { throw new Error(`Failed to get work items: ${error instanceof Error ? error.message : 'Unknown error'}`); } } /** * Normalize iteration path format for Azure DevOps API compatibility * Format: ProjectName\SprintName (NOT ProjectName\Iteration\SprintName) * Azure DevOps REST API expects direct hierarchy without 'Iteration' component * Fixed TF401347 error by using correct \Iteration\ prefix format */ private normalizeIterationPath(iterationPath: string): string { // Remove leading/trailing whitespace let normalized = iterationPath.trim(); // Convert forward slashes to backslashes for consistency with Azure DevOps normalized = normalized.replace(/\//g, '\\'); // Remove leading backslash if present if (normalized.startsWith('\\')) { normalized = normalized.substring(1); } // Handle different input scenarios const projectName = this.currentConfig!.project; // Case 1: Path starts with project name and has proper Iteration prefix if (normalized.startsWith(`${projectName}\\Iteration\\`)) { console.log(`[DEBUG] Path already in correct format with Iteration prefix: ${normalized}`); return normalized; } // Case 2: Path starts with project but missing Iteration component if (normalized.startsWith(`${projectName}\\`) && !normalized.includes('\\Iteration\\')) { // Insert Iteration component after project name const pathParts = normalized.split('\\'); if (pathParts.length >= 2) { pathParts.splice(1, 0, 'Iteration'); normalized = pathParts.join('\\'); console.log(`[DEBUG] Added Iteration component to path: ${normalized}`); return normalized; } } // Case 3: Has Iteration prefix but missing project name (Iteration\SprintName) if (normalized.startsWith('Iteration\\')) { normalized = `${projectName}\\${normalized}`; console.log(`[DEBUG] Added project name prefix to Iteration path: ${normalized}`); return normalized; } // Case 4: Just the sprint name (SprintName or Sprint 3) if (!normalized.includes('\\')) { normalized = `${projectName}\\Iteration\\${normalized}`; console.log(`[DEBUG] Added full project and Iteration prefix to sprint: ${normalized}`); return normalized; } // Case 5: Starts with something else - ensure proper format if (!normalized.startsWith(projectName)) { // Check if it already has an Iteration component if (normalized.includes('\\Iteration\\')) { normalized = `${projectName}\\${normalized}`; } else { // Add both project name and Iteration component normalized = `${projectName}\\Iteration\\${normalized}`; } console.log(`[DEBUG] Added project name prefix with Iteration: ${normalized}`); } console.log(`[DEBUG] Normalized iteration path from '${iterationPath}' to '${normalized}'`); return normalized; } /** * Validate if an iteration path exists in the project using improved logic */ private async validateIterationPath(iterationPath: string): Promise<string> { try { const normalizedPath = this.normalizeIterationPath(iterationPath); // Approach 1: Get project classification nodes with deep traversal try { const classificationNodes = await this.makeApiRequest('/wit/classificationnodes/iterations?api-version=7.1&$depth=10'); const findInNodes = (node: any, targetPath: string): boolean => { // Check current node path if (node.path === targetPath) { console.log(`[DEBUG] Found exact path match: ${node.path}`); return true; } // Check alternative path formats (direct hierarchy without Iteration component) const alternativePaths = [ node.path, node.name, `${this.currentConfig!.project}\\${node.name}`, node.structureType === 'iteration' ? node.path : null ].filter(Boolean); for (const altPath of alternativePaths) { if (altPath === targetPath || altPath?.replace(/\\/g, '/') === targetPath.replace(/\\/g, '/')) { console.log(`[DEBUG] Found alternative path match: ${altPath} -> ${targetPath}`); return true; } } // Recursively check children if (node.children && node.children.length > 0) { for (const child of node.children) { if (findInNodes(child, targetPath)) { return true; } } } return false; }; if (classificationNodes && findInNodes(classificationNodes, normalizedPath)) { console.log(`[DEBUG] Iteration path '${normalizedPath}' validated successfully`); return normalizedPath; } // Also try with original path format if (normalizedPath !== iterationPath && findInNodes(classificationNodes, iterationPath)) { console.log(`[DEBUG] Original iteration path '${iterationPath}' validated successfully`); return iterationPath; } } catch (classificationError) { console.log(`[DEBUG] Classification nodes query failed: ${classificationError instanceof Error ? classificationError.message : 'Unknown error'}`); } // Approach 2: Get team iterations (fallback) try { const iterations = await this.makeApiRequest('/work/teamsettings/iterations?api-version=7.1'); const pathExists = iterations.value.some((iteration: any) => { const possiblePaths = [ iteration.path, iteration.name, `${this.currentConfig!.project}\\${iteration.name}`, `${this.currentConfig!.project}/${iteration.name}` ].filter(Boolean); return possiblePaths.some(path => path === normalizedPath || path === iterationPath || path?.replace(/\\/g, '/') === normalizedPath.replace(/\\/g, '/') || path?.replace(/\\/g, '/') === iterationPath.replace(/\\/g, '/') ); }); if (pathExists) { console.log(`[DEBUG] Iteration path validated via team iterations`); return normalizedPath; } } catch (teamError) { console.log(`[DEBUG] Team iterations query failed: ${teamError instanceof Error ? teamError.message : 'Unknown error'}`); } // If both validation attempts failed to find the path, it doesn't exist console.log(`[DEBUG] Could not validate iteration path '${iterationPath}', normalized format '${normalizedPath}' does not exist`); console.log(`[DEBUG] SUGGESTION: Ensure the iteration '${normalizedPath}' exists in Azure DevOps project settings`); console.log(`[DEBUG] Expected format: ProjectName\\SprintName (e.g., '${this.currentConfig!.project}\\Sprint 1')`); throw new Error(`Iteration path '${iterationPath}' does not exist in project '${this.currentConfig!.project}'`); } catch (error) { // If there was an error that's not related to path validation (e.g., auth, network), // check if it's our custom "doesn't exist" error, if so re-throw it if (error instanceof Error && error.message.includes('does not exist in project')) { throw error; } // For other errors (network, auth, etc.), return normalized path with warning const normalizedPath = this.normalizeIterationPath(iterationPath); console.log(`[DEBUG] Validation error for path '${iterationPath}': ${error instanceof Error ? error.message : 'Unknown error'}`); console.log(`[DEBUG] Using normalized path due to validation service unavailability`); return normalizedPath; } } /** * Update work item iteration path post-creation */ private async updateWorkItemIterationPath(workItemId: number, iterationPath: string): Promise<void> { const operations = [{ op: 'replace', path: '/fields/System.IterationPath', value: iterationPath }]; await this.makeApiRequest( `/wit/workitems/${workItemId}?api-version=7.1`, 'PATCH', operations ); } /** * Validate work item state for the given work item type * Prevents invalid state errors by checking supported states */ private async validateWorkItemState(workItemType: string, state: string): Promise<string> { try { // Get work item type definition to check valid states const typeDefinition = await this.makeApiRequest( `/wit/workitemtypes/${encodeURIComponent(workItemType)}?api-version=7.1` ); // Extract valid states from the work item type definition const validStates = typeDefinition.states?.map((s: any) => s.name) || []; if (validStates.length > 0 && !validStates.includes(state)) { console.log(`[DEBUG] Invalid state '${state}' for work item type '${workItemType}'. Valid states: [${validStates.join(', ')}]`); // Common state mappings for fallback const stateMappings: { [key: string]: { [key: string]: string } } = { 'Bug': { 'Removed': 'Resolved', 'removed': 'Resolved' }, 'Task': { 'Removed': 'Done', 'removed': 'Done' }, 'User Story': { 'Removed': 'Resolved', 'removed': 'Resolved' } }; const fallbackState = stateMappings[workItemType]?.[state] || validStates[0] || 'Active'; console.log(`[DEBUG] Using fallback state '${fallbackState}' instead of '${state}' for work item type '${workItemType}'`); return fallbackState; } console.log(`[DEBUG] State '${state}' is valid for work item type '${workItemType}'`); return state; } catch (error) { // If validation fails, return the original state and let Azure DevOps handle it console.log(`[DEBUG] Could not validate state '${state}' for work item type '${workItemType}': ${error instanceof Error ? error.message : 'Unknown error'}`); console.log(`[DEBUG] Proceeding with original state - Azure DevOps will validate`); return state; } } /** * Create a new work item in Azure DevOps with enhanced iteration path handling */ private async createWorkItem(args: any): Promise<any> { if (!args.type || !args.title) { throw new Error('Work item type and title are required'); } try { const operations = [ { op: 'add', path: '/fields/System.Title', value: args.title } ]; if (args.description) { operations.push({ op: 'add', path: '/fields/System.Description', value: args.description }); } if (args.assignedTo) { operations.push({ op: 'add', path: '/fields/System.AssignedTo', value: args.assignedTo }); } if (args.tags) { operations.push({ op: 'add', path: '/fields/System.Tags', value: args.tags }); } // Support parent relationship during creation using relations API if (args.parent) { // Validate parent ID is a number const parentId = parseInt(args.parent, 10); if (isNaN(parentId) || parentId <= 0) { throw new Error(`Invalid parent work item ID: ${args.parent}. Must be a positive integer.`); } const parentUrl = `${this.currentConfig!.organizationUrl}/${this.currentConfig!.project}/_apis/wit/workItems/${parentId}`; console.log(`[DEBUG] Setting parent relationship to work item ${parentId} using URL: ${parentUrl}`); operations.push({ op: 'add', path: '/relations/-', value: { rel: 'System.LinkTypes.Hierarchy-Reverse', url: parentUrl, attributes: { comment: `Parent relationship set via MCP create-work-item command` } } }); } // Enhanced iteration path handling with normalization and validation let iterationPathHandled = false; let iterationPathError = null; let finalIterationPath = null; if (args.iterationPath) { try { // Validate and normalize the iteration path finalIterationPath = await this.validateIterationPath(args.iterationPath); // Add normalized path to the creation operations operations.push({ op: 'add', path: '/fields/System.IterationPath', value: finalIterationPath }); iterationPathHandled = true; console.log(`[DEBUG] Iteration path normalized to '${finalIterationPath}' and will be set during creation`); } catch (validationError) { iterationPathError = validationError; finalIterationPath = this.normalizeIterationPath(args.iterationPath); console.log(`[DEBUG] Iteration path validation failed: ${validationError instanceof Error ? validationError.message : 'Unknown error'}`); console.log(`[DEBUG] Will attempt to set normalized path '${finalIterationPath}' after work item creation`); } } // Support state during creation with validation if (args.state) { // Validate state for work item type to prevent TF401347-like errors const validatedState = await this.validateWorkItemState(args.type, args.state); operations.push({ op: 'add', path: '/fields/System.State', value: validatedState }); } // Handle generic field creation with intelligent field name resolution if (args.fields && typeof args.fields === 'object') { Object.entries(args.fields).forEach(([fieldName, fieldValue]) => { // CRITICAL FIX: Implement proper field name resolution as specified in GitHub issue #53 let normalizedFieldName = fieldName; // CRITICAL: Microsoft.VSTS.* fields must NEVER be prefixed with System. // Azure DevOps field categories: // - System fields: Always prefixed with "System." (e.g., System.Title, System.State) // - Microsoft fields: Never prefixed, use full name (e.g., Microsoft.VSTS.Common.Priority) // - Custom fields: May have organization-specific prefixes // Apply System. prefix ONLY to fields that don't already have System. or Microsoft. prefixes if (!fieldName.startsWith('System.') && !fieldName.startsWith('Microsoft.')) { // Only add System. prefix for known system fields without namespaces const knownSystemFields = ['Title', 'Description', 'State', 'AssignedTo', 'Tags', 'IterationPath', 'AreaPath']; if (knownSystemFields.includes(fieldName)) { normalizedFieldName = `System.${fieldName}`; } // All other fields (including BusinessValue, Priority, Effort) remain unchanged // This preserves custom fields and Microsoft.VSTS.* fields correctly } // System.* and Microsoft.* fields are preserved exactly as-is console.log(`[DEBUG] Field resolution: "${fieldName}" → "${normalizedFieldName}"`); operations.push({ op: 'add', path: `/fields/${normalizedFieldName}`, value: fieldValue }); }); } // Debug logging to validate the endpoint construction const endpoint = `/wit/workitems/$${args.type}?api-version=7.1`; console.log(`[DEBUG] Creating work item with endpoint: ${endpoint}`); console.log(`[DEBUG] Full URL will be: ${this.currentConfig!.organizationUrl}/${this.currentConfig!.project}/_apis${endpoint}`); // Create the work item const result = await this.makeApiRequest( endpoint, 'PATCH', operations ); // Handle iteration path post-creation if it wasn't set during creation if (args.iterationPath && !iterationPathHandled && finalIterationPath) { try { console.log(`[DEBUG] Attempting to set normalized iteration path '${finalIterationPath}' post-creation for work item ${result.id}`); await this.updateWorkItemIterationPath(result.id, finalIterationPath); // Refresh the work item to get updated fields const updatedResult = await this.makeApiRequest(`/wit/workitems/${result.id}?api-version=7.1`); Object.assign(result, updatedResult); console.log(`[DEBUG] Successfully set iteration path post-creation`); } catch (postCreationError) { console.error(`[WARNING] Failed to set iteration path post-creation: ${postCreationError instanceof Error ? postCreationError.message : 'Unknown error'}`); // Don't fail the entire operation, just log the warning } } // Extract parent information from relations let parentInfo = null; if (result.relations && result.relations.length > 0) { const parentRelation = result.relations.find((rel: any) => rel.rel === 'System.LinkTypes.Hierarchy-Reverse' ); if (parentRelation) { // Extract parent ID from URL (e.g., .../workItems/1562 -> 1562) const match = parentRelation.url.match(/workItems\/(\d+)$/); parentInfo = { id: match ? parseInt(match[1], 10) : null, url: parentRelation.url, comment: parentRelation.attributes?.comment }; } } // Prepare response with enhanced error reporting const response: any = { success: true, workItem: { id: result.id, title: result.fields['System.Title'], type: result.fields['System.WorkItemType'], state: result.fields['System.State'], parent: result.fields['System.Parent'] || parentInfo?.id || null, parentRelation: parentInfo, iterationPath: result.fields['System.IterationPath'], assignedTo: result.fields['System.AssignedTo']?.displayName || result.fields['System.AssignedTo'], url: result._links.html.href, relations: result.relations?.length || 0 }, message: args.parent ? `Work item created with parent relationship to work item ${args.parent}` : 'Work item created successfully' }; // Add iteration path handling details to response if (args.iterationPath) { response.iterationPathHandling = { requested: args.iterationPath, normalized: finalIterationPath, setDuringCreation: iterationPathHandled, finalValue: result.fields['System.IterationPath'] }; if (iterationPathError) { response.iterationPathHandling.validationError = iterationPathError instanceof Error ? iterationPathError.message : 'Unknown validation error'; } } return { content: [{ type: 'text', text: JSON.stringify(response, null, 2), }], }; } catch (error) { throw new Error(`Failed to create work item: ${error instanceof Error ? error.message : 'Unknown error'}`); } } /** * Update an existing work item in Azure DevOps */ private async updateWorkItem(args: any): Promise<any> { if (!args.id) { throw new Error('Work item ID is required'); } if (!args.fields && !args.parent && !args.iterationPath && !args.state && !args.assignedTo && !args.title && !args.description && !args.tags) { throw new Error('At least one field to update must be provided'); } try { const operations = []; // Handle individual field updates if (args.title) { operations.push({ op: 'replace', path: '/fields/System.Title', value: args.title }); } if (args.description) { operations.push({ op: 'replace', path: '/fields/System.Description', value: args.description }); } if (args.state) { // Get current work item to determine its type for state validation let workItemType = 'Task'; // Default fallback try { const currentWorkItem = await this.makeApiRequest(`/wit/workitems/${args.id}?api-version=7.1`); workItemType = currentWorkItem.fields['System.WorkItemType'] || 'Task'; } catch (error) { console.log(`[DEBUG] Could not fetch work item type for validation, using default: ${error instanceof Error ? error.message : 'Unknown error'}`); } // Validate state for work item type to prevent invalid state errors const validatedState = await this.validateWorkItemState(workItemType, args.state); operations.push({ op: 'replace', path: '/fields/System.State', value: validatedState }); } if (args.assignedTo) { operations.push({ op: 'replace', path: '/fields/System.AssignedTo', value: args.assignedTo }); } if (args.tags) { operations.push({ op: 'replace', path: '/fields/System.Tags', value: args.tags }); } // Handle parent relationship using relations API if (args.parent) { // Validate parent ID is a number const parentId = parseInt(args.parent, 10); if (isNaN(parentId) || parentId <= 0) { throw new Error(`Invalid parent work item ID: ${args.parent}. Must be a positive integer.`); } const parentUrl = `${this.currentConfig!.organizationUrl}/${this.currentConfig!.project}/_apis/wit/workItems/${parentId}`; console.log(`[DEBUG] Setting parent relationship to work item ${parentId} using URL: ${parentUrl}`); operations.push({ op: 'add', path: '/relations/-', value: { rel: 'System.LinkTypes.Hierarchy-Reverse', url: parentUrl, attributes: { comment: `Parent relationship updated via MCP update-work-item command` } } }); } // Handle iteration path assignment with normalization (System.IterationPath) if (args.iterationPath) { const normalizedIterationPath = this.normalizeIterationPath(args.iterationPath); operations.push({ op: 'replace', path: '/fields/System.IterationPath', value: normalizedIterationPath }); console.log(`[DEBUG] Iteration path normalized from '${args.iterationPath}' to '${normalizedIterationPath}' for update`); } // Handle generic field updates with intelligent field name resolution if (args.fields && typeof args.fields === 'object') { Object.entries(args.fields).forEach(([fieldName, fieldValue]) => { // CRITICAL FIX: Implement proper field name resolution as specified in GitHub issue #53 let normalizedFieldName = fieldName; // CRITICAL: Microsoft.VSTS.* fields must NEVER be prefixed with System. // Azure DevOps field categories: // - System fields: Always prefixed with "System." (e.g., System.Title, System.State) // - Microsoft fields: Never prefixed, use full name (e.g., Microsoft.VSTS.Common.Priority) // - Custom fields: May have organization-specific prefixes // Apply System. prefix ONLY to fields that don't already have System. or Microsoft. prefixes if (!fieldName.startsWith('System.') && !fieldName.startsWith('Microsoft.')) { // Only add System. prefix for known system fields without namespaces const knownSystemFields = ['Title', 'Description', 'State', 'AssignedTo', 'Tags', 'IterationPath', 'AreaPath']; if (knownSystemFields.includes(fieldName)) { normalizedFieldName = `System.${fieldName}`; } // All other fields (including BusinessValue, Priority, Effort) remain unchanged // This preserves custom fields and Microsoft.VSTS.* fields correctly } // System.* and Microsoft.* fields are preserved exactly as-is console.log(`[DEBUG] Field resolution: "${fieldName}" → "${normalizedFieldName}"`); operations.push({ op: 'replace', path: `/fields/${normalizedFieldName}`, value: fieldValue }); }); } if (operations.length === 0) { throw new Error('No valid update operations specified'); } // Debug logging to validate the endpoint construction const endpoint = `/wit/workitems/${args.id}?api-version=7.1`; console.log(`[DEBUG] Updating work item ${args.id} with endpoint: ${endpoint}`); console.log(`[DEBUG] Operations:`, JSON.stringify(operations, null, 2)); const result = await this.makeApiRequest( endpoint, 'PATCH', operations ); // Extract parent information from relations let parentInfo = null; if (result.relations && result.relations.length > 0) { const parentRelation = result.relations.find((rel: any) => rel.rel === 'System.LinkTypes.Hierarchy-Reverse' ); if (parentRelation) { // Extract parent ID from URL (e.g., .../workItems/1562 -> 1562) const match = parentRelation.url.match(/workItems\/(\d+)$/); parentInfo = { id: match ? parseInt(match[1], 10) : null, url: parentRelation.url, comment: parentRelation.attributes?.comment }; } } return { content: [{ type: 'text', text: JSON.stringify({ success: true, workItem: { id: result.id, title: result.fields['System.Title'], type: result.fields['System.WorkItemType'], state: result.fields['System.State'], parent: result.fields['System.Parent'] || parentInfo?.id || null, parentRelation: parentInfo, iterationPath: result.fields['System.IterationPath'], assignedTo: result.fields['System.AssignedTo']?.displayName || result.fields['System.AssignedTo'], url: result._links.html.href, relations: result.relations?.length || 0 }, operations: operations.length, message: args.parent ? `Work item updated with parent relationship to work item ${args.parent}` : `Successfully updated work item ${args.id}` }, null, 2), }], }; } catch (error) { throw new Error(`Failed to update work item: ${error instanceof Error ? error.message : 'Unknown error'}`); } } /** * Add a comment to an existing work item in Azure DevOps * Fixed API version compatibility issue - using 6.0 for comments */ private async addWorkItemComment(args: any): Promise<any> { if (!args.id) { throw new Error('Work item ID is required'); } if (!args.comment) { throw new Error('Comment text is required'); } try { const commentData = { text: args.comment }; // Use API version 6.0-preview.4 for comments - required for work item comments endpoint const endpoint = `/wit/workitems/${args.id}/comments?api-version=6.0-preview.4`; console.log(`[DEBUG] Adding comment to work item ${args.id} with endpoint: ${endpoint}`); const result = await this.makeApiRequest( endpoint, 'POST', commentData ); return { content: [{ type: 'text', text: JSON.stringify({ success: true, comment: { id: result.id, workItemId: args.id, text: result.text, createdBy: result.createdBy?.displayName || result.createdBy, createdDate: result.createdDate, url: result.url }, message: `Successfully added comment to work item ${args.id}` }, null, 2), }], }; } catch (error) { // Provide specific guidance for API version issues if (error instanceof Error && error.message.includes('preview')) { throw new Error(`Failed to add work item comment - API version issue: ${error.message}. Try using a different API version.`); } throw new Error(`Failed to add work item comment: ${error instanceof Error ? error.message : 'Unknown error'}`); } } /** * Get repositories from Azure DevOps project */ private async getRepositories(args: any): Promise<any> { try { const result = await this.makeApiRequest('/git/repositories?api-version=7.1'); const repositories = result.value.map((repo: any) => ({ id: repo.id, name: repo.name, url: repo.webUrl, defaultBranch: repo.defaultBranch, size: repo.size, ...(args.includeLinks && { links: repo._links }) })); return { content: [{ type: 'text', text: JSON.stringify({ count: repositories.length, repositories }, null, 2), }], }; } catch (error) { throw new Error(`Failed to get repositories: ${error instanceof Error ? error.message : 'Unknown error'}`); } } /** * Get build definitions and recent builds */ private async getBuilds(args: any): Promise<any> { try { let endpoint = '/build/builds?api-version=7.1'; const params = []; if (args.definitionIds && args.definitionIds.length > 0) { params.push(`definitions=${args.definitionIds.join(',')}`); } if (args.top) { params.push(`$top=${args.top}`); } else { params.push('$top=10'); // Default to 10 builds } if (params.length > 0) { endpoint += '&' + params.join('&'); } const result = await this.makeApiRequest(endpoint); const builds = result.value.map((build: any) => ({ id: build.id, buildNumber: build.buildNumber, status: build.status, result: build.result, definition: { id: build.definition.id, name: build.definition.name }, startTime: build.startTime, finishTime: build.finishTime, url: build._links.web.href })); return { content: [{ type: 'text', text: JSON.stringify({ count: builds.length, builds }, null, 2), }], }; } catch (error) { throw new Error(`Failed to get builds: ${error instanceof Error ? error.message : 'Unknown error'}`); } } /** * Get pull requests from Azure DevOps repositories */ private async getPullRequests(args: any): Promise<any> { try { let endpoint = '/git/pullrequests?api-version=7.1'; const params = []; // Status filter (default to active) const status = args.status || 'active'; if (status !== 'all') { params.push(`searchCriteria.status=${status}`); } // Creator filter if (args.createdBy) { params.push(`searchCriteria.creatorId=${encodeURIComponent(args.createdBy)}`); } // Repository filter if (args.repositoryId) { params.push(`searchCriteria.repositoryId=${encodeURIComponent(args.repositoryId)}`); } // Top (limit) parameter const top = args.top || 25; params.push(`$top=${top}`); if (params.length > 0) { endpoint += '&' + params.join('&'); } const result = await this.makeApiRequest(endpoint); const pullRequests = result.value.map((pr: any) => ({ id: pr.pullRequestId, title: pr.title, description: pr.description, status: pr.status, createdBy: { displayName: pr.createdBy.displayName, uniqueName: pr.createdBy.uniqueName }, creationDate: pr.creationDate, repository: { id: pr.repository.id, name: pr.repository.name }, sourceRefName: pr.sourceRefName, targetRefName: pr.targetRefName, url: pr._links?.web?.href || `${this.currentConfig!.organizationUrl}/${this.currentConfig!.project}/_git/${pr.repository.name}/pullrequest/${pr.pullRequestId}`, isDraft: pr.isDraft || false, mergeStatus: pr.mergeStatus })); return { content: [{ type: 'text', text: JSON.stringify({ count: pullRequests.length, status: status, pullRequests }, null, 2), }], }; } catch (error) { throw new Error(`Failed to get pull requests: ${error instanceof Error ? error.message : 'Unknown error'}`); } } /** * Trigger a build pipeline in Azure DevOps */ private async triggerPipeline(args: any): Promise<any> { try { let definitionId = args.definitionId; // If definition name is provided instead of ID, look up the ID if (!definitionId && args.definitionName) { const definitions = await this.makeApiRequest('/build/definitions?api-version=7.1'); const definition = definitions.value.find((def: any) => def.name.toLowerCase() === args.definitionName.toLowerCase() ); if (!definition) { throw new Error(`Build definition '${args.definitionName}' not found`); } definitionId = definition.id; } if (!definitionId) { throw new Error('Either definitionId or definitionName must be provided'); } // Prepare the build request const buildRequest: any = { definition: { id: definitionId } }; // Add source branch if specified if (args.sourceBranch) { buildRequest.sourceBranch = args.sourceBranch.startsWith('refs/') ? args.sourceBranch : `refs/heads/${args.sourceBranch}`; } // Add parameters if specified if (args.parameters && typeof args.parameters === 'object') { buildRequest.parameters = JSON.stringify(args.parameters); } const result = await this.makeApiRequest( '/build/builds?api-version=7.1', 'POST', buildRequest ); return { content: [{ type: 'text', text: JSON.stringify({ success: true, build: { id: result.id, buildNumber: result.buildNumber, status: result.status, queueTime: result.queueTime, definition: { id: result.definition.id, name: result.definition.name }, sourceBranch: result.sourceBranch, url: result._links?.web?.href || `${this.currentConfig!.organizationUrl}/${this.currentConfig!.project}/_build/results?buildId=${result.id}`, requestedBy: { displayName: result.requestedBy?.displayName || 'API Request', uniqueName: result.requestedBy?.uniqueName || 'api' } } }, null, 2), }], }; } catch (error) { throw new Error(`Failed to trigger pipeline: ${error instanceof Error ? error.message : 'Unknown error'}`); } } /** * Get pipeline status and detailed information */ private async getPipelineStatus(args: any): Promise<any> { try { if (args.buildId) { // Get specific build details const build = await this.makeApiRequest(`/build/builds/${args.buildId}?api-version=7.1`); let timeline = null; if (args.includeTimeline) { try { timeline = await this.makeApiRequest(`/build/builds/${args.buildId}/timeline?api-version=7.1`); } catch (timelineError) { // Sanitize error message to prevent log injection const sanitizedError = timelineError instanceof Error ? timelineError.message.replace(/[\r\n\t]/g, '_') : 'Unknown timeline error'; console.error('Failed to get timeline:', sanitizedError); // Continue without timeline if it fails } } const buildInfo = { id: build.id, buildNumber: build.buildNumber, status: build.status, result: build.result, definition: { id: build.definition.id, name: build.definition.name }, sourceBranch: build.sourceBranch, sourceVersion: build.sourceVersion, queueTime: build.queueTime, startTime: build.startTime, finishTime: build.finishTime, url: build._links?.web?.href, requestedBy: { displayName: build.requestedBy?.displayName, uniqueName: build.requestedBy?.uniqueName }, ...(timeline && { timeline: timeline.records?.map((record: any) => ({ name: record.name, type: record.type, state: record.state, result: record.result, startTime: record.startTime, finishTime: record.finishTime, percentComplete: record.percentComplete })) }) }; return { content: [{ type: 'text', text: JSON.stringify(buildInfo, null, 2), }], }; } else if (args.definitionId) { // Get latest builds for a specific definition const builds = await this.makeApiRequest( `/build/builds?definitions=${args.definitionId}&$top=5&api-version=7.1` ); const buildsInfo = builds.value.map((build: any) => ({ id: build.id, buildNumber: build.buildNumber, status: build.status, result: build.result, sourceBranch: build.sourceBranch, queueTime: build.queueTime, startTime: build.startTime, finishTime: build.finishTime, url: build._links?.web?.href })); return { content: [{ type: 'text', text: JSON.stringify({ definitionId: args.definitionId, recentBuilds: buildsInfo }, null, 2), }], }; } else { throw new Error('Either buildId or definitionId must be provided'); } } catch (error) { throw new Error(`Failed to get pipeline status: ${error instanceof Error ? error.message : 'Unknown error'}`); } } }

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/wangkanai/devops-enhanced-mcp'

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