From 4876da91bf99bd02d9089f92b34f6a3b23a5b8f7 Mon Sep 17 00:00:00 2001 From: Dan Hellem Date: Thu, 13 Nov 2025 16:46:20 +0000 Subject: [PATCH 1/2] feat: improve error handling in work item tools --- src/tools/work-items.ts | 657 ++++++++++++++++++------------ test/src/tools/work-items.test.ts | 20 +- 2 files changed, 405 insertions(+), 272 deletions(-) diff --git a/src/tools/work-items.ts b/src/tools/work-items.ts index 7b56286e..e69034f6 100644 --- a/src/tools/work-items.ts +++ b/src/tools/work-items.ts @@ -71,14 +71,22 @@ function configureWorkItemTools(server: McpServer, tokenProvider: () => Promise< team: z.string().describe("The name or ID of the Azure DevOps team."), }, async ({ project, team }) => { - const connection = await connectionProvider(); - const workApi = await connection.getWorkApi(); - const teamContext = { project, team }; - const backlogs = await workApi.getBacklogs(teamContext); - - return { - content: [{ type: "text", text: JSON.stringify(backlogs, null, 2) }], - }; + try { + const connection = await connectionProvider(); + const workApi = await connection.getWorkApi(); + const teamContext = { project, team }; + const backlogs = await workApi.getBacklogs(teamContext); + + return { + content: [{ type: "text", text: JSON.stringify(backlogs, null, 2) }], + }; + } catch (error) { + const errorMessage = error instanceof Error ? error.message : "Unknown error occurred"; + return { + content: [{ type: "text", text: `Error listing backlogs: ${errorMessage}` }], + isError: true, + }; + } } ); @@ -91,15 +99,23 @@ function configureWorkItemTools(server: McpServer, tokenProvider: () => Promise< backlogId: z.string().describe("The ID of the backlog category to retrieve work items from."), }, async ({ project, team, backlogId }) => { - const connection = await connectionProvider(); - const workApi = await connection.getWorkApi(); - const teamContext = { project, team }; + try { + const connection = await connectionProvider(); + const workApi = await connection.getWorkApi(); + const teamContext = { project, team }; - const workItems = await workApi.getBacklogLevelWorkItems(teamContext, backlogId); + const workItems = await workApi.getBacklogLevelWorkItems(teamContext, backlogId); - return { - content: [{ type: "text", text: JSON.stringify(workItems, null, 2) }], - }; + return { + content: [{ type: "text", text: JSON.stringify(workItems, null, 2) }], + }; + } catch (error) { + const errorMessage = error instanceof Error ? error.message : "Unknown error occurred"; + return { + content: [{ type: "text", text: `Error listing backlog work items: ${errorMessage}` }], + isError: true, + }; + } } ); @@ -113,14 +129,22 @@ function configureWorkItemTools(server: McpServer, tokenProvider: () => Promise< includeCompleted: z.boolean().default(false).describe("Whether to include completed work items. Defaults to false."), }, async ({ project, type, top, includeCompleted }) => { - const connection = await connectionProvider(); - const workApi = await connection.getWorkApi(); + try { + const connection = await connectionProvider(); + const workApi = await connection.getWorkApi(); - const workItems = await workApi.getPredefinedQueryResults(project, type, top, includeCompleted); + const workItems = await workApi.getPredefinedQueryResults(project, type, top, includeCompleted); - return { - content: [{ type: "text", text: JSON.stringify(workItems, null, 2) }], - }; + return { + content: [{ type: "text", text: JSON.stringify(workItems, null, 2) }], + }; + } catch (error) { + const errorMessage = error instanceof Error ? error.message : "Unknown error occurred"; + return { + content: [{ type: "text", text: `Error retrieving work items: ${errorMessage}` }], + isError: true, + }; + } } ); @@ -133,46 +157,54 @@ function configureWorkItemTools(server: McpServer, tokenProvider: () => Promise< fields: z.array(z.string()).optional().describe("Optional list of fields to include in the response. If not provided, a hardcoded default set of fields will be used."), }, async ({ project, ids, fields }) => { - const connection = await connectionProvider(); - const workItemApi = await connection.getWorkItemTrackingApi(); - const defaultFields = ["System.Id", "System.WorkItemType", "System.Title", "System.State", "System.Parent", "System.Tags", "Microsoft.VSTS.Common.StackRank", "System.AssignedTo"]; - - // If no fields are provided, use the default set of fields - const fieldsToUse = !fields || fields.length === 0 ? defaultFields : fields; - - const workitems = await workItemApi.getWorkItemsBatch({ ids, fields: fieldsToUse }, project); - - // List of identity fields that need to be transformed from objects to formatted strings - const identityFields = [ - "System.AssignedTo", - "System.CreatedBy", - "System.ChangedBy", - "System.AuthorizedAs", - "Microsoft.VSTS.Common.ActivatedBy", - "Microsoft.VSTS.Common.ResolvedBy", - "Microsoft.VSTS.Common.ClosedBy", - ]; - - // Format identity fields to include displayName and uniqueName - // Removing the identity object as the response. It's too much and not needed - if (workitems && Array.isArray(workitems)) { - workitems.forEach((item) => { - if (item.fields) { - identityFields.forEach((fieldName) => { - if (item.fields && item.fields[fieldName] && typeof item.fields[fieldName] === "object") { - const identityField = item.fields[fieldName]; - const name = identityField.displayName || ""; - const email = identityField.uniqueName || ""; - item.fields[fieldName] = `${name} <${email}>`.trim(); - } - }); - } - }); - } + try { + const connection = await connectionProvider(); + const workItemApi = await connection.getWorkItemTrackingApi(); + const defaultFields = ["System.Id", "System.WorkItemType", "System.Title", "System.State", "System.Parent", "System.Tags", "Microsoft.VSTS.Common.StackRank", "System.AssignedTo"]; + + // If no fields are provided, use the default set of fields + const fieldsToUse = !fields || fields.length === 0 ? defaultFields : fields; + + const workitems = await workItemApi.getWorkItemsBatch({ ids, fields: fieldsToUse }, project); + + // List of identity fields that need to be transformed from objects to formatted strings + const identityFields = [ + "System.AssignedTo", + "System.CreatedBy", + "System.ChangedBy", + "System.AuthorizedAs", + "Microsoft.VSTS.Common.ActivatedBy", + "Microsoft.VSTS.Common.ResolvedBy", + "Microsoft.VSTS.Common.ClosedBy", + ]; + + // Format identity fields to include displayName and uniqueName + // Removing the identity object as the response. It's too much and not needed + if (workitems && Array.isArray(workitems)) { + workitems.forEach((item) => { + if (item.fields) { + identityFields.forEach((fieldName) => { + if (item.fields && item.fields[fieldName] && typeof item.fields[fieldName] === "object") { + const identityField = item.fields[fieldName]; + const name = identityField.displayName || ""; + const email = identityField.uniqueName || ""; + item.fields[fieldName] = `${name} <${email}>`.trim(); + } + }); + } + }); + } - return { - content: [{ type: "text", text: JSON.stringify(workitems, null, 2) }], - }; + return { + content: [{ type: "text", text: JSON.stringify(workitems, null, 2) }], + }; + } catch (error) { + const errorMessage = error instanceof Error ? error.message : "Unknown error occurred"; + return { + content: [{ type: "text", text: `Error retrieving work items batch: ${errorMessage}` }], + isError: true, + }; + } } ); @@ -191,12 +223,22 @@ function configureWorkItemTools(server: McpServer, tokenProvider: () => Promise< .describe("Expand options include 'all', 'fields', 'links', 'none', and 'relations'. Relations can be used to get child workitems. Defaults to 'none'."), }, async ({ id, project, fields, asOf, expand }) => { - const connection = await connectionProvider(); - const workItemApi = await connection.getWorkItemTrackingApi(); - const workItem = await workItemApi.getWorkItem(id, fields, asOf, expand as unknown as WorkItemExpand, project); - return { - content: [{ type: "text", text: JSON.stringify(workItem, null, 2) }], - }; + try { + const connection = await connectionProvider(); + const workItemApi = await connection.getWorkItemTrackingApi(); + const workItem = await workItemApi.getWorkItem(id, fields, asOf, expand as unknown as WorkItemExpand, project); + + return { + content: [{ type: "text", text: JSON.stringify(workItem, null, 2) }], + }; + } catch (error) { + const errorMessage = error instanceof Error ? error.message : "Unknown error occurred"; + + return { + content: [{ type: "text", text: `Error retrieving work item: ${errorMessage}` }], + isError: true, + }; + } } ); @@ -209,13 +251,21 @@ function configureWorkItemTools(server: McpServer, tokenProvider: () => Promise< top: z.number().default(50).describe("Optional number of comments to retrieve. Defaults to all comments."), }, async ({ project, workItemId, top }) => { - const connection = await connectionProvider(); - const workItemApi = await connection.getWorkItemTrackingApi(); - const comments = await workItemApi.getComments(project, workItemId, top); + try { + const connection = await connectionProvider(); + const workItemApi = await connection.getWorkItemTrackingApi(); + const comments = await workItemApi.getComments(project, workItemId, top); - return { - content: [{ type: "text", text: JSON.stringify(comments, null, 2) }], - }; + return { + content: [{ type: "text", text: JSON.stringify(comments, null, 2) }], + }; + } catch (error) { + const errorMessage = error instanceof Error ? error.message : "Unknown error occurred"; + return { + content: [{ type: "text", text: `Error listing work item comments: ${errorMessage}` }], + isError: true, + }; + } } ); @@ -229,35 +279,42 @@ function configureWorkItemTools(server: McpServer, tokenProvider: () => Promise< format: z.enum(["markdown", "html"]).optional().default("html"), }, async ({ project, workItemId, comment, format }) => { - const connection = await connectionProvider(); - - const orgUrl = connection.serverUrl; - const accessToken = await tokenProvider(); - - const body = { - text: comment, - }; - - const formatParameter = format === "markdown" ? 0 : 1; - const response = await fetch(`${orgUrl}/${project}/_apis/wit/workItems/${workItemId}/comments?format=${formatParameter}&api-version=${markdownCommentsApiVersion}`, { - method: "POST", - headers: { - "Authorization": `Bearer ${accessToken}`, - "Content-Type": "application/json", - "User-Agent": userAgentProvider(), - }, - body: JSON.stringify(body), - }); - - if (!response.ok) { - throw new Error(`Failed to add a work item comment: ${response.statusText}}`); - } + try { + const connection = await connectionProvider(); + const orgUrl = connection.serverUrl; + const accessToken = await tokenProvider(); - const comments = await response.text(); + const body = { + text: comment, + }; - return { - content: [{ type: "text", text: comments }], - }; + const formatParameter = format === "markdown" ? 0 : 1; + const response = await fetch(`${orgUrl}/${project}/_apis/wit/workItems/${workItemId}/comments?format=${formatParameter}&api-version=${markdownCommentsApiVersion}`, { + method: "POST", + headers: { + "Authorization": `Bearer ${accessToken}`, + "Content-Type": "application/json", + "User-Agent": userAgentProvider(), + }, + body: JSON.stringify(body), + }); + + if (!response.ok) { + throw new Error(`Failed to add a work item comment: ${response.statusText}}`); + } + + const comments = await response.text(); + + return { + content: [{ type: "text", text: comments }], + }; + } catch (error) { + const errorMessage = error instanceof Error ? error.message : "Unknown error occurred"; + return { + content: [{ type: "text", text: `Error adding work item comment: ${errorMessage}` }], + isError: true, + }; + } } ); @@ -276,41 +333,49 @@ function configureWorkItemTools(server: McpServer, tokenProvider: () => Promise< .describe("Optional expand parameter to include additional details. Defaults to 'None'."), }, async ({ project, workItemId, top, skip, expand }) => { - const connection = await connectionProvider(); - const workItemApi = await connection.getWorkItemTrackingApi(); - const revisions = await workItemApi.getRevisions(workItemId, top, skip, safeEnumConvert(WorkItemExpand, expand), project); - - // Dynamically clean up identity objects in revision fields - // Identity objects typically have properties like displayName, url, _links, id, uniqueName, imageUrl, descriptor - if (revisions && Array.isArray(revisions)) { - revisions.forEach((revision) => { - if (revision.fields) { - Object.keys(revision.fields).forEach((fieldName) => { - const fieldValue = revision.fields ? revision.fields[fieldName] : undefined; - // Check if this is an identity object by looking for common identity properties - if ( - fieldValue && - typeof fieldValue === "object" && - !Array.isArray(fieldValue) && - "displayName" in fieldValue && - ("url" in fieldValue || "_links" in fieldValue || "uniqueName" in fieldValue) - ) { - // Remove unwanted properties from identity objects - delete fieldValue.url; - delete fieldValue._links; - delete fieldValue.id; - delete fieldValue.uniqueName; - delete fieldValue.imageUrl; - delete fieldValue.descriptor; - } - }); - } - }); - } + try { + const connection = await connectionProvider(); + const workItemApi = await connection.getWorkItemTrackingApi(); + const revisions = await workItemApi.getRevisions(workItemId, top, skip, safeEnumConvert(WorkItemExpand, expand), project); + + // Dynamically clean up identity objects in revision fields + // Identity objects typically have properties like displayName, url, _links, id, uniqueName, imageUrl, descriptor + if (revisions && Array.isArray(revisions)) { + revisions.forEach((revision) => { + if (revision.fields) { + Object.keys(revision.fields).forEach((fieldName) => { + const fieldValue = revision.fields ? revision.fields[fieldName] : undefined; + // Check if this is an identity object by looking for common identity properties + if ( + fieldValue && + typeof fieldValue === "object" && + !Array.isArray(fieldValue) && + "displayName" in fieldValue && + ("url" in fieldValue || "_links" in fieldValue || "uniqueName" in fieldValue) + ) { + // Remove unwanted properties from identity objects + delete fieldValue.url; + delete fieldValue._links; + delete fieldValue.id; + delete fieldValue.uniqueName; + delete fieldValue.imageUrl; + delete fieldValue.descriptor; + } + }); + } + }); + } - return { - content: [{ type: "text", text: JSON.stringify(revisions, null, 2) }], - }; + return { + content: [{ type: "text", text: JSON.stringify(revisions, null, 2) }], + }; + } catch (error) { + const errorMessage = error instanceof Error ? error.message : "Unknown error occurred"; + return { + content: [{ type: "text", text: `Error listing work item revisions: ${errorMessage}` }], + isError: true, + }; + } } ); @@ -527,15 +592,23 @@ function configureWorkItemTools(server: McpServer, tokenProvider: () => Promise< iterationId: z.string().describe("The ID of the iteration to retrieve work items for."), }, async ({ project, team, iterationId }) => { - const connection = await connectionProvider(); - const workApi = await connection.getWorkApi(); + try { + const connection = await connectionProvider(); + const workApi = await connection.getWorkApi(); - //get the work items for the current iteration - const workItems = await workApi.getIterationWorkItems({ project, team }, iterationId); + //get the work items for the current iteration + const workItems = await workApi.getIterationWorkItems({ project, team }, iterationId); - return { - content: [{ type: "text", text: JSON.stringify(workItems, null, 2) }], - }; + return { + content: [{ type: "text", text: JSON.stringify(workItems, null, 2) }], + }; + } catch (error) { + const errorMessage = error instanceof Error ? error.message : "Unknown error occurred"; + return { + content: [{ type: "text", text: `Error retrieving work items for iteration: ${errorMessage}` }], + isError: true, + }; + } } ); @@ -560,20 +633,28 @@ function configureWorkItemTools(server: McpServer, tokenProvider: () => Promise< .describe("An array of field updates to apply to the work item."), }, async ({ id, updates }) => { - const connection = await connectionProvider(); - const workItemApi = await connection.getWorkItemTrackingApi(); + try { + const connection = await connectionProvider(); + const workItemApi = await connection.getWorkItemTrackingApi(); - // Convert operation names to lowercase for API - const apiUpdates = updates.map((update) => ({ - ...update, - op: update.op, - })); + // Convert operation names to lowercase for API + const apiUpdates = updates.map((update) => ({ + ...update, + op: update.op, + })); - const updatedWorkItem = await workItemApi.updateWorkItem(null, apiUpdates, id); + const updatedWorkItem = await workItemApi.updateWorkItem(null, apiUpdates, id); - return { - content: [{ type: "text", text: JSON.stringify(updatedWorkItem, null, 2) }], - }; + return { + content: [{ type: "text", text: JSON.stringify(updatedWorkItem, null, 2) }], + }; + } catch (error) { + const errorMessage = error instanceof Error ? error.message : "Unknown error occurred"; + return { + content: [{ type: "text", text: `Error updating work item: ${errorMessage}` }], + isError: true, + }; + } } ); @@ -585,14 +666,22 @@ function configureWorkItemTools(server: McpServer, tokenProvider: () => Promise< workItemType: z.string().describe("The name of the work item type to retrieve."), }, async ({ project, workItemType }) => { - const connection = await connectionProvider(); - const workItemApi = await connection.getWorkItemTrackingApi(); + try { + const connection = await connectionProvider(); + const workItemApi = await connection.getWorkItemTrackingApi(); - const workItemTypeInfo = await workItemApi.getWorkItemType(project, workItemType); + const workItemTypeInfo = await workItemApi.getWorkItemType(project, workItemType); - return { - content: [{ type: "text", text: JSON.stringify(workItemTypeInfo, null, 2) }], - }; + return { + content: [{ type: "text", text: JSON.stringify(workItemTypeInfo, null, 2) }], + }; + } catch (error) { + const errorMessage = error instanceof Error ? error.message : "Unknown error occurred"; + return { + content: [{ type: "text", text: `Error retrieving work item type: ${errorMessage}` }], + isError: true, + }; + } } ); @@ -671,14 +760,22 @@ function configureWorkItemTools(server: McpServer, tokenProvider: () => Promise< useIsoDateFormat: z.boolean().default(false).describe("Whether to use ISO date format in the response. Defaults to false."), }, async ({ project, query, expand, depth, includeDeleted, useIsoDateFormat }) => { - const connection = await connectionProvider(); - const workItemApi = await connection.getWorkItemTrackingApi(); + try { + const connection = await connectionProvider(); + const workItemApi = await connection.getWorkItemTrackingApi(); - const queryDetails = await workItemApi.getQuery(project, query, safeEnumConvert(QueryExpand, expand), depth, includeDeleted, useIsoDateFormat); + const queryDetails = await workItemApi.getQuery(project, query, safeEnumConvert(QueryExpand, expand), depth, includeDeleted, useIsoDateFormat); - return { - content: [{ type: "text", text: JSON.stringify(queryDetails, null, 2) }], - }; + return { + content: [{ type: "text", text: JSON.stringify(queryDetails, null, 2) }], + }; + } catch (error) { + const errorMessage = error instanceof Error ? error.message : "Unknown error occurred"; + return { + content: [{ type: "text", text: `Error retrieving query: ${errorMessage}` }], + isError: true, + }; + } } ); @@ -694,23 +791,31 @@ function configureWorkItemTools(server: McpServer, tokenProvider: () => Promise< responseType: z.enum(["full", "ids"]).default("full").describe("Response type: 'full' returns complete query results (default), 'ids' returns only work item IDs for reduced payload size."), }, async ({ id, project, team, timePrecision, top, responseType }) => { - const connection = await connectionProvider(); - const workItemApi = await connection.getWorkItemTrackingApi(); - const teamContext = { project, team }; - const queryResult = await workItemApi.queryById(id, teamContext, timePrecision, top); - - // If ids mode, extract and return only the IDs - if (responseType === "ids") { - const ids = queryResult.workItems?.map((workItem) => workItem.id).filter((id): id is number => id !== undefined) || []; + try { + const connection = await connectionProvider(); + const workItemApi = await connection.getWorkItemTrackingApi(); + const teamContext = { project, team }; + const queryResult = await workItemApi.queryById(id, teamContext, timePrecision, top); + + // If ids mode, extract and return only the IDs + if (responseType === "ids") { + const ids = queryResult.workItems?.map((workItem) => workItem.id).filter((id): id is number => id !== undefined) || []; + return { + content: [{ type: "text", text: JSON.stringify({ ids, count: ids.length }, null, 2) }], + }; + } + + // Default: return full query results return { - content: [{ type: "text", text: JSON.stringify({ ids, count: ids.length }, null, 2) }], + content: [{ type: "text", text: JSON.stringify(queryResult, null, 2) }], + }; + } catch (error) { + const errorMessage = error instanceof Error ? error.message : "Unknown error occurred"; + return { + content: [{ type: "text", text: `Error retrieving query results: ${errorMessage}` }], + isError: true, }; } - - // Default: return full query results - return { - content: [{ type: "text", text: JSON.stringify(queryResult, null, 2) }], - }; } ); @@ -731,61 +836,69 @@ function configureWorkItemTools(server: McpServer, tokenProvider: () => Promise< .describe("An array of updates to apply to work items. Each update should include the operation (op), work item ID (id), field path (path), and new value (value)."), }, async ({ updates }) => { - const connection = await connectionProvider(); - const orgUrl = connection.serverUrl; - const accessToken = await tokenProvider(); - - // Extract unique IDs from the updates array - const uniqueIds = Array.from(new Set(updates.map((update) => update.id))); - - const body = uniqueIds.map((id) => { - const workItemUpdates = updates.filter((update) => update.id === id); - const operations = workItemUpdates.map(({ op, path, value, format }) => ({ - op: op, - path: path, - value: encodeFormattedValue(value, format), - })); + try { + const connection = await connectionProvider(); + const orgUrl = connection.serverUrl; + const accessToken = await tokenProvider(); - // Add format operations for Markdown fields - workItemUpdates.forEach(({ path, value, format }) => { - if (format === "Markdown" && value && value.length > 50) { - operations.push({ - op: "Add", - path: `/multilineFieldsFormat${path.replace("/fields", "")}`, - value: "Markdown", - }); - } + // Extract unique IDs from the updates array + const uniqueIds = Array.from(new Set(updates.map((update) => update.id))); + + const body = uniqueIds.map((id) => { + const workItemUpdates = updates.filter((update) => update.id === id); + const operations = workItemUpdates.map(({ op, path, value, format }) => ({ + op: op, + path: path, + value: encodeFormattedValue(value, format), + })); + + // Add format operations for Markdown fields + workItemUpdates.forEach(({ path, value, format }) => { + if (format === "Markdown" && value && value.length > 50) { + operations.push({ + op: "Add", + path: `/multilineFieldsFormat${path.replace("/fields", "")}`, + value: "Markdown", + }); + } + }); + + return { + method: "PATCH", + uri: `/_apis/wit/workitems/${id}?api-version=${batchApiVersion}`, + headers: { + "Content-Type": "application/json-patch+json", + }, + body: operations, + }; }); - return { + const response = await fetch(`${orgUrl}/_apis/wit/$batch?api-version=${batchApiVersion}`, { method: "PATCH", - uri: `/_apis/wit/workitems/${id}?api-version=${batchApiVersion}`, headers: { - "Content-Type": "application/json-patch+json", + "Authorization": `Bearer ${accessToken}`, + "Content-Type": "application/json", + "User-Agent": userAgentProvider(), }, - body: operations, - }; - }); - - const response = await fetch(`${orgUrl}/_apis/wit/$batch?api-version=${batchApiVersion}`, { - method: "PATCH", - headers: { - "Authorization": `Bearer ${accessToken}`, - "Content-Type": "application/json", - "User-Agent": userAgentProvider(), - }, - body: JSON.stringify(body), - }); - - if (!response.ok) { - throw new Error(`Failed to update work items in batch: ${response.statusText}`); - } + body: JSON.stringify(body), + }); - const result = await response.json(); + if (!response.ok) { + throw new Error(`Failed to update work items in batch: ${response.statusText}`); + } - return { - content: [{ type: "text", text: JSON.stringify(result, null, 2) }], - }; + const result = await response.json(); + + return { + content: [{ type: "text", text: JSON.stringify(result, null, 2) }], + }; + } catch (error) { + const errorMessage = error instanceof Error ? error.message : "Unknown error occurred"; + return { + content: [{ type: "text", text: `Error updating work items in batch: ${errorMessage}` }], + isError: true, + }; + } } ); @@ -811,53 +924,61 @@ function configureWorkItemTools(server: McpServer, tokenProvider: () => Promise< .describe(""), }, async ({ project, updates }) => { - const connection = await connectionProvider(); - const orgUrl = connection.serverUrl; - const accessToken = await tokenProvider(); - - // Extract unique IDs from the updates array - const uniqueIds = Array.from(new Set(updates.map((update) => update.id))); - - const body = uniqueIds.map((id) => ({ - method: "PATCH", - uri: `/_apis/wit/workitems/${id}?api-version=${batchApiVersion}`, - headers: { - "Content-Type": "application/json-patch+json", - }, - body: updates - .filter((update) => update.id === id) - .map(({ linkToId, type, comment }) => ({ - op: "add", - path: "/relations/-", - value: { - rel: `${getLinkTypeFromName(type)}`, - url: `${orgUrl}/${project}/_apis/wit/workItems/${linkToId}`, - attributes: { - comment: comment || "", + try { + const connection = await connectionProvider(); + const orgUrl = connection.serverUrl; + const accessToken = await tokenProvider(); + + // Extract unique IDs from the updates array + const uniqueIds = Array.from(new Set(updates.map((update) => update.id))); + + const body = uniqueIds.map((id) => ({ + method: "PATCH", + uri: `/_apis/wit/workitems/${id}?api-version=${batchApiVersion}`, + headers: { + "Content-Type": "application/json-patch+json", + }, + body: updates + .filter((update) => update.id === id) + .map(({ linkToId, type, comment }) => ({ + op: "add", + path: "/relations/-", + value: { + rel: `${getLinkTypeFromName(type)}`, + url: `${orgUrl}/${project}/_apis/wit/workItems/${linkToId}`, + attributes: { + comment: comment || "", + }, }, - }, - })), - })); - - const response = await fetch(`${orgUrl}/_apis/wit/$batch?api-version=${batchApiVersion}`, { - method: "PATCH", - headers: { - "Authorization": `Bearer ${accessToken}`, - "Content-Type": "application/json", - "User-Agent": userAgentProvider(), - }, - body: JSON.stringify(body), - }); - - if (!response.ok) { - throw new Error(`Failed to update work items in batch: ${response.statusText}`); - } + })), + })); + + const response = await fetch(`${orgUrl}/_apis/wit/$batch?api-version=${batchApiVersion}`, { + method: "PATCH", + headers: { + "Authorization": `Bearer ${accessToken}`, + "Content-Type": "application/json", + "User-Agent": userAgentProvider(), + }, + body: JSON.stringify(body), + }); + + if (!response.ok) { + throw new Error(`Failed to update work items in batch: ${response.statusText}`); + } - const result = await response.json(); + const result = await response.json(); - return { - content: [{ type: "text", text: JSON.stringify(result, null, 2) }], - }; + return { + content: [{ type: "text", text: JSON.stringify(result, null, 2) }], + }; + } catch (error) { + const errorMessage = error instanceof Error ? error.message : "Unknown error occurred"; + return { + content: [{ type: "text", text: `Error linking work items: ${errorMessage}` }], + isError: true, + }; + } } ); diff --git a/test/src/tools/work-items.test.ts b/test/src/tools/work-items.test.ts index c65fc466..857b0d27 100644 --- a/test/src/tools/work-items.test.ts +++ b/test/src/tools/work-items.test.ts @@ -813,7 +813,10 @@ describe("configureWorkItemTools", () => { workItemId: 299, }; - await expect(handler(params)).rejects.toThrow("Failed to add a work item comment: Not Found"); + const result = await handler(params); + expect(result.isError).toBe(true); + expect(result.content[0].text).toContain("Error adding work item comment"); + expect(result.content[0].text).toContain("Failed to add a work item comment: Not Found"); }); }); @@ -1512,7 +1515,10 @@ describe("configureWorkItemTools", () => { ], }; - await expect(handler(params)).rejects.toThrow("Unknown link type: unknown_type"); + const result = await handler(params); + expect(result.isError).toBe(true); + expect(result.content[0].text).toContain("Error linking work items"); + expect(result.content[0].text).toContain("Unknown link type: unknown_type"); }); }); @@ -1678,7 +1684,10 @@ describe("configureWorkItemTools", () => { ], }; - await expect(handler(params)).rejects.toThrow("Failed to update work items in batch: Bad Request"); + const result = await handler(params); + expect(result.isError).toBe(true); + expect(result.content[0].text).toContain("Error updating work items in batch"); + expect(result.content[0].text).toContain("Failed to update work items in batch: Bad Request"); }); }); @@ -1753,7 +1762,10 @@ describe("configureWorkItemTools", () => { ], }; - await expect(handler(params)).rejects.toThrow("Failed to update work items in batch: Unauthorized"); + const result = await handler(params); + expect(result.isError).toBe(true); + expect(result.content[0].text).toContain("Error linking work items"); + expect(result.content[0].text).toContain("Failed to update work items in batch: Unauthorized"); }); }); From d3cb99218dca9f9bc23bfc4e3ba979feba186ba5 Mon Sep 17 00:00:00 2001 From: Dan Hellem Date: Thu, 13 Nov 2025 17:03:34 +0000 Subject: [PATCH 2/2] feat: enhance error handling for work item tools --- test/src/tools/work-items.test.ts | 390 ++++++++++++++++++++++++++++++ 1 file changed, 390 insertions(+) diff --git a/test/src/tools/work-items.test.ts b/test/src/tools/work-items.test.ts index 857b0d27..354fc048 100644 --- a/test/src/tools/work-items.test.ts +++ b/test/src/tools/work-items.test.ts @@ -2552,6 +2552,396 @@ describe("configureWorkItemTools", () => { }); }); + describe("additional error handling for all tools", () => { + it("should handle list_backlogs errors", async () => { + configureWorkItemTools(server, tokenProvider, connectionProvider, userAgentProvider); + + const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "wit_list_backlogs"); + if (!call) throw new Error("wit_list_backlogs tool not registered"); + const [, , , handler] = call; + + (mockWorkApi.getBacklogs as jest.Mock).mockRejectedValue(new Error("API Error")); + + const params = { + project: "Contoso", + team: "Fabrikam", + }; + + const result = await handler(params); + + expect(result.content[0].text).toBe("Error listing backlogs: API Error"); + expect(result.isError).toBe(true); + }); + + it("should handle list_backlog_work_items errors", async () => { + configureWorkItemTools(server, tokenProvider, connectionProvider, userAgentProvider); + + const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "wit_list_backlog_work_items"); + if (!call) throw new Error("wit_list_backlog_work_items tool not registered"); + const [, , , handler] = call; + + (mockWorkApi.getBacklogLevelWorkItems as jest.Mock).mockRejectedValue(new Error("API Error")); + + const params = { + project: "Contoso", + team: "Fabrikam", + backlogId: "Microsoft.FeatureCategory", + }; + + const result = await handler(params); + + expect(result.content[0].text).toBe("Error listing backlog work items: API Error"); + expect(result.isError).toBe(true); + }); + + it("should handle my_work_items errors", async () => { + configureWorkItemTools(server, tokenProvider, connectionProvider, userAgentProvider); + + const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "wit_my_work_items"); + if (!call) throw new Error("wit_my_work_items tool not registered"); + const [, , , handler] = call; + + (mockWorkApi.getPredefinedQueryResults as jest.Mock).mockRejectedValue(new Error("API Error")); + + const params = { + project: "Contoso", + type: "assignedtome", + top: 50, + includeCompleted: false, + }; + + const result = await handler(params); + + expect(result.content[0].text).toBe("Error retrieving work items: API Error"); + expect(result.isError).toBe(true); + }); + + it("should handle get_work_items_batch_by_ids errors", async () => { + configureWorkItemTools(server, tokenProvider, connectionProvider, userAgentProvider); + + const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "wit_get_work_items_batch_by_ids"); + if (!call) throw new Error("wit_get_work_items_batch_by_ids tool not registered"); + const [, , , handler] = call; + + (mockWorkItemTrackingApi.getWorkItemsBatch as jest.Mock).mockRejectedValue(new Error("API Error")); + + const params = { + ids: [1, 2, 3], + project: "Contoso", + }; + + const result = await handler(params); + + expect(result.content[0].text).toBe("Error retrieving work items batch: API Error"); + expect(result.isError).toBe(true); + }); + + it("should handle get_work_item errors", async () => { + configureWorkItemTools(server, tokenProvider, connectionProvider, userAgentProvider); + + const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "wit_get_work_item"); + if (!call) throw new Error("wit_get_work_item tool not registered"); + const [, , , handler] = call; + + (mockWorkItemTrackingApi.getWorkItem as jest.Mock).mockRejectedValue(new Error("API Error")); + + const params = { + id: 12, + project: "Contoso", + }; + + const result = await handler(params); + + expect(result.content[0].text).toBe("Error retrieving work item: API Error"); + expect(result.isError).toBe(true); + }); + + it("should handle list_work_item_comments errors", async () => { + configureWorkItemTools(server, tokenProvider, connectionProvider, userAgentProvider); + + const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "wit_list_work_item_comments"); + if (!call) throw new Error("wit_list_work_item_comments tool not registered"); + const [, , , handler] = call; + + (mockWorkItemTrackingApi.getComments as jest.Mock).mockRejectedValue(new Error("API Error")); + + const params = { + project: "Contoso", + workItemId: 299, + top: 10, + }; + + const result = await handler(params); + + expect(result.content[0].text).toBe("Error listing work item comments: API Error"); + expect(result.isError).toBe(true); + }); + + it("should handle list_work_item_revisions errors", async () => { + configureWorkItemTools(server, tokenProvider, connectionProvider, userAgentProvider); + + const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "wit_list_work_item_revisions"); + if (!call) throw new Error("wit_list_work_item_revisions tool not registered"); + const [, , , handler] = call; + + (mockWorkItemTrackingApi.getRevisions as jest.Mock).mockRejectedValue(new Error("API Error")); + + const params = { + project: "Contoso", + workItemId: 299, + top: 10, + }; + + const result = await handler(params); + + expect(result.content[0].text).toBe("Error listing work item revisions: API Error"); + expect(result.isError).toBe(true); + }); + + it("should handle get_work_items_for_iteration errors", async () => { + configureWorkItemTools(server, tokenProvider, connectionProvider, userAgentProvider); + + const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "wit_get_work_items_for_iteration"); + if (!call) throw new Error("wit_get_work_items_for_iteration tool not registered"); + const [, , , handler] = call; + + (mockWorkApi.getIterationWorkItems as jest.Mock).mockRejectedValue(new Error("API Error")); + + const params = { + project: "Contoso", + team: "Fabrikam", + iterationId: "abc-123", + }; + + const result = await handler(params); + + expect(result.content[0].text).toBe("Error retrieving work items for iteration: API Error"); + expect(result.isError).toBe(true); + }); + + it("should handle update_work_item errors", async () => { + configureWorkItemTools(server, tokenProvider, connectionProvider, userAgentProvider); + + const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "wit_update_work_item"); + if (!call) throw new Error("wit_update_work_item tool not registered"); + const [, , , handler] = call; + + (mockWorkItemTrackingApi.updateWorkItem as jest.Mock).mockRejectedValue(new Error("API Error")); + + const params = { + id: 131489, + updates: [ + { + op: "Add", + path: "/fields/System.Title", + value: "Updated Sample Task", + }, + ], + }; + + const result = await handler(params); + + expect(result.content[0].text).toBe("Error updating work item: API Error"); + expect(result.isError).toBe(true); + }); + + it("should handle update_work_item with lowercase operation transformation", async () => { + configureWorkItemTools(server, tokenProvider, connectionProvider, userAgentProvider); + + const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "wit_update_work_item"); + if (!call) throw new Error("wit_update_work_item tool not registered"); + const [, , , handler] = call; + + (mockWorkItemTrackingApi.updateWorkItem as jest.Mock).mockResolvedValue([_mockWorkItem]); + + // Test that REMOVE gets transformed to remove by the Zod transform + const params = { + id: 131489, + updates: [ + { + op: "REMOVE", + path: "/fields/System.Description", + value: "", + }, + ], + }; + + const result = await handler(params); + + // The operation value is kept as-is per the implementation + expect(mockWorkItemTrackingApi.updateWorkItem).toHaveBeenCalled(); + expect(result.content[0].text).toBe(JSON.stringify([_mockWorkItem], null, 2)); + }); + + it("should handle get_work_item_type errors", async () => { + configureWorkItemTools(server, tokenProvider, connectionProvider, userAgentProvider); + + const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "wit_get_work_item_type"); + if (!call) throw new Error("wit_get_work_item_type tool not registered"); + const [, , , handler] = call; + + (mockWorkItemTrackingApi.getWorkItemType as jest.Mock).mockRejectedValue(new Error("API Error")); + + const params = { + project: "Contoso", + workItemType: "Bug", + }; + + const result = await handler(params); + + expect(result.content[0].text).toBe("Error retrieving work item type: API Error"); + expect(result.isError).toBe(true); + }); + + it("should handle get_query errors", async () => { + configureWorkItemTools(server, tokenProvider, connectionProvider, userAgentProvider); + + const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "wit_get_query"); + if (!call) throw new Error("wit_get_query tool not registered"); + const [, , , handler] = call; + + (mockWorkItemTrackingApi.getQuery as jest.Mock).mockRejectedValue(new Error("API Error")); + + const params = { + project: "Contoso", + query: "342f0f44-4069-46b1-a940-3d0468979ceb", + depth: 1, + includeDeleted: false, + useIsoDateFormat: false, + }; + + const result = await handler(params); + + expect(result.content[0].text).toBe("Error retrieving query: API Error"); + expect(result.isError).toBe(true); + }); + + it("should handle get_query_results_by_id errors", async () => { + configureWorkItemTools(server, tokenProvider, connectionProvider, userAgentProvider); + + const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "wit_get_query_results_by_id"); + if (!call) throw new Error("wit_get_query_results_by_id tool not registered"); + const [, , , handler] = call; + + (mockWorkItemTrackingApi.queryById as jest.Mock).mockRejectedValue(new Error("API Error")); + + const params = { + id: "342f0f44-4069-46b1-a940-3d0468979ceb", + project: "Contoso", + team: "Fabrikam", + timePrecision: false, + top: 50, + }; + + const result = await handler(params); + + expect(result.content[0].text).toBe("Error retrieving query results: API Error"); + expect(result.isError).toBe(true); + }); + + it("should handle get_query_results_by_id with responseType ids", async () => { + configureWorkItemTools(server, tokenProvider, connectionProvider, userAgentProvider); + + const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "wit_get_query_results_by_id"); + if (!call) throw new Error("wit_get_query_results_by_id tool not registered"); + const [, , , handler] = call; + + const mockQueryResultsWithIds = { + workItems: [{ id: 1 }, { id: 2 }, { id: 3 }], + }; + + (mockWorkItemTrackingApi.queryById as jest.Mock).mockResolvedValue(mockQueryResultsWithIds); + + const params = { + id: "342f0f44-4069-46b1-a940-3d0468979ceb", + project: "Contoso", + responseType: "ids", + }; + + const result = await handler(params); + + const parsedResult = JSON.parse(result.content[0].text); + expect(parsedResult.ids).toEqual([1, 2, 3]); + expect(parsedResult.count).toBe(3); + }); + + it("should handle update_work_items_batch errors", async () => { + configureWorkItemTools(server, tokenProvider, connectionProvider, userAgentProvider); + + const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "wit_update_work_items_batch"); + if (!call) throw new Error("wit_update_work_items_batch tool not registered"); + const [, , , handler] = call; + + mockConnection.serverUrl = "https://dev.azure.com/contoso"; + (tokenProvider as jest.Mock).mockRejectedValue(new Error("Token error")); + + const params = { + updates: [ + { + op: "replace", + id: 1, + path: "/fields/System.Title", + value: "Updated Title", + }, + ], + }; + + const result = await handler(params); + + expect(result.content[0].text).toBe("Error updating work items in batch: Token error"); + expect(result.isError).toBe(true); + }); + + it("should handle work_items_link errors", async () => { + configureWorkItemTools(server, tokenProvider, connectionProvider, userAgentProvider); + + const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "wit_work_items_link"); + if (!call) throw new Error("wit_work_items_link tool not registered"); + const [, , , handler] = call; + + mockConnection.serverUrl = "https://dev.azure.com/contoso"; + (tokenProvider as jest.Mock).mockRejectedValue(new Error("Token error")); + + const params = { + project: "TestProject", + updates: [ + { + id: 1, + linkToId: 2, + type: "related", + }, + ], + }; + + const result = await handler(params); + + expect(result.content[0].text).toBe("Error linking work items: Token error"); + expect(result.isError).toBe(true); + }); + + it("should handle add_artifact_link with unknown error type", async () => { + configureWorkItemTools(server, tokenProvider, connectionProvider, userAgentProvider); + + const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "wit_add_artifact_link"); + if (!call) throw new Error("wit_add_artifact_link tool not registered"); + const [, , , handler] = call; + + mockWorkItemTrackingApi.updateWorkItem.mockRejectedValue("String error"); + + const params = { + workItemId: 1234, + project: "TestProject", + artifactUri: "vstfs:///Git/Ref/invalid", + linkType: "Branch", + }; + + const result = await handler(params); + + expect(result.content[0].text).toBe("Error adding artifact link to work item: Unknown error occurred"); + expect(result.isError).toBe(true); + }); + }); + describe("artifact link tools", () => { describe("wit_add_artifact_link", () => { it("should add artifact link to work item successfully", async () => {