update-work-item
Modify existing Azure DevOps work items by updating fields like title, description, state, assignee, or parent relationships to track project progress.
Instructions
Update an existing work item in Azure DevOps
Input Schema
TableJSON Schema
| Name | Required | Description | Default |
|---|---|---|---|
| id | Yes | Work item ID to update | |
| title | No | Updated work item title | |
| description | No | Updated work item description | |
| state | No | Updated work item state (e.g., Active, Resolved, Closed) | |
| assignedTo | No | Email of the person to assign the work item to | |
| parent | No | Parent work item ID for establishing hierarchy | |
| iterationPath | No | Iteration path for sprint assignment (e.g., ProjectName\Sprint 1) | |
| tags | No | Semicolon-separated tags | |
| fields | No | Generic field updates as key-value pairs |
Implementation Reference
- src/handlers/tool-handlers.ts:666-856 (handler)The primary handler function `updateWorkItem` that executes the tool logic: validates inputs, normalizes iteration paths, handles parent relationships, field updates (title, description, state, assignedTo, tags, generic fields), and sends PATCH request to Azure DevOps WIT API.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'}`); } }
- src/index.ts:165-210 (registration)Registers the 'update-work-item' tool in the MCP ListTools handler with name, description, and detailed inputSchema defining parameters like id (required), title, description, state, assignedTo, parent, iterationPath, tags, fields.{ name: 'update-work-item', description: 'Update an existing work item in Azure DevOps', inputSchema: { type: 'object', properties: { id: { type: 'number', description: 'Work item ID to update', }, title: { type: 'string', description: 'Updated work item title', }, description: { type: 'string', description: 'Updated work item description', }, state: { type: 'string', description: 'Updated work item state (e.g., Active, Resolved, Closed)', }, assignedTo: { type: 'string', description: 'Email of the person to assign the work item to', }, parent: { type: 'number', description: 'Parent work item ID for establishing hierarchy', }, iterationPath: { type: 'string', description: 'Iteration path for sprint assignment (e.g., ProjectName\\Sprint 1)', }, tags: { type: 'string', description: 'Semicolon-separated tags', }, fields: { type: 'object', description: 'Generic field updates as key-value pairs', }, }, required: ['id'], }, },
- `normalizeIterationPath` helper utility used by updateWorkItem to format iteration paths correctly for Azure DevOps API (handles project prefix, Iteration component, slash/backslash normalization).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; }
- `validateWorkItemState` helper used in updateWorkItem to validate and map work item states against the type's supported states, preventing invalid state errors.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; } }
- src/handlers/tool-handlers.ts:71-131 (helper)`makeApiRequest` core helper utility used by updateWorkItem to make authenticated HTTPS requests to Azure DevOps APIs with proper headers for PATCH operations.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(); }); }