@mcp-consultant-tools/azure-devops 27.0.0-beta.5 → 27.0.0-beta.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/build/index.js CHANGED
@@ -10,11 +10,14 @@ import { realpathSync } from "node:fs";
10
10
  import { createMcpServer, createEnvLoader } from "@mcp-consultant-tools/core";
11
11
  import { AzureDevOpsService } from "./AzureDevOpsService.js";
12
12
  import { z } from 'zod';
13
- import { checkFieldFormats, getConversionInstructions, workItemToMarkdown, commentsToMarkdown, buildPatchOperations, updateSyncRevision, getSyncConfig, ensureFolderExists, getWorkItemFilePath, getCommentsFilePath, fileExists, writeWorkItemFile, readWorkItemFile, readFileContent, listSyncedWorkItems, validateFolderPath, autoCommitMultipleFiles,
13
+ import { descWithExamples, WIQL_EXAMPLES, PATCH_OP_EXAMPLES, WORK_ITEM_FIELD_EXAMPLES, SYNC_TO_FILE_EXAMPLES, SYNC_FROM_FILE_EXAMPLES, SYNC_TASKS_TO_FILE_EXAMPLES, SYNC_TASKS_FROM_FILE_EXAMPLES, } from './tool-examples.js';
14
+ import { checkFieldFormats, workItemToMarkdown, commentsToMarkdown, buildPatchOperations, updateSyncRevision, getSyncConfig, ensureFolderExists, getWorkItemFilePath, getCommentsFilePath, fileExists, writeWorkItemFile, readWorkItemFile, readFileContent, listSyncedWorkItems, validateFolderPath, autoCommitMultipleFiles,
14
15
  // Task sync utilities
15
16
  tasksToMarkdown, parseTasksMarkdown, buildTaskPatchOperations, buildNewTaskFields, getTasksFilePath, updateTasksFileAfterCreate,
16
17
  // New work item utilities
17
- parseNewWorkItemMarkdown, buildNewWorkItemFields, generateNewWorkItemTemplate, convertNewFileToSynced, getNewWorkItemFilePath, findNextNewFileIndex, findNewWorkItemFiles, renameFile, } from './sync/index.js';
18
+ parseNewWorkItemMarkdown, buildNewWorkItemFields, generateNewWorkItemTemplate, convertNewFileToSynced, getNewWorkItemFilePath, findNextNewFileIndex, findNewWorkItemFiles, renameFile,
19
+ // Markdown format utilities
20
+ getAllLargeTextFields, autoConvertFieldsToMarkdown, isHtmlContent, } from './sync/index.js';
18
21
  /**
19
22
  * Register azure-devops tools and prompts to an MCP server
20
23
  * @param server - The MCP server instance
@@ -579,7 +582,7 @@ export function registerAzureDevOpsTools(server, azuredevopsService) {
579
582
  });
580
583
  server.tool("query-work-items", "Query work items using WIQL (Work Item Query Language) in Azure DevOps", {
581
584
  project: z.string().describe("The project name"),
582
- wiql: z.string().describe("The WIQL query string (e.g., \"SELECT [System.Id], [System.Title] FROM WorkItems WHERE [System.State] = 'Active'\")"),
585
+ wiql: z.string().describe(descWithExamples("The WIQL query string. SQL-like syntax with field names in brackets. Common fields: [System.Id], [System.Title], [System.State], [System.WorkItemType], [System.AssignedTo], [System.Parent]. Use @Me for current user.", WIQL_EXAMPLES)),
583
586
  maxResults: z.number().optional().describe("Maximum number of results (default: 200)"),
584
587
  }, async ({ project, wiql, maxResults }) => {
585
588
  try {
@@ -666,24 +669,53 @@ export function registerAzureDevOpsTools(server, azuredevopsService) {
666
669
  };
667
670
  }
668
671
  });
669
- server.tool("update-work-item", "Update a work item in Azure DevOps using JSON Patch operations (requires AZUREDEVOPS_ENABLE_WORK_ITEM_WRITE=true)", {
672
+ server.tool("update-work-item", "Update a work item in Azure DevOps using JSON Patch operations (requires AZUREDEVOPS_ENABLE_WORK_ITEM_WRITE=true). Auto-injects markdown format operations for large text fields.", {
670
673
  project: z.string().describe("The project name"),
671
674
  workItemId: z.number().describe("The work item ID"),
672
675
  patchOperations: z.array(z.object({
673
- op: z.string().describe("The operation type (e.g., 'add', 'replace', 'remove')"),
674
- path: z.string().describe("The field path (e.g., '/fields/System.State')"),
676
+ op: z.string().describe("The operation type: 'add' (set value), 'replace' (update existing), or 'remove' (clear field)"),
677
+ path: z.string().describe("The field path starting with '/fields/' (e.g., '/fields/System.State', '/fields/System.Title')"),
675
678
  value: z.any().optional().describe("The value to set (not required for 'remove' operation)")
676
- })).describe("Array of JSON Patch operations"),
677
- }, async ({ project, workItemId, patchOperations }) => {
679
+ })).describe(descWithExamples("Array of JSON Patch operations. Each operation specifies what to change.", PATCH_OP_EXAMPLES)),
680
+ skipAutoConvert: z.boolean().optional().describe("Skip automatic markdown format injection for large text fields. Only use when explicitly requested. Default: false"),
681
+ }, async ({ project, workItemId, patchOperations, skipAutoConvert }) => {
678
682
  try {
679
683
  const service = getAzureDevOpsService();
680
- const result = await service.updateWorkItem(project, workItemId, patchOperations);
684
+ // Auto-inject markdown format operations for large text fields
685
+ let finalOperations = [...patchOperations];
686
+ const formatOpsAdded = [];
687
+ if (!skipAutoConvert) {
688
+ // Find large text fields being updated (includes custom fields)
689
+ const allLargeTextFields = getAllLargeTextFields();
690
+ const largeTextFieldsBeingUpdated = patchOperations
691
+ .filter((op) => op.path?.startsWith('/fields/'))
692
+ .map((op) => op.path.replace('/fields/', ''))
693
+ .filter((field) => allLargeTextFields.includes(field));
694
+ for (const field of largeTextFieldsBeingUpdated) {
695
+ // Check if format operation already exists
696
+ const hasFormatOp = finalOperations.some((op) => op.path === `/multilineFieldsFormat/${field}`);
697
+ if (!hasFormatOp) {
698
+ // Add format operation to ensure markdown
699
+ finalOperations.push({
700
+ op: 'add',
701
+ path: `/multilineFieldsFormat/${field}`,
702
+ value: 'Markdown'
703
+ });
704
+ formatOpsAdded.push(field);
705
+ }
706
+ }
707
+ }
708
+ const result = await service.updateWorkItem(project, workItemId, finalOperations);
709
+ let message = `Updated work item ${workItemId}`;
710
+ if (formatOpsAdded.length > 0) {
711
+ message += ` (auto-set markdown format for: ${formatOpsAdded.join(', ')})`;
712
+ }
681
713
  const resultStr = JSON.stringify(result, null, 2);
682
714
  return {
683
715
  content: [
684
716
  {
685
717
  type: "text",
686
- text: `Updated work item ${workItemId}:\n\n${resultStr}`,
718
+ text: `${message}:\n\n${resultStr}`,
687
719
  },
688
720
  ],
689
721
  };
@@ -702,8 +734,8 @@ export function registerAzureDevOpsTools(server, azuredevopsService) {
702
734
  });
703
735
  server.tool("create-work-item", "Create a new work item in Azure DevOps with optional parent relationship (requires AZUREDEVOPS_ENABLE_WORK_ITEM_WRITE=true)", {
704
736
  project: z.string().describe("The project name"),
705
- workItemType: z.string().describe("The work item type (e.g., 'Bug', 'Task', 'User Story')"),
706
- fields: z.record(z.any()).describe("Object with field values (e.g., {\"System.Title\": \"Bug title\", \"System.Description\": \"Details\"})"),
737
+ workItemType: z.string().describe("The work item type: 'Bug', 'Task', 'User Story', 'Feature', 'Epic', or custom types"),
738
+ fields: z.record(z.any()).describe(descWithExamples("Object with field values. Required: System.Title. Common fields: System.Description, System.State, System.AssignedTo, Microsoft.VSTS.Common.AcceptanceCriteria, Microsoft.VSTS.TCM.ReproSteps (bugs).", WORK_ITEM_FIELD_EXAMPLES)),
707
739
  parentId: z.number().optional().describe("Optional parent work item ID (for creating child items). Simplified alternative to relations parameter."),
708
740
  relations: z.array(z.object({
709
741
  rel: z.string().describe("Relation type (e.g., 'System.LinkTypes.Hierarchy-Reverse' for parent)"),
@@ -945,16 +977,70 @@ export function registerAzureDevOpsTools(server, azuredevopsService) {
945
977
  }
946
978
  });
947
979
  // ========================================
980
+ // BUILD TROUBLESHOOTING TOOLS (Read-only)
981
+ // NOTE: These tools are duplicated in azure-devops-admin package.
982
+ // If you update these, also update packages/azure-devops-admin/src/index.ts
983
+ // ========================================
984
+ server.tool("get-build-status", "Get build status and details. Use detail='summary' for basic status, 'timeline' for step breakdown (default scope='problems' shows only failed/warning items), or 'full' for logs. The timelineScope controls what records are included: 'problems' (default, only errors/warnings), 'stages' (minimal), 'jobs' (moderate), 'all' (everything).", {
985
+ project: z.string().describe("The project name"),
986
+ buildId: z.number().describe("The build ID"),
987
+ detail: z.enum(["summary", "timeline", "full"]).optional().describe("Level of detail: 'summary' (default), 'timeline' (include steps), or 'full' (include logs)"),
988
+ timelineScope: z.enum(["stages", "jobs", "all", "problems"]).optional().describe("Timeline scope: 'problems' (default, only errors/warnings/failures), 'stages' (minimal), 'jobs' (moderate), 'all' (everything - may be large)"),
989
+ maxIssues: z.number().optional().describe("Maximum issues per record (default: 5, prioritizes errors over warnings)"),
990
+ }, async ({ project, buildId, detail, timelineScope, maxIssues }) => {
991
+ try {
992
+ const service = getAzureDevOpsService();
993
+ const result = await service.getBuildStatus(project, buildId, detail || 'summary', timelineScope || 'problems', maxIssues || 5);
994
+ return { content: [{ type: "text", text: `Build ${buildId} status:\n\n${JSON.stringify(result, null, 2)}` }] };
995
+ }
996
+ catch (error) {
997
+ console.error("Error getting build status:", error);
998
+ return { content: [{ type: "text", text: `Failed to get build status: ${error.message}` }] };
999
+ }
1000
+ });
1001
+ server.tool("get-build-timeline", "Get step-by-step breakdown of a build. Shows stages, jobs, and tasks with timing, status, and error/warning counts. Use scope to control output size: 'problems' (default, only errors/warnings), 'stages' (minimal), 'jobs' (moderate), 'all' (everything). Always includes summary stats regardless of scope.", {
1002
+ project: z.string().describe("The project name"),
1003
+ buildId: z.number().describe("The build ID"),
1004
+ scope: z.enum(["stages", "jobs", "all", "problems"]).optional().describe("Filter scope: 'problems' (default, only errors/warnings/failures), 'stages' (minimal), 'jobs' (moderate), 'all' (everything - may be large)"),
1005
+ maxIssues: z.number().optional().describe("Maximum issues per record (default: 5, prioritizes errors over warnings)"),
1006
+ }, async ({ project, buildId, scope, maxIssues }) => {
1007
+ try {
1008
+ const service = getAzureDevOpsService();
1009
+ const result = await service.getBuildTimeline(project, buildId, scope || 'problems', maxIssues || 5);
1010
+ return { content: [{ type: "text", text: `Build ${buildId} timeline:\n\n${JSON.stringify(result, null, 2)}` }] };
1011
+ }
1012
+ catch (error) {
1013
+ console.error("Error getting build timeline:", error);
1014
+ return { content: [{ type: "text", text: `Failed to get build timeline: ${error.message}` }] };
1015
+ }
1016
+ });
1017
+ server.tool("get-build-logs", "Get build logs. Without logId, returns list of available logs with line counts. With logId, returns that log's content. NOTE: For verbose logs, consider using a sub-agent to analyze the content.", {
1018
+ project: z.string().describe("The project name"),
1019
+ buildId: z.number().describe("The build ID"),
1020
+ logId: z.number().optional().describe("Optional specific log ID to retrieve content"),
1021
+ }, async ({ project, buildId, logId }) => {
1022
+ try {
1023
+ const service = getAzureDevOpsService();
1024
+ const result = await service.getBuildLogs(project, buildId, logId);
1025
+ return { content: [{ type: "text", text: `Build ${buildId} logs:\n\n${JSON.stringify(result, null, 2)}` }] };
1026
+ }
1027
+ catch (error) {
1028
+ console.error("Error getting build logs:", error);
1029
+ return { content: [{ type: "text", text: `Failed to get build logs: ${error.message}` }] };
1030
+ }
1031
+ });
1032
+ // ========================================
948
1033
  // WORK ITEM SYNC TOOLS (Local Markdown)
949
1034
  // ========================================
950
- server.tool("sync-work-item-to-file", "Download work item(s) from ADO and save as local markdown file(s). Token-efficient for editing. Requires markdown format fields (not HTML). Can also pull all child work items under a parent (e.g., all User Stories under a Feature).", {
1035
+ server.tool("sync-work-item-to-file", descWithExamples("Download work item(s) from ADO and save as local markdown file(s). Token-efficient for editing. Auto-converts HTML fields to markdown. Can also pull all child work items under a parent (e.g., all User Stories under a Feature).", SYNC_TO_FILE_EXAMPLES), {
951
1036
  project: z.string().describe("The project name"),
952
1037
  workItemIds: z.array(z.number()).default([]).describe("Work item IDs to pull (optional if using parentId)"),
953
1038
  parentId: z.number().optional().describe("Pull all child work items of this parent (e.g., Feature ID to pull all User Stories)"),
954
- childType: z.string().optional().describe("Filter by work item type when using parentId (default: 'User Story')"),
1039
+ childType: z.string().optional().describe("Filter by work item type when using parentId (default: 'User Story'). Common values: 'User Story', 'Bug', 'Task'"),
955
1040
  folder: z.string().optional().describe("Override folder path (default: docs/user-stories or AZUREDEVOPS_SYNC_FOLDER)"),
956
1041
  includeComments: z.boolean().optional().describe("Also save comments to {id}-comments.md (default: false)"),
957
- }, async ({ project, workItemIds: providedWorkItemIds, parentId, childType, folder, includeComments }) => {
1042
+ skipAutoConvert: z.boolean().optional().describe("Skip automatic HTML-to-markdown conversion. Only use when explicitly requested. Default: false (auto-convert enabled)"),
1043
+ }, async ({ project, workItemIds: providedWorkItemIds, parentId, childType, folder, includeComments, skipAutoConvert }) => {
958
1044
  try {
959
1045
  const service = getAzureDevOpsService();
960
1046
  const syncConfig = getSyncConfig(folder);
@@ -993,21 +1079,44 @@ export function registerAzureDevOpsTools(server, azuredevopsService) {
993
1079
  for (const workItemId of workItemIds) {
994
1080
  try {
995
1081
  // Fetch work item from ADO
996
- const workItem = await service.getWorkItem(project, workItemId);
997
- const revision = workItem.rev || workItem._rev || 1;
1082
+ let workItem = await service.getWorkItem(project, workItemId);
1083
+ let revision = workItem.rev || workItem._rev || 1;
1084
+ let convertedFields = [];
998
1085
  // Check field formats
999
1086
  const formats = checkFieldFormats(workItem);
1000
1087
  if (!formats.ready) {
1001
- const htmlFields = [];
1088
+ if (skipAutoConvert) {
1089
+ // User explicitly requested to skip conversion
1090
+ const htmlFields = [];
1091
+ if (formats.description === 'html')
1092
+ htmlFields.push('Description');
1093
+ if (formats.acceptanceCriteria === 'html')
1094
+ htmlFields.push('Acceptance Criteria');
1095
+ skipped.push({
1096
+ id: workItemId,
1097
+ reason: `HTML fields: ${htmlFields.join(', ')}. skipAutoConvert=true, skipping.`,
1098
+ });
1099
+ continue;
1100
+ }
1101
+ // Auto-convert HTML fields to markdown in ADO
1102
+ const fieldsToConvert = [];
1002
1103
  if (formats.description === 'html')
1003
- htmlFields.push('Description');
1104
+ fieldsToConvert.push('System.Description');
1004
1105
  if (formats.acceptanceCriteria === 'html')
1005
- htmlFields.push('Acceptance Criteria');
1006
- skipped.push({
1007
- id: workItemId,
1008
- reason: `HTML fields: ${htmlFields.join(', ')}. Convert to markdown in ADO first.`,
1009
- });
1010
- continue;
1106
+ fieldsToConvert.push('Microsoft.VSTS.Common.AcceptanceCriteria');
1107
+ try {
1108
+ convertedFields = await autoConvertFieldsToMarkdown(service, project, workItemId, workItem.fields, fieldsToConvert);
1109
+ // Re-fetch work item with converted content
1110
+ workItem = await service.getWorkItem(project, workItemId);
1111
+ revision = workItem.rev || workItem._rev || 1;
1112
+ }
1113
+ catch (convertError) {
1114
+ skipped.push({
1115
+ id: workItemId,
1116
+ reason: `Failed to auto-convert HTML: ${convertError.message}`,
1117
+ });
1118
+ continue;
1119
+ }
1011
1120
  }
1012
1121
  // Convert to markdown and save
1013
1122
  const { content: markdown, skippedFields: secondarySkipped } = workItemToMarkdown(workItem, revision);
@@ -1017,6 +1126,7 @@ export function registerAzureDevOpsTools(server, azuredevopsService) {
1017
1126
  id: workItemId,
1018
1127
  file: filePath,
1019
1128
  revision,
1129
+ ...(convertedFields.length > 0 ? { converted: convertedFields } : {}),
1020
1130
  ...(secondarySkipped.length > 0 ? { skippedFields: secondarySkipped } : {}),
1021
1131
  });
1022
1132
  filesToCommit.push({ filePath, workItemId });
@@ -1068,11 +1178,12 @@ export function registerAzureDevOpsTools(server, azuredevopsService) {
1068
1178
  };
1069
1179
  }
1070
1180
  });
1071
- server.tool("sync-work-item-from-file", "Upload local markdown changes back to ADO. Auto-detects new_*.md files and creates them as new work items. Requires AZUREDEVOPS_ENABLE_WORK_ITEM_WRITE=true.", {
1181
+ server.tool("sync-work-item-from-file", descWithExamples("Upload local markdown changes back to ADO. Auto-detects new_*.md files and creates them as new work items. Auto-converts HTML fields to markdown. Requires AZUREDEVOPS_ENABLE_WORK_ITEM_WRITE=true.", SYNC_FROM_FILE_EXAMPLES), {
1072
1182
  project: z.string().describe("The project name"),
1073
1183
  workItemIds: z.array(z.number()).default([]).describe("Work item IDs to push (optional - new_*.md files are auto-detected)"),
1074
1184
  folder: z.string().optional().describe("Override folder path (default: docs/user-stories or AZUREDEVOPS_SYNC_FOLDER)"),
1075
- }, async ({ project, workItemIds, folder }) => {
1185
+ skipAutoConvert: z.boolean().optional().describe("Skip automatic HTML-to-markdown conversion. Only use when explicitly requested. Default: false (auto-convert enabled)"),
1186
+ }, async ({ project, workItemIds, folder, skipAutoConvert }) => {
1076
1187
  try {
1077
1188
  const service = getAzureDevOpsService();
1078
1189
  const syncConfig = getSyncConfig(folder);
@@ -1138,8 +1249,8 @@ export function registerAzureDevOpsTools(server, azuredevopsService) {
1138
1249
  const oldRevision = parsed.frontmatter.lastSyncedRevision;
1139
1250
  // Fetch current work item from ADO to compare
1140
1251
  const currentWorkItem = await service.getWorkItem(project, workItemId);
1141
- // Build patch operations
1142
- const { operations, skippedFields } = buildPatchOperations(parsed, currentWorkItem);
1252
+ // Build patch operations (auto-converts HTML fields unless skipAutoConvert)
1253
+ const { operations, skippedFields, convertedFields } = buildPatchOperations(parsed, currentWorkItem, skipAutoConvert);
1143
1254
  if (operations.length === 0 && skippedFields.length === 0) {
1144
1255
  // No changes
1145
1256
  pushed.push({
@@ -1156,7 +1267,10 @@ export function registerAzureDevOpsTools(server, azuredevopsService) {
1156
1267
  if (operations.length > 0) {
1157
1268
  const updatedWorkItem = await service.updateWorkItem(project, workItemId, operations);
1158
1269
  newRevision = updatedWorkItem.rev || updatedWorkItem._rev || oldRevision + 1;
1159
- fieldsUpdated = operations.map(op => op.path.replace('/fields/', ''));
1270
+ // Filter out format operations from fieldsUpdated
1271
+ fieldsUpdated = operations
1272
+ .filter(op => op.path.startsWith('/fields/'))
1273
+ .map(op => op.path.replace('/fields/', ''));
1160
1274
  // Update local file with new revision
1161
1275
  const content = await readFileContent(filePath);
1162
1276
  const updatedContent = updateSyncRevision(content, newRevision);
@@ -1169,6 +1283,7 @@ export function registerAzureDevOpsTools(server, azuredevopsService) {
1169
1283
  newRevision,
1170
1284
  fieldsUpdated,
1171
1285
  skippedFields,
1286
+ ...(convertedFields.length > 0 ? { convertedFields } : {}),
1172
1287
  });
1173
1288
  }
1174
1289
  else {
@@ -1177,6 +1292,7 @@ export function registerAzureDevOpsTools(server, azuredevopsService) {
1177
1292
  oldRevision,
1178
1293
  newRevision,
1179
1294
  fieldsUpdated,
1295
+ ...(convertedFields.length > 0 ? { convertedFields } : {}),
1180
1296
  });
1181
1297
  }
1182
1298
  }
@@ -1265,7 +1381,10 @@ export function registerAzureDevOpsTools(server, azuredevopsService) {
1265
1381
  ready: readyCount,
1266
1382
  needsConversion: needsConversionCount,
1267
1383
  },
1268
- conversionInstructions: needsConversionCount > 0 ? getConversionInstructions() : undefined,
1384
+ autoConvertAvailable: true,
1385
+ message: needsConversionCount === 0
1386
+ ? 'All work items are markdown format - ready to sync'
1387
+ : `${needsConversionCount} work item(s) have HTML fields. Will be auto-converted to markdown on sync (unless skipAutoConvert=true).`,
1269
1388
  };
1270
1389
  return {
1271
1390
  content: [{
@@ -1365,11 +1484,12 @@ export function registerAzureDevOpsTools(server, azuredevopsService) {
1365
1484
  // ========================================
1366
1485
  // TASK SYNC TOOLS
1367
1486
  // ========================================
1368
- server.tool("sync-tasks-to-file", "Download all tasks under a parent work item (User Story) to a local markdown file. Supports pulling tasks for multiple parents at once.", {
1487
+ server.tool("sync-tasks-to-file", descWithExamples("Download all tasks under a parent work item (User Story) to a local markdown file. Auto-converts HTML descriptions to markdown. Supports pulling tasks for multiple parents at once.", SYNC_TASKS_TO_FILE_EXAMPLES), {
1369
1488
  project: z.string().describe("The project name"),
1370
1489
  parentIds: z.array(z.number()).describe("Parent work item IDs (User Stories) to fetch tasks for"),
1371
1490
  folder: z.string().optional().describe("Override folder path (default: docs/user-stories or AZUREDEVOPS_SYNC_FOLDER)"),
1372
- }, async ({ project, parentIds, folder }) => {
1491
+ skipAutoConvert: z.boolean().optional().describe("Skip automatic HTML-to-markdown conversion for task descriptions. Only use when explicitly requested. Default: false"),
1492
+ }, async ({ project, parentIds, folder, skipAutoConvert }) => {
1373
1493
  try {
1374
1494
  const service = getAzureDevOpsService();
1375
1495
  const syncConfig = getSyncConfig(folder);
@@ -1386,12 +1506,26 @@ export function registerAzureDevOpsTools(server, azuredevopsService) {
1386
1506
  // Query for child tasks using WIQL
1387
1507
  const wiql = `SELECT [System.Id] FROM WorkItems WHERE [System.Parent] = ${parentId} AND [System.WorkItemType] = 'Task' ORDER BY [System.Id] ASC`;
1388
1508
  const queryResult = await service.queryWorkItems(project, wiql, 200);
1389
- // Fetch full details for each task
1509
+ // Fetch full details for each task and auto-convert HTML descriptions
1390
1510
  const tasks = [];
1511
+ let tasksConverted = 0;
1391
1512
  if (queryResult.workItems && queryResult.workItems.length > 0) {
1392
1513
  for (const wi of queryResult.workItems) {
1393
1514
  try {
1394
- const task = await service.getWorkItem(project, wi.id);
1515
+ let task = await service.getWorkItem(project, wi.id);
1516
+ // Auto-convert HTML description to markdown unless skipAutoConvert
1517
+ const description = task.fields?.['System.Description'];
1518
+ if (description && !skipAutoConvert && isHtmlContent(description)) {
1519
+ try {
1520
+ await autoConvertFieldsToMarkdown(service, project, wi.id, task.fields, ['System.Description']);
1521
+ // Re-fetch with converted content
1522
+ task = await service.getWorkItem(project, wi.id);
1523
+ tasksConverted++;
1524
+ }
1525
+ catch (convertError) {
1526
+ console.error(`Failed to convert HTML description for task ${wi.id}:`, convertError.message);
1527
+ }
1528
+ }
1395
1529
  tasks.push(task);
1396
1530
  }
1397
1531
  catch (taskError) {
@@ -1443,11 +1577,12 @@ export function registerAzureDevOpsTools(server, azuredevopsService) {
1443
1577
  };
1444
1578
  }
1445
1579
  });
1446
- server.tool("sync-tasks-from-file", "Push local task changes back to ADO with upsert semantics. Existing tasks (## Task #ID) are updated, new tasks (## NEW TASK) are created. Requires AZUREDEVOPS_ENABLE_WORK_ITEM_WRITE=true.", {
1580
+ server.tool("sync-tasks-from-file", descWithExamples("Push local task changes back to ADO with upsert semantics. Existing tasks (## Task #ID) are updated, new tasks (## NEW TASK) are created. Auto-converts HTML fields to markdown. Requires AZUREDEVOPS_ENABLE_WORK_ITEM_WRITE=true.", SYNC_TASKS_FROM_FILE_EXAMPLES), {
1447
1581
  project: z.string().describe("The project name"),
1448
1582
  parentIds: z.array(z.number()).describe("Parent work item IDs to sync tasks for"),
1449
1583
  folder: z.string().optional().describe("Override folder path (default: docs/user-stories or AZUREDEVOPS_SYNC_FOLDER)"),
1450
- }, async ({ project, parentIds, folder }) => {
1584
+ skipAutoConvert: z.boolean().optional().describe("Skip automatic HTML-to-markdown conversion for task descriptions. Only use when explicitly requested. Default: false"),
1585
+ }, async ({ project, parentIds, folder, skipAutoConvert }) => {
1451
1586
  try {
1452
1587
  const service = getAzureDevOpsService();
1453
1588
  const syncConfig = getSyncConfig(folder);
@@ -1477,15 +1612,16 @@ export function registerAzureDevOpsTools(server, azuredevopsService) {
1477
1612
  for (const task of parsed.tasks) {
1478
1613
  try {
1479
1614
  if (task.id !== null) {
1480
- // Existing task - update
1615
+ // Existing task - update (auto-converts HTML fields unless skipAutoConvert)
1481
1616
  const currentTask = await service.getWorkItem(project, task.id);
1482
- const { operations, fieldsUpdated } = buildTaskPatchOperations(task, currentTask);
1617
+ const { operations, fieldsUpdated, convertedFields } = buildTaskPatchOperations(task, currentTask, skipAutoConvert);
1483
1618
  if (operations.length > 0) {
1484
1619
  await service.updateWorkItem(project, task.id, operations);
1485
1620
  updated.push({
1486
1621
  id: task.id,
1487
1622
  parentId,
1488
1623
  fieldsUpdated,
1624
+ ...(convertedFields.length > 0 ? { convertedFields } : {}),
1489
1625
  });
1490
1626
  }
1491
1627
  else {
@@ -1557,9 +1693,18 @@ export function registerAzureDevOpsTools(server, azuredevopsService) {
1557
1693
  }
1558
1694
  });
1559
1695
  // Log registration summary (enablePullRequestWrite already defined above)
1560
- const syncToolsCount = 4; // Work item sync tools
1561
- const taskSyncToolsCount = 2; // Task sync tools
1562
- const baseToolsCount = 21 + syncToolsCount + taskSyncToolsCount; // 15 original + 6 PR read-only + 4 work item sync + 2 task sync
1696
+ // Tool count breakdown:
1697
+ // - Wiki: 6 (get-wikis, search-wiki-pages, get-wiki-page, create-wiki-page, update-wiki-page, str-replace-wiki-page)
1698
+ // - Work Item: 7 (get-work-item, query-work-items, get-work-item-comments, add-work-item-comment, update-work-item, create-work-item, delete-work-item)
1699
+ // - Variable Group: 2 (list-variable-groups, get-variable-group)
1700
+ // - PR Read-only: 6 (list-repositories, list-pull-requests, get-pull-request, get-pull-request-threads, get-pull-request-commits, get-pull-request-changes)
1701
+ // - Build Troubleshooting: 3 (get-build-status, get-build-timeline, get-build-logs)
1702
+ // - Work Item Sync: 5 (sync-work-item-to-file, sync-work-item-from-file, check-work-item-markdown, list-synced-work-items, create-user-story-file)
1703
+ // - Task Sync: 2 (sync-tasks-to-file, sync-tasks-from-file)
1704
+ // - Config: 1 (get-configuration)
1705
+ // Total base: 6 + 7 + 2 + 6 + 3 + 5 + 2 + 1 = 32
1706
+ // + PR Write: 1 (add-pull-request-thread) - conditional
1707
+ const baseToolsCount = 32;
1563
1708
  const prWriteToolsCount = enablePullRequestWrite ? 1 : 0;
1564
1709
  const totalToolsCount = baseToolsCount + prWriteToolsCount;
1565
1710
  console.error(`azure-devops tools registered: ${totalToolsCount} tools (${baseToolsCount} base + ${prWriteToolsCount} PR write), 4 prompts`);